Testing GraphQL Subscriptions: WebSockets, Real-Time Events, and Mocking

Testing GraphQL Subscriptions: WebSockets, Real-Time Events, and Mocking

GraphQL subscriptions are the hardest part of GraphQL to test. They're asynchronous, event-driven, and use WebSockets instead of HTTP. Most guides skip them. This one doesn't.

How Subscriptions Work (Testing Perspective)

A GraphQL subscription:

  1. Client sends subscription over WebSocket
  2. Server holds the connection open
  3. When events occur, server pushes data to the client
  4. Client receives messages asynchronously

Testing this means you need to control: when events fire, how many events are emitted, and when the subscription ends. The graphql-ws protocol adds another layer of complexity on top of raw WebSockets.

Unit Testing: Test the Resolver Directly

Subscription resolvers typically use AsyncIterator (or pubsub). Test them without a WebSocket connection.

// schema.js
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();

const typeDefs = gql`
  type Message {
    id: ID!
    content: String!
    author: String!
  }
  
  type Subscription {
    messageSent(roomId: ID!): Message
  }
`;

const resolvers = {
  Subscription: {
    messageSent: {
      subscribe: (_, { roomId }, { pubsub }) =>
        pubsub.asyncIterableIterator([`MESSAGE_${roomId}`]),
      resolve: (payload) => payload.message
    }
  }
};
// test: trigger the iterator directly
test('messageSent emits events for the correct room', async () => {
  const testPubSub = new PubSub();
  const context = { pubsub: testPubSub };
  
  // Get the iterator
  const iterator = resolvers.Subscription.messageSent.subscribe(
    null,
    { roomId: 'room1' },
    context
  );
  
  // Publish an event
  const message = { id: '1', content: 'Hello', author: 'Alice' };
  testPubSub.publish('MESSAGE_room1', { message });
  
  // Collect the next value
  const { value } = await iterator.next();
  const resolved = resolvers.Subscription.messageSent.resolve(value);
  
  expect(resolved).toEqual(message);
  
  iterator.return(); // clean up
});

test('messageSent does not emit for different rooms', async () => {
  const testPubSub = new PubSub();
  const context = { pubsub: testPubSub };
  
  const iterator = resolvers.Subscription.messageSent.subscribe(
    null,
    { roomId: 'room1' },
    context
  );
  
  // Publish to a different room
  testPubSub.publish('MESSAGE_room2', { message: { id: '2', content: 'Not for room1' } });
  
  // No event should arrive for room1
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('timeout')), 100)
  );
  
  await expect(Promise.race([iterator.next(), timeout]))
    .rejects.toThrow('timeout');
  
  iterator.return();
});

Integration Testing with Apollo Server

For full end-to-end testing, use Apollo Server's executeOperation with subscription support.

const { ApolloServer } = require('@apollo/server');
const { makeExecutableSchema } = require('@graphql-tools/schema');

async function startTestServer() {
  const testPubSub = new PubSub();
  
  const server = new ApolloServer({
    schema: makeExecutableSchema({ typeDefs, resolvers }),
    context: () => ({ pubsub: testPubSub })
  });
  
  return { server, pubsub: testPubSub };
}

test('subscription receives multiple events', async () => {
  const { server, pubsub } = await startTestServer();
  
  const messages = [];
  const errors = [];
  let done = false;
  
  // Use executeOperation for subscription
  const { body } = await server.executeOperation({
    query: `
      subscription TestSub($roomId: ID!) {
        messageSent(roomId: $roomId) {
          id
          content
          author
        }
      }
    `,
    variables: { roomId: 'room1' }
  });
  
  // body.kind will be 'incremental' for subscriptions
  if (body.kind === 'incremental') {
    const asyncIterator = body.initialResult;
    
    // Publish events after subscribing
    setTimeout(() => {
      pubsub.publish('MESSAGE_room1', {
        message: { id: '1', content: 'First', author: 'Alice' }
      });
      pubsub.publish('MESSAGE_room1', {
        message: { id: '2', content: 'Second', author: 'Bob' }
      });
    }, 10);
    
    // Collect 2 events
    for await (const result of asyncIterator) {
      messages.push(result.data?.messageSent);
      if (messages.length >= 2) break;
    }
  }
  
  expect(messages).toHaveLength(2);
  expect(messages[0].content).toBe('First');
  expect(messages[1].content).toBe('Second');
});

