Testland
Browse all skills & agents

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-author
View source

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.
  • mercurius-tests 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

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:

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']);

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.