Skip to main content
Back to Blog
API Testing
2026-07-10

GraphQL Subscriptions Testing Guide

Test GraphQL subscriptions with WebSocket clients, async assertions, event ordering, auth refresh, cleanup, and real-time failure diagnostics.

GraphQL Subscriptions Testing Guide

The mutation passed, the database row changed, and the browser still never received the update. GraphQL subscription failures live between layers: WebSocket handshake, connection init payload, authentication, async iterator wiring, pub/sub filtering, resolver payload shape, client unsubscribe behavior, and timing. A normal GraphQL query test does not exercise those paths.

This guide is about testing subscription behavior with real WebSocket clients and server-side event sources. It assumes you already know GraphQL schema basics and want reliable assertions for real-time flows. For query and mutation coverage, read GraphQL testing complete guide. For lower-level socket behavior, close frames, and protocol diagnostics, use WebSocket testing guide.

Subscription tests need a live protocol boundary

A subscription resolver can be unit-tested as an async iterator, but that is not enough. The client does not call the resolver directly. It connects over WebSocket, sends a connection initialization message, starts an operation, receives next payloads, and eventually completes or closes. Bugs in any of those steps can make a production real-time UI stale.

The most valuable automated test opens a real WebSocket subscription against a test server, triggers the event through the same mutation or service path production uses, and asserts the received payload. That sounds slower than a resolver unit test, but it covers the integration boundary that actually breaks.

RiskResolver unit test catches itWebSocket subscription test catches itExample failure
Resolver returns wrong fieldYesYescommentAdded.author is null.
Connection init auth rejectedNoYesClient connects but receives unauthorized close.
Pub/sub topic typoSometimesYesMutation publishes COMMENT_ADDED while subscription listens to COMMENTS_ADDED.
Filter logic wrongYes if isolated carefullyYesUser receives events for a different project.
Protocol mismatchNoYesClient uses graphql-transport-ws but server expects legacy protocol.
Cleanup leak after unsubscribeNoYesServer keeps listener after client completes operation.
Event ordering issueNot usuallyYesTwo updates arrive reversed under real async dispatch.

A real graphql-ws client test

The graphql-ws client exposes a subscribe function that works well in tests. Wrap it in a Promise so the test waits for the expected event and rejects on timeout. The example below assumes the server already exposes a subscription named commentAdded and a mutation named addComment.

import { createClient } from 'graphql-ws';
import WebSocket from 'ws';

function waitForCommentAdded(projectId: string, token: string) {
  const client = createClient({
    url: 'ws://localhost:4000/graphql',
    webSocketImpl: WebSocket,
    connectionParams: {
      Authorization: 'Bearer ' + token,
    },
  });

  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      dispose();
      reject(new Error('Timed out waiting for commentAdded'));
    }, 5000);

    const dispose = client.subscribe(
      {
        query: [
          'subscription CommentAdded($projectId: ID!) {',
          '  commentAdded(projectId: $projectId) {',
          '    id',
          '    body',
          '    projectId',
          '  }',
          '}',
        ].join('\n'),
        variables: { projectId },
      },
      {
        next: (result) => {
          clearTimeout(timeout);
          dispose();
          resolve(result);
        },
        error: (error) => {
          clearTimeout(timeout);
          reject(error);
        },
        complete: () => undefined,
      },
    );
  });
}

test('subscriber receives the comment created for the same project', async () => {
  const token = await testTokenForUser('user-1');
  const eventPromise = waitForCommentAdded('project-123', token);

  await request(app)
    .post('/graphql')
    .set('Authorization', 'Bearer ' + token)
    .send({
      query: [
        'mutation AddComment($projectId: ID!, $body: String!) {',
        '  addComment(projectId: $projectId, body: $body) { id }',
        '}',
      ].join('\n'),
      variables: { projectId: 'project-123', body: 'Ready for review' },
    })
    .expect(200);

  await expect(eventPromise).resolves.toMatchObject({
    data: {
      commentAdded: {
        body: 'Ready for review',
        projectId: 'project-123',
      },
    },
  });
});

The test subscribes before it triggers the mutation. This ordering matters. If the mutation runs first, a fast pub/sub implementation can emit before the subscription is active, and the test becomes a race disguised as an integration check.

Testing authentication at connection init

Subscription authentication often happens during connection initialization, not per event. That changes the test plan. You need to cover valid token, missing token, expired token, and user-permission filtering after the connection is accepted. A connection can be valid while a specific subscription operation is forbidden.

Keep auth tests separate from payload tests. A payload test should not debug token shape. An auth test should not need a complex domain mutation. Use the smallest subscription that proves the server enforces the rule.

Auth scenarioExpected behaviorUseful assertion
Missing tokenConnection rejected or operation errorClient receives an error before any next payload.
Expired tokenRejected with auth-specific reasonClose or error message is classified as unauthorized.
Valid token, wrong projectOperation succeeds but emits no forbidden eventTrigger another project's event and assert timeout plus no payload.
Token expires after connectDepends on policyDocument whether long-lived sockets are rechecked.
Role changes while connectedDepends on policyTest if the server must revoke or stop events.
Malformed connectionParamsRejectedServer does not throw unhandled exception.

