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:
- Client sends subscription over WebSocket
- Server holds the connection open
- When events occur, server pushes data to the client
- 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.