graphql-subscription-test-author
Authors GraphQL subscription resolver test suites over graphql-ws (WebSocket) and graphql-sse (Server-Sent Events) transports: subscribe to event streams via the async-iterator API, assert emitted data shape and sequence, verify connection lifecycle and protocol close codes, and validate auth-on-connect (connectionParams / authenticate callback) plus resolver-level pubsub trigger logic. Use for real-time subscription operations; not for queries or mutations - for those use apollo-server-tests, graphql-yoga-tests, or mercurius-tests.
Install with skills.sh (any agent)
npx skills add testland/qa --skill graphql-subscription-test-authorgraphql-subscription-test-author
Overview
Per the GraphQL October 2021 spec (section 6.3, blocked by Cloudflare Turnstile - cite by stable ID "GraphQL October 2021 spec, Section 6.3: Subscription"), a subscription operation must: select a single root field, return an event stream, and emit one result per event. Each emitted result is executed independently against the schema, exactly like a query.
This skill covers testing the transport and resolver layers for subscriptions. The two most common Node.js transports are:
Both expose an identical async-iterator surface via client.iterate(), making the same test patterns portable across transports.
When to use
Distinct scope vs. sibling skills:
How to use
Authoring
Install
# WS transport
npm install --save-dev graphql-ws ws @types/ws
# SSE transport
npm install --save-dev graphql-sse
# Shared test utilities
npm install --save-dev graphql jest ts-jestServer setup
Full server bootstraps for both transports (graphql-ws over ws, graphql-sse over Node http), including the port: 0 free-port pattern, are in references/transport-server-setup.md.
Basic subscription test (graphql-ws)
Per the-guild.dev/graphql/ws/get-started (opens in new window): both queries and subscriptions use client.iterate(), which returns an async iterator.
import { createClient } from 'graphql-ws';
describe('greetings subscription', () => {
let wss: ReturnType<typeof startWsServer>;
let client: ReturnType<typeof createClient>;
beforeAll(() => {
wss = startWsServer();
const addr = wss.wss.address() as { port: number };
client = createClient({ url: `ws://localhost:${addr.port}/graphql` });
});
afterAll(async () => {
client.dispose();
await wss.dispose();
wss.wss.close();
});
it('streams three greetings then completes', async () => {
const results: unknown[] = [];
const sub = client.iterate({ query: 'subscription { greetings }' });
for await (const event of sub) {
results.push(event.data);
if (results.length === 3) break; // break closes the stream
}
expect(results).toEqual([
{ greetings: 'Hi' },
{ greetings: 'Bonjour' },
{ greetings: 'Hola' },
]);
});
});Basic subscription test (graphql-sse)
Per the-guild.dev/graphql/sse/get-started (opens in new window):
import { createClient } from 'graphql-sse';
it('receives events over SSE', async () => {
const { server, url } = startSseServer();
const client = createClient({ url });
const results: unknown[] = [];
const sub = client.iterate({ query: 'subscription { greetings }' });
for await (const event of sub) {
results.push(event.data);
if (results.length === 1) break;
}
expect(results[0]).toEqual({ greetings: 'Hi' });
server.close();
});Auth on connect
connectionParams (WS) and the authenticate callback (SSE) reject unauthenticated clients before any event is sent. Full server + test patterns for both transports, plus the async token-refresh factory, are in references/auth-on-connect.md.
Connection lifecycle and close codes
The graphql-ws protocol close-code table (4400 - 4429) and the ordered connecting -> connected -> closed lifecycle assertion are in references/lifecycle-and-close-codes.md.
Resolver, filter, and error tests
Resolver-isolation pubsub tests (via graphql subscribe()), event-filter predicate tests, and resolver-error propagation are in references/resolver-and-filter-tests.md.
Worked example
Scenario: a messageAdded(channel) subscription must stream only messages for the subscribed channel and must reject clients that connect without a token.
Result: one run proves both the resolver filter (only the matching channel emits) and the auth gate (tokenless connect is refused with 4403).
Running
npm test # jest / vitest pick up *.test.ts
npx jest subscriptions/ --verboseSet a testTimeout for subscriptions - default 5 s is tight when the event loop needs to process WS frames:
// jest.config.ts
export default { testTimeout: 15_000 };Parsing results
Both graphql-ws and graphql-sse client.iterate() yield objects shaped { data?: T; errors?: GraphQLError[] }. Check errors before asserting data:
const { value } = await sub.next();
expect(value.errors).toBeUndefined();
expect(value.data?.greetings).toBeDefined();For the graphql package's subscribe() function, the iterator yields ExecutionResult objects. Multipart/incremental results are separate from standard subscription results.
CI integration
# .github/workflows/graphql-subscription-tests.yml
name: graphql-subscriptions
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: npx jest subscriptions/ --forceExit --testTimeout=15000--forceExit prevents Jest from hanging on open WS connections if a test fails before dispose() or close() is called.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Hardcoded ws://localhost:4000 | Port conflicts in parallel CI | port: 0, read back the OS-assigned port |
No dispose() / close() in afterAll | Open WS/SSE connections prevent Jest from exiting | Always dispose client and close server |
retryAttempts not set to 0 in rejection tests | Default retry masks the 4403 close event | Set retryAttempts: 0 for auth-rejection assertions |
Asserting data without checking errors | Masked errors produce false positives | Check errors === undefined before asserting data |
Using executeOperation for subscription tests | Apollo in-process runner does not start a WS server | Use transport-layer client with createClient |
| Single combined test for subscribe + auth + filter | Failures hard to diagnose | One test per behavior |
| Testing only the transport, not the resolver | Resolver pubsub logic goes untested | Combine graphql.subscribe() unit tests with transport integration tests |
Limitations
References
Auth on connect
View source (opens in new window)Auth on connect
Reject unauthenticated clients before any event is sent: connectionParams on the WS client, the authenticate callback on the SSE handler.
Auth on connect (graphql-ws)
Per the-guild.dev/graphql/ws/recipes (opens in new window), onConnect returns false to close with code 4403: Forbidden:
// Server
useServer(
{
schema,
onConnect: async (ctx) => {
if (!(await isTokenValid(ctx.connectionParams?.token))) {
return false; // closes with 4403
}
},
},
wss,
);
// Test: reject missing token
it('closes with 4403 when token absent', (done) => {
const badClient = createClient({
url: `ws://localhost:${port}/graphql`,
connectionParams: {}, // no token
retryAttempts: 0,
on: {
closed: (event) => {
expect((event as CloseEvent).code).toBe(4403);
done();
},
},
});
badClient.subscribe({ query: 'subscription { greetings }' }, {
next: () => {},
error: () => {},
complete: () => {},
});
});
// Test: accept valid token
it('receives events when token valid', async () => {
const authedClient = createClient({
url: `ws://localhost:${port}/graphql`,
connectionParams: { token: 'valid-token' },
});
const sub = authedClient.iterate({ query: 'subscription { greetings }' });
const { value } = await sub.next();
expect(value?.data).toBeDefined();
authedClient.dispose();
});Per the-guild.dev/graphql/ws/recipes (opens in new window), connectionParams supports async factories for token refresh:
const client = createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: async () => ({ token: await getAccessToken() }),
on: {
closed: (event) => {
if ((event as CloseEvent).code === 4403) scheduleTokenRefresh();
},
},
});Auth on connect (graphql-sse)
Per the-guild.dev/graphql/sse/recipes (opens in new window), the authenticate callback on createHandler returns [null, response] to reject:
const handler = createHandler({
schema,
authenticate: async (req) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token || !(await isTokenValid(token))) {
return [null, { status: 401, statusText: 'Unauthorized' }];
}
return token;
},
});
it('rejects unauthenticated SSE connections with 401', async () => {
const client = createClient({
url,
headers: () => ({ authorization: 'Bearer bad-token' }),
});
const sub = client.iterate({ query: 'subscription { greetings }' });
await expect(sub.next()).rejects.toMatchObject({ message: /401/ });
});Connection lifecycle and close codes
View source (opens in new window)Connection lifecycle and close codes
Protocol close codes (graphql-ws)
Per the-guild.dev/graphql/ws/docs (opens in new window), the on option in ClientOptions accepts event-keyed callbacks. Protocol close codes are defined by the graphql-ws spec:
| Code | Meaning |
|---|---|
| 4400 | Bad request / invalid message |
| 4401 | Unauthorized (no ConnectionInit before timeout) |
| 4403 | Forbidden (server onConnect returned false) |
| 4408 | Connection initialisation timeout |
| 4409 | Subscriber already exists for that id |
| 4429 | Too many initialisation requests |
Lifecycle assertion (graphql-ws)
const events: string[] = [];
const client = createClient({
url,
connectionParams: { token: 'valid' },
on: {
connecting: () => events.push('connecting'),
connected: () => events.push('connected'),
closed: () => events.push('closed'),
error: () => events.push('error'),
},
});
const sub = client.iterate({ query: 'subscription { greetings }' });
await sub.next(); // wait for first event - connection must be open
await sub.return?.(); // graceful close via iterator return
// Allow close event to fire
await new Promise((r) => setTimeout(r, 50));
expect(events).toEqual(['connecting', 'connected', 'closed']);Resolver, filter, and error tests
View source (opens in new window)Resolver, filter, and error tests
Isolate the resolver's pubsub wiring from the transport stack, then assert the filter predicate and error propagation.
Resolver-level pubsub test
Isolate the resolver's pubsub wiring without a full transport stack using the graphql subscribe function directly:
import { subscribe, parse } from 'graphql';
import { schema, pubsub } from './schema';
it('resolver emits events published to the channel', async () => {
const result = await subscribe({
schema,
document: parse('subscription { messageAdded { id text } }'),
});
if ('errors' in result) throw new Error('Subscription failed');
// Publish after subscribing
pubsub.publish('MESSAGE_ADDED', { messageAdded: { id: '1', text: 'hello' } });
const { value } = await result.next();
expect(value.data).toEqual({ messageAdded: { id: '1', text: 'hello' } });
await result.return?.(); // clean up iterator
});This tests the resolver in isolation - no WebSocket server, no client library. Pair with transport-layer tests for full coverage.
Event-sequence and filter tests
it('only emits events that pass the filter predicate', async () => {
const sub = client.iterate({
query: 'subscription Messages($channel: String!) { messageAdded(channel: $channel) { text } }',
variables: { channel: 'team-a' },
});
pubsub.publish('MESSAGE_ADDED', { channel: 'team-b', messageAdded: { text: 'wrong' } });
pubsub.publish('MESSAGE_ADDED', { channel: 'team-a', messageAdded: { text: 'right' } });
const { value } = await sub.next();
expect(value.data?.messageAdded?.text).toBe('right');
await sub.return?.();
});Error propagation tests
Per the-guild.dev/graphql/sse/recipes (opens in new window), resolver errors during a subscription should surface via the iterator, not crash the server:
it('surfaces resolver errors as GraphQL errors, not exceptions', async () => {
const result = await subscribe({
schema: errorSchema, // schema whose subscription resolver throws
document: parse('subscription { failingFeed }'),
});
if ('errors' in result) throw new Error('Subscribe itself failed');
const { value } = await result.next();
expect(value.errors).toBeDefined();
expect(value.errors?.[0].message).toMatch(/expected error/i);
});Transport server setup
View source (opens in new window)Transport server setup
Both transports expose an identical async-iterator surface via client.iterate(), so the same test patterns are portable once the server is up.
Server setup (graphql-ws over ws)
Per the-guild.dev/graphql/ws/get-started (opens in new window):
import { useServer } from 'graphql-ws/use/ws';
import { WebSocketServer } from 'ws';
import { schema } from './schema';
export function startWsServer(port = 0) {
const wss = new WebSocketServer({ port });
const dispose = useServer({ schema }, wss);
return { wss, dispose };
}Use port: 0 so the OS assigns a free port - parallel-test safe.
Server setup (graphql-sse over Node http)
Per the-guild.dev/graphql/sse/get-started (opens in new window):
import { createServer } from 'http';
import { createHandler } from 'graphql-sse/lib/use/http';
import { schema } from './schema';
export function startSseServer() {
const handler = createHandler({ schema });
const server = createServer((req, res) => {
if (req.url === '/graphql/stream') return handler(req, res);
res.writeHead(404).end();
});
server.listen(0);
const { port } = server.address() as { port: number };
return { server, url: `http://localhost:${port}/graphql/stream` };
}Related skills
apollo-server-tests
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.
graphql-complexity-limit-tester
Crafts over-limit depth and complexity queries then asserts rejection before execution, verifying that graphql-depth-limit, graphql-cost-analysis, and graphql-armor (max-depth / cost-limit / max-tokens plugins) are actually enforced and not just configured. Use when auditing a GraphQL service for DoS exposure after depth or cost limits have been added as mitigations, or when adding tests that prove the limits in CI before a production deployment.
graphql-n-plus-one-remediation
Traces a GraphQL resolver tree to locate the N+1 pattern (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when a list-returning resolver is added or changed in review, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.
graphql-yoga-tests
Tests a GraphQL Yoga server (the-guild.dev runtime) with `yoga.fetch()` for in-process, no-network request simulation of queries and mutations, `@graphql-tools/executor-http` for subscription and incremental-delivery (streaming) tests, auth-header pass-through, and production-config gates for disabled introspection and persisted operations. Use to test a GraphQL Yoga server, write Yoga query, mutation, or subscription tests, or check its production plugin config; for a different runtime harness use apollo-server-tests, mercurius-tests, or hasura-tests instead, not this skill.
hasura-tests
Wraps Hasura GraphQL Engine testing patterns: docker-compose test instance, the metadata API for declarative schema/permission setup, x-hasura-role and x-hasura-user-id session headers for role-based permission tests, the v1/graphql endpoint via curl/HTTPie/native HTTP clients, and the role-by-table-by-operation permission-matrix pattern. Use for a metadata-driven Hasura engine where row-level permissions dominate; for a code-first server runtime harness use graphql-yoga-tests, apollo-server-tests, or mercurius-tests instead, not this skill.
introspection-attack-surface-reference
Pure-reference catalog of GraphQL introspection as an attack surface and the production-deployment controls for it. Covers what introspection exposes (every type, field, directive, deprecation, description via __schema / __type), Apollo Server's default behaviour (introspection: false when NODE_ENV=production), the `hideSchemaDetailsFromClientErrors: true` companion setting (strips 'did you mean' suggestions), Yoga / Mercurius / Hasura equivalents, query-depth + query-cost limits, persisted-query allowlisting as the strongest mitigation, and the testable behaviours each control creates. Use when designing the production-safety posture of a GraphQL server or auditing an existing deployment.
mercurius-tests
Wraps Mercurius (Fastify GraphQL plugin) testing patterns: `app.inject()` for HTTP-layer simulation without spinning up a network listener, plugin-registration setup (await app.register(mercurius, { schema, resolvers, graphiql: false })), production-config gates (graphiql: false; jit threshold; query depth limits via fastify-rate-limit + complexity), and the per-test app lifecycle (app.close() in afterEach). Use when writing tests for a Fastify + Mercurius GraphQL server.
persisted-query-strategy-reference
Pure-reference catalog of GraphQL Persisted Query strategies: the Apollo APQ SHA-256 hash protocol, the PersistedQueryNotFoundError retry flow, the `extensions.persistedQuery` payload, GET-vs-POST and CDN-cache implications, and the three operation modes (auto-register, strict allowlist, hybrid). Use to design or audit a server's persisted-query request layer. This strategy reference emits no tests; to author the runtime tests use apollo-server-tests, graphql-yoga-tests, or mercurius-tests, and for introspection lockdown see introspection-attack-surface-reference.
pothos-builder-tests
Wraps Pothos GraphQL schema-builder testing patterns: testing the SchemaBuilder output (lexicographicSortSchema + printSchema for snapshot tests), testing resolvers via the standard `graphql()` function from graphql-js (no server needed), integration with Apollo Server / GraphQL Yoga (Pothos emits standard graphql-js schemas), and code-first builder unit tests. Covers the SchemaBuilder API surface (queryType, mutationType, objectType, t.field, t.arg). Use when testing a Pothos-built schema before or alongside the server-runtime tests.