Do not assume the browser client and test client serialize connectionParams the same way. Use the same protocol package and connection payload shape whenever possible. A common production bug is an auth header that works for HTTP GraphQL but is never sent during WebSocket connection init.

Server-side test server with useServer

For isolated subscription tests, start a real WebSocket server around your executable schema. The graphql-ws package provides useServer for the graphql-transport-ws protocol. The example below uses ws and @graphql-tools/schema. It is intentionally small so tests can own server startup and teardown.

import { createServer } from 'node:http';
import { EventEmitter } from 'node:events';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { execute, subscribe } from 'graphql';
import { useServer } from 'graphql-ws/use/ws';
import { WebSocketServer } from 'ws';

const events = new EventEmitter();

const typeDefs = [
  'type Comment { id: ID!, projectId: ID!, body: String! }',
  'type Query { ping: String! }',
  'type Mutation { addComment(projectId: ID!, body: String!): Comment! }',
  'type Subscription { commentAdded(projectId: ID!): Comment! }',
].join('\n');

const resolvers = {
  Query: { ping: () => 'pong' },
  Mutation: {
    addComment: (_parent: unknown, args: any) => {
      const comment = { id: 'c1', projectId: args.projectId, body: args.body };
      events.emit('comment', comment);
      return comment;
    },
  },
  Subscription: {
    commentAdded: {
      subscribe: async function* (_parent: unknown, args: any) {
        const queue: any[] = [];
        const listener = (comment: any) => {
          if (comment.projectId === args.projectId) {
            queue.push(comment);
          }
        };

        events.on('comment', listener);
        try {
          while (true) {
            if (queue.length > 0) {
              yield { commentAdded: queue.shift() };
            }
            await new Promise((resolve) => setTimeout(resolve, 10));
          }
        } finally {
          events.off('comment', listener);
        }
      },
    },
  },
};

export async function startSubscriptionServer() {
  const schema = makeExecutableSchema({ typeDefs, resolvers });
  const httpServer = createServer();
  const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
  const cleanup = useServer({ schema, execute, subscribe }, wsServer);

  await new Promise<void>((resolve) => httpServer.listen(4000, resolve));

  return async () => {
    await cleanup.dispose();
    wsServer.close();
    httpServer.close();
  };
}

Production code should use a better async iterator abstraction than a polling queue, but the example demonstrates the pieces that the test must exercise: schema, execute, subscribe, WebSocket server, and cleanup.

Event ordering and multiple payloads

Many subscription tests stop at the first payload. That misses ordering bugs and duplicate events. When the product depends on event order, collect several payloads and assert the sequence. For collaborative editing, counters, notifications, and status timelines, order is part of the behavior.

The test should trigger events through the production path and wait until it has received the expected count. Do not assert strict timing between events unless timing is the feature. Assert order by event data.

FeaturePayload sequence to assertOrdering mistake to catch
Task status timelinequeued, running, completedcompleted arrives before running due to async publish ordering.
Chat roommessage 1, message 2, message 3Duplicate delivery or reversed message order.
Notification badgecount 1, count 2Client receives stale count after fresh count.
Auction bid feedlower bid, higher bid, winner updateWinner event emitted before final bid event.
Document collaborationpatch version 4, patch version 5Version gap or duplicate patch.

Unsubscribe and resource cleanup

A subscription that delivers the right event can still leak. Each active client may register a listener, database cursor, Redis subscription, or pub/sub handler. Tests should verify that cleanup runs when the client disposes the operation and when the socket closes unexpectedly.

Expose listener counts in test-only instrumentation if necessary. Do not expose them in production APIs. The point is to catch a server that keeps per-client listeners forever. In Node, EventEmitter listenerCount can help for local test doubles. In Redis or Kafka-backed pub/sub, use wrapper metrics around subscribe and unsubscribe calls.

A leak test should open a subscription, wait until the server confirms registration, dispose the client operation, then wait until the listener count returns to baseline. This is more valuable than a heap snapshot for normal CI. Heap analysis can follow if the listener-count test fails.

Handling timeouts without hiding missing events

Every subscription test needs a timeout. Without one, a missing event hangs the test process. The timeout should fail with the subscription name, variables, and trigger action. A generic Jest timeout tells you almost nothing.

Avoid using long timeouts as a substitute for deterministic event triggering. If a local subscription event needs 30 seconds to appear, the system under test is probably doing too much for a normal integration test. Separate the real-time contract from slow background processing by emitting a domain event after the slow work completes, then testing the slow work separately.

Protocol errors deserve their own tests

GraphQL subscription clients and servers must agree on the WebSocket subprotocol. A server using graphql-transport-ws will not behave like a server using the older subscriptions-transport-ws package. Tests should cover the protocol you support and reject unsupported clients predictably. Otherwise, a frontend upgrade can fail as a mysterious connection close in production.

