graphql-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
- Writing tests for subscription resolvers (pubsub trigger, filter, error).
- Asserting connection lifecycle: handshake, auth rejection, protocol close.
- Verifying auth-on-connect:
connectionParams (WS) or authenticate
callback (SSE) reject unauthenticated clients before any event is sent.
- Integration-testing that event-stream shape matches the schema contract.
Distinct scope vs. sibling skills:
apollo-server-tests covers queries/mutations via executeOperation;
its Limitations section explicitly notes "Doesn't test subscriptions
over WS."
graphql-yoga-tests covers Yoga's yoga.fetch() path; subscription
tests there go through Yoga's own plugin hooks, not graphql-ws/sse
directly.
graphql-yoga-tests (references/mercurius.md) and hasura-tests target those specific runtimes.
How to use
- Install the transport client(s) plus the test runner (
graphql-ws + ws,
or graphql-sse, alongside graphql and jest).
- Start the server on
port: 0 in beforeAll so parallel suites each get a
free OS-assigned port; dispose() the runner and close() the server in
afterAll.
- Create a client with
createClient and drive the subscription through
client.iterate(), pushing each event.data into an array; break once the
expected count arrives to close the stream.
- Assert the emitted sequence and shape against the schema contract, checking
errors is undefined before reading data.
- Add auth-on-connect tests: reject a missing / invalid token (close code
4403 for WS, 401 for SSE) and accept a valid one.
- Add a resolver-isolation test with
graphql subscribe() + pubsub.publish(...)
to cover trigger / filter / error logic without a transport.
- Run under an extended
testTimeout and --forceExit in CI so open sockets
never hang the runner.
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-jest
Server 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:
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:
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.
- Start the WS server (see the server-setup reference) with an
onConnect that
returns false on a missing token.
- Connect an authed client with
connectionParams: { token: 'valid-token' } and
open client.iterate({ query: 'subscription Messages($channel: String!) { messageAdded(channel: $channel) { text } } ', variables: { channel: 'team-a' } }).
pubsub.publish a team-b message then a team-a message; await sub.next().
- Assert
value.data?.messageAdded?.text === 'right' - the team-b event was
filtered out, proving the predicate.
- Open a second client with
connectionParams: {} and retryAttempts: 0; assert
its closed event carries code 4403.
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/ --verbose
Set 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
- Resolver isolation tests require a testable pubsub instance. If the
production pubsub is injected via context, pass a test double; if it is a
module singleton, reset it in
beforeEach.
- WS close-code assertions are timing-sensitive. Use
retryAttempts: 0
and wrap in a done callback (or Promise + event listener) rather than
awaiting the iterator.
- SSE in Node requires
fetch or a polyfill. graphql-sse clients
default to the global fetch; Node < 18 needs node-fetch or undici.
- Does not cover HTTP-layer concerns. CORS, rate limiting, and response
headers for the SSE endpoint need separate HTTP-layer tests (e.g.,
supertest).
- Does not cover schema-contract drift. Pair with
graphql-schema-regression (in the qa-contract-testing plugin)
to catch subscription field renames between provider and consumer.
References
- graphql-ws get-started and recipes:
the-guild.dev/graphql/ws/get-started,
the-guild.dev/graphql/ws/recipes.
- graphql-ws protocol docs (MessageType, ClientOptions, ServerOptions,
close codes):
the-guild.dev/graphql/ws/docs.
- graphql-sse get-started and recipes:
the-guild.dev/graphql/sse/get-started,
the-guild.dev/graphql/sse/recipes.
- GraphQL October 2021 spec, Section 6.3 Subscription (Cloudflare-protected;
cite by stable ID): subscription execution model, single-root-field
constraint, per-event independent execution.
- Sibling skill (queries/mutations, in-process):
apollo-server-tests.
- Sibling skill (Yoga transport, including SSE via Yoga):
graphql-yoga-tests.
- Contract-drift cross-plugin:
graphql-schema-regression.