Wraps Apollo Server testing patterns: `server.executeOperation()` (in-process, no HTTP), `supertest` against an ephemeral-port HTTP server (port 0), context injection via the `contextValue` second-argument, and assertion patterns for response shape + errors. Includes the production-config gates testable through this skill - introspection-disabled, persisted-query mode, hideSchemaDetailsFromClientErrors. Use when writing tests for an Apollo Server v4+ GraphQL service.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
executeOperation initializes automatically - no startup needed for
unit-style tests against the schema in-process. For HTTP-layer tests
(CORS, middleware, response headers), use supertest against an
ephemeral-port server.
executeOperation.supertest.npm install --save-dev @apollo/server supertest @types/supertestexecuteOperationimport { ApolloServer } from '@apollo/server';
import { typeDefs, resolvers } from './schema';
const testServer = new ApolloServer({ typeDefs, resolvers });
test('returns greeting', async () => {
const response = await testServer.executeOperation({
query: 'query SayHelloWorld($name: String) { hello(name: $name) }',
variables: { name: 'world' },
});
// Parse/validation/execution errors surface in `errors`, not thrown
if (response.body.kind !== 'single') throw new Error('expected single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.hello).toBe('Hello world!');
});const res = await testServer.executeOperation(
{ query: GET_LAUNCH, variables: { id: 1 } },
{
contextValue: {
user: { id: 1, email: 'a@a.a' },
dataSources: { userAPI, launchAPI },
},
},
);contextValue bypasses the production context function (which parses headers).
import { startStandaloneServer } from '@apollo/server/standalone';
import request from 'supertest';
let server: ApolloServer;
let url: string;
beforeAll(async () => {
server = new ApolloServer({ typeDefs, resolvers });
({ url } = await startStandaloneServer(server, {
listen: { port: 0 }, // OS picks port → parallel-test safe
}));
});
afterAll(async () => {
await server?.stop();
});
it('says hello over HTTP', async () => {
const response = await request(url)
.post('/')
.send({ query: '{ hello }' });
expect(response.status).toBe(200);
expect(response.body.data?.hello).toBeDefined();
});npm test # jest / vitest pick up *.test.ts
npx jest schema.test.ts -t "introspection"Assert prod-time security defaults under NODE_ENV=production:
import { ApolloServer } from '@apollo/server';
test('introspection disabled when production', async () => {
process.env.NODE_ENV = 'production';
const prodServer = new ApolloServer({
typeDefs, resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
const resp = await prodServer.executeOperation({
query: '{ __schema { types { name } } }',
});
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(resp.body.singleResult.errors).toBeDefined();
expect(resp.body.singleResult.errors?.[0].message).toMatch(/introspection/i);
});
test('hideSchemaDetailsFromClientErrors strips did-you-mean', async () => {
const server = new ApolloServer({
typeDefs, resolvers,
hideSchemaDetailsFromClientErrors: true,
});
const resp = await server.executeOperation({
query: '{ usre { id } }', // typo
});
if (resp.body.kind !== 'single') throw new Error('expected single');
expect(JSON.stringify(resp.body.singleResult.errors)).not.toMatch(/did you mean/i);
});Strict allowlist (APQ) mode:
test('strict APQ rejects unregistered hash', async () => {
const server = new ApolloServer({
typeDefs, resolvers,
persistedQueries: false, // turn off auto-register
plugins: [/* strict-allowlist plugin from manifest */],
});
const { url } = await startStandaloneServer(server, { listen: { port: 0 } });
const resp = await request(url).post('/').send({
extensions: { persistedQuery: { version: 1, sha256Hash: 'deadbeef'.repeat(8) } },
});
expect(resp.body.errors[0].extensions.code).toMatch(/PERSISTED_QUERY/);
});The executeOperation response shape is discriminated:
type ExecuteOperationResult =
| { body: { kind: 'single'; singleResult: { data?: ...; errors?: ... } } }
| { body: { kind: 'incremental'; ... } };Always check kind === 'single' first. The errors array contains
GraphQLError objects with message, path, extensions.code
(e.g., 'BAD_USER_INPUT', 'UNAUTHENTICATED',
'PERSISTED_QUERY_NOT_FOUND').
For supertest: standard HTTP response; response.body.errors if
GraphQL-level error, response.status if transport error.
# .github/workflows/graphql-tests.yml
name: graphql
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- name: Production-config assertions
env:
NODE_ENV: production
run: npx jest tests/production-config/ --forceExitThe production-config jobs run separately with NODE_ENV=production
so the actual prod-time defaults are exercised.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Tests in NODE_ENV=test only | Production defaults differ; introspection-disabled gate untested | Separate prod-config test job |
executeOperation for HTTP-layer concerns | Skips middleware, CORS, headers | Use supertest for HTTP-layer |
Hardcoded port 4000 in tests | Parallel CI conflicts | port: 0 |
Forgetting server.stop() in afterAll | Connection leak across tests | Always stop |
Asserting errors[0].message string | Brittle to wording / i18n | Assert extensions.code |
Skipping contextValue injection | Tests use real auth headers; flaky | Inject mocked context per test |
| One mega-test for the whole schema | Failures hard to diagnose | One test per resolver / operation |
data access without checking errors | Errors masked; false positives | Check errors === undefined first |
executeOperation skips HTTP. Auth via headers, CORS,
rate-limiting middleware are not exercised. Use supertest for those.graphql-ws test patterns.