Protocol tests should stay small. Connect with the expected protocol and confirm a simple subscription starts. Connect with missing connection init data and confirm the server rejects or errors according to your policy. If the server supports keepalive or ping-pong behavior, assert that an idle connection remains healthy for the required window. These tests belong closer to the subscription server than to product feature tests.

Protocol behaviorTest setupExpected result
Correct graphql-transport-ws clientClient sends connection init and subscribeServer accepts and can deliver next payload.
Missing auth in connection paramsClient connects without tokenServer rejects connection or returns operation error.
Unsupported subprotocolRaw WebSocket uses wrong protocolConnection closes without starting operation.
Duplicate operation idClient starts two operations with same idServer returns protocol error or closes as specified.
Client completes operationClient calls disposeServer stops sending events and releases listener.
Socket closes abruptlyClient terminates connectionServer cleanup runs without unhandled exception.

Filtering tests for tenant and project boundaries

Subscription filters are security boundaries when events contain tenant, project, or role-specific data. A filter bug can leak information even if queries and mutations are protected correctly. Test positive delivery and negative non-delivery side by side.

For a project-scoped subscription, create two subscribers with different project permissions. Trigger an event for project A. Subscriber A should receive it. Subscriber B should not. The negative assertion needs a short timeout, but it should be deliberate and named. A passing no-event test means the forbidden payload did not arrive within the agreed window after the trigger.

Do not rely on client-side filtering for sensitive events. If the server sends every event and the browser filters by project id, the data already crossed the boundary. Subscription tests should inspect what arrives over the socket, not only what the UI chooses to render.

BoundaryPositive caseNegative case
TenantUser in tenant 1 receives tenant 1 alertUser in tenant 2 receives no tenant 1 alert.
ProjectMaintainer receives project commentViewer from another project receives no payload.
RoleAdmin receives moderation eventMember receives no moderation event.
Feature flagFlagged user receives beta eventUnflagged user receives no beta event.
Direct messageParticipant receives messageNonparticipant receives no payload.

Payload shape, nullability, and schema drift

Subscriptions use the same GraphQL schema as queries, but real-time payloads often come from different code paths. A mutation may return a fully hydrated object while the subscription resolver publishes a thinner object from a queue. If a non-null field is missing in the subscription payload, GraphQL execution can null the parent field or return an error to subscribers.

Add tests for fields the UI needs immediately. If the notification bell renders id, title, severity, and createdAt, the subscription test should request those fields and assert they are non-null. Do not only request id because it is easy. A subscription can pass the thin test and still break the real component.

Schema drift also happens when backend teams add a field to query resolvers but forget subscription payload construction. A good regression test uses the same fragment as the frontend component. Importing GraphQL documents from the client package can be worth the dependency if it prevents the server test from requesting a weaker shape than production.

Backpressure and burst behavior

Real-time systems do not only deliver single events. A user may reconnect after being offline, a job may emit many progress updates, or a chat room may receive a burst. Subscription tests should include at least one burst scenario for important feeds. The assertion should verify count, order, and absence of duplicates.

Keep burst tests bounded. Five to ten events are enough to catch queue draining and ordering mistakes in normal CI. Load testing the subscription server is a separate exercise. For correctness tests, you want a small sequence where every event has a distinct version or sequence number.

If the server intentionally drops intermediate events and sends only the latest state, test that policy explicitly. A progress subscription may send 10, 40, 100 and skip 20 or 30. That is fine if clients only need the latest state. It is not fine for audit, chat, or financial event streams.

Reconnect behavior after a dropped socket

Real clients reconnect. A useful subscription suite includes one test where the socket closes after the operation starts, then the client reconnects and starts a fresh operation. The server should not keep the old listener alive, and the client should not receive duplicate events after reconnect. If the product promises missed-event recovery, the reconnect test also needs a cursor, timestamp, or version argument. Without an explicit recovery token, a subscription can only deliver future events.

Document that policy in the schema description or client contract. Otherwise users will assume reconnect means replay, while the server only means resume listening from now. That expectation mismatch causes support incidents.

Frequently Asked Questions

Should I unit test subscription resolvers or only test WebSockets?

Use both, but for different risk. Resolver unit tests are good for filter logic and payload construction. WebSocket tests prove protocol setup, authentication, operation start, event delivery, and cleanup.

Which GraphQL WebSocket protocol should tests use?

Use the same protocol as production clients. For new systems that is often graphql-transport-ws through the graphql-ws package. Avoid mixing it with the older subscriptions-transport-ws protocol unless your server intentionally supports both.

How do I prevent subscription tests from racing the event?

Create the subscription first, wait until the operation is active if your server exposes a hook, then trigger the mutation or domain action. Starting the mutation before subscribing can lose fast events.

Should a test assert that no forbidden event arrives?

Yes, but keep the timeout short and specific. Trigger an event the user must not receive, wait for a bounded interval, and fail if any payload arrives. This is important for project, tenant, and role-based filtering.

How can I detect subscription listener leaks in CI?

Add test-only instrumentation around the pub/sub registration layer. Assert that listener or subscription counts return to baseline after client dispose and socket close paths.

GraphQL Subscriptions Testing Guide | QASkills.sh