WebSocket Integration Test

For testing the full WebSocket transport, use graphql-ws and a real server.

const { createClient } = require('graphql-ws');
const WebSocket = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');

let wsServer;
let testPubSub;

beforeAll(async () => {
  testPubSub = new PubSub();
  
  const schema = makeExecutableSchema({
    typeDefs,
    resolvers: makeResolvers(testPubSub)
  });
  
  wsServer = new WebSocket.Server({ port: 0 }); // random port
  
  useServer({ schema }, wsServer);
});

afterAll(() => {
  wsServer.close();
});

test('WebSocket subscription receives live events', async () => {
  const port = wsServer.address().port;
  
  const client = createClient({
    url: `ws://localhost:${port}/graphql`,
    webSocketImpl: WebSocket
  });
  
  const received = [];
  
  await new Promise((resolve, reject) => {
    const unsubscribe = client.subscribe(
      {
        query: `subscription { messageSent(roomId: "ws-test") { content } }`
      },
      {
        next: (data) => {
          received.push(data.data.messageSent);
          if (received.length >= 2) {
            unsubscribe();
            resolve();
          }
        },
        error: reject,
        complete: resolve
      }
    );
    
    // Publish events after a brief delay to ensure subscription is established
    setTimeout(() => {
      testPubSub.publish('MESSAGE_ws-test', {
        message: { id: '1', content: 'Event 1', author: 'Alice' }
      });
      testPubSub.publish('MESSAGE_ws-test', {
        message: { id: '2', content: 'Event 2', author: 'Bob' }
      });
    }, 50);
    
    // Timeout safety
    setTimeout(() => reject(new Error('Subscription timed out')), 3000);
  });
  
  client.dispose();
  
  expect(received).toHaveLength(2);
  expect(received[0].content).toBe('Event 1');
});

Testing Error Scenarios

Subscriptions can fail during authentication or when the event source fails.

test('subscription errors on unauthorized access', async () => {
  const client = createClient({
    url: `ws://localhost:${port}/graphql`,
    webSocketImpl: WebSocket,
    connectionParams: { authorization: 'invalid-token' }
  });
  
  const errors = [];
  
  await new Promise((resolve) => {
    const unsubscribe = client.subscribe(
      { query: `subscription { protectedEvent { data } }` },
      {
        next: () => {},
        error: (err) => {
          errors.push(err);
          resolve();
        },
        complete: resolve
      }
    );
  });
  
  client.dispose();
  
  expect(errors.length).toBeGreaterThan(0);
  expect(errors[0].message).toMatch(/unauthorized/i);
});

Testing Subscription Cleanup

Ensure subscriptions don't leak when clients disconnect.

test('active subscriber count decreases after unsubscribe', async () => {
  const tracker = { active: 0 };
  
  // Schema with subscription tracking
  const trackingResolvers = {
    Subscription: {
      counter: {
        subscribe: async function* () {
          tracker.active++;
          try {
            yield { counter: 1 };
            yield { counter: 2 };
          } finally {
            tracker.active--; // cleanup
          }
        }
      }
    }
  };
  
  expect(tracker.active).toBe(0);
  
  const iterator = trackingResolvers.Subscription.counter.subscribe();
  expect(tracker.active).toBe(1);
  
  await iterator.next(); // consume one value
  
  await iterator.return(); // unsubscribe
  
  expect(tracker.active).toBe(0);
});

Common Pitfalls

Race conditions in tests: Publishing before the subscription is established causes missed events. Use a small delay or a ready callback.

Iterator leaks: Always call iterator.return() in test cleanup, or subscriptions stay open and affect subsequent tests.

PubSub scope: Use a fresh PubSub instance per test or per test file. A shared instance means events from one test can bleed into another.

WebSocket port conflicts: Use port: 0 for random ports in test servers.

Timeout vs assertion: Subscriptions that don't fire shouldn't hang your test suite. Always set a timeout and reject on it.

GraphQL subscriptions are genuinely more complex to test than queries and mutations, but the same patterns — unit test the resolver, integration test the transport — keep things manageable.

Read more

Start now free