graphql-subscription-test-author

Use when GraphQL subscriptions need tests over graphql-ws (WebSocket) or graphql-sse: subscribing through the async iterator, asserting event shape and order, connection lifecycle and close codes, auth on connect and pubsub triggers. Queries and mutations belong to the server test skills.

/plugin install qa-graphql@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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

  1. Install the transport client(s) plus the test runner (graphql-ws + ws, or graphql-sse, alongside graphql and jest).
  2. 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.
  3. 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.
  4. Assert the emitted sequence and shape against the schema contract, checking errors is undefined before reading data.
  5. Add auth-on-connect tests: reject a missing / invalid token (close code 4403 for WS, 401 for SSE) and accept a valid one.
  6. Add a resolver-isolation test with graphql subscribe() + pubsub.publish(...) to cover trigger / filter / error logic without a transport.
  7. 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 (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.

  1. Start the WS server (see the server-setup reference) with an onConnect that returns false on a missing token.
  2. 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' } }).
  3. pubsub.publish a team-b message then a team-a message; await sub.next().
  4. Assert value.data?.messageAdded?.text === 'right' - the team-b event was filtered out, proving the predicate.
  5. 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-patternWhy it failsFix
Hardcoded ws://localhost:4000Port conflicts in parallel CIport: 0, read back the OS-assigned port
No dispose() / close() in afterAllOpen WS/SSE connections prevent Jest from exitingAlways dispose client and close server
retryAttempts not set to 0 in rejection testsDefault retry masks the 4403 close eventSet retryAttempts: 0 for auth-rejection assertions
Asserting data without checking errorsMasked errors produce false positivesCheck errors === undefined before asserting data
Using executeOperation for subscription testsApollo in-process runner does not start a WS serverUse transport-layer client with createClient
Single combined test for subscribe + auth + filterFailures hard to diagnoseOne test per behavior
Testing only the transport, not the resolverResolver pubsub logic goes untestedCombine 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

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/ });
});
Reference

Connection lifecycle and close codes

View source (opens in a new tab) (opens in new window)

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:

CodeMeaning
4400Bad request / invalid message
4401Unauthorized (no ConnectionInit before timeout)
4403Forbidden (server onConnect returned false)
4408Connection initialisation timeout
4409Subscriber already exists for that id
4429Too 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']);
Reference

Resolver, filter, and error tests

View source (opens in a new tab) (opens in new window)

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);
});

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` };
}