QA How-To
How to Test GraphQL Subscriptions with Playwright (2026)
Learn how to test GraphQL subscriptions with Playwright using a real graphql-ws server, protocol assertions, UI checks, cleanup, and CI-ready examples.
20 min read | 2,737 words
TL;DR
Run the application against a real, disposable GraphQL subscription server. Use Playwright's page WebSocket event to inspect graphql-transport-ws frames, trigger an event through the system's public API, and assert the UI result; use an in-page graphql-ws client when you need precise protocol, authentication, error, or unsubscribe coverage.
Key Takeaways
- Test subscription transport and user-visible behavior at separate boundaries so failures are easy to diagnose.
- Use the graphql-transport-ws subprotocol and assert connection_ack before starting an operation.
- Capture Playwright WebSocket frames before the page opens the socket to avoid missing the handshake.
- Give each test a unique channel or topic so parallel workers cannot consume one another's events.
- Trigger events through a public API and assert both the protocol payload and the rendered UI.
- Verify unsubscribe and socket closure because leaked subscriptions create flaky tests and production resource waste.
If you need to know how to test GraphQL subscriptions with Playwright, use two complementary tests: a browser-level test that proves a pushed event reaches the UI, and a focused protocol test that runs a GraphQL WebSocket client inside the browser. A normal HTTP assertion is insufficient because a subscription is a long-lived, stateful conversation rather than one request and one response.
This tutorial builds a small TypeScript fixture around the standard graphql-transport-ws subprotocol used by graphql-ws. You will observe the actual frames, publish an event through HTTP, verify the payload and rendered notification, test rejection paths, and prove that cleanup occurs. For broader query and mutation coverage, keep the GraphQL API testing guide beside this subscription-specific workflow.
TL;DR
Use a real server for one end-to-end happy path and a direct in-page client for protocol edge cases. Attach page.on('websocket') before navigation, wait for connection_ack, publish through a public API, and assert a next frame plus the UI. Do not replace every subscription test with mocked callbacks because that skips authentication, serialization, routing, and unsubscribe behavior.
| Approach | Proves | Best use | Main limitation |
|---|---|---|---|
| UI plus real subscription server | Browser client, server, event routing, state update, rendering | Critical user journeys | Slower and more failure sources |
In-page graphql-ws client |
Handshake, auth, payloads, errors, completion | Protocol integration and negative cases | Does not prove application rendering |
| Playwright frame observation | Exact sent and received WebSocket messages | Diagnostics and contract assertions | Observes traffic but does not create app state |
| Mocked application transport | Component reaction to controlled events | Rare states and deterministic UI branches | Cannot validate the network contract |
What You Will Build
By the end, you will have a Playwright suite that can:
- connect with the
graphql-transport-wssubprotocol and confirm the server acknowledges the connection; - subscribe to a unique order topic, then publish a deterministic event through an HTTP endpoint;
- inspect incoming WebSocket frames without changing application code;
- verify a live status update in the DOM;
- cover invalid authentication, GraphQL errors, unsubscribe, and socket closure;
- run safely in parallel and retain useful traces when CI fails.
The examples use an order-status subscription because it exposes realistic concerns: a scoped identifier, an authenticated connection, a server-side event, and a visible state transition. The same pattern works for chat, notifications, job progress, dashboards, and collaborative editing. If your system is broadly event-driven, the event-driven API testing guide explains producer and consumer boundaries that sit outside the browser test.
Prerequisites
Use Node.js 20 or newer, an application that speaks GraphQL over WebSocket, and Playwright Test. The code assumes your GraphQL endpoint is ws://127.0.0.1:4000/graphql, your web app is http://127.0.0.1:3000, and a test-only publisher accepts POST /test/events. Change those URLs to match your environment. Never expose a test publisher in production.
Install the runner and protocol client:
npm install -D @playwright/test typescript
npm install graphql graphql-ws
npx playwright install chromium
Use graphql-ws rather than the retired subscriptions-transport-ws package. The examples deliberately name the graphql-transport-ws subprotocol because the two wire protocols have different messages and are not interchangeable. Your server must accept WebSocket upgrades and implement connection_init, connection_ack, subscribe, next, error, and complete as appropriate.
Set test environment values without committing secrets:
export WEB_URL=http://127.0.0.1:3000
export GRAPHQL_WS_URL=ws://127.0.0.1:4000/graphql
export TEST_API_URL=http://127.0.0.1:4000
export E2E_TOKEN=local-e2e-token
Verify the setup by starting the app and opening WEB_URL. Confirm the browser network panel shows a WebSocket connection whose negotiated protocol is graphql-transport-ws. If the upgrade returns 404 or 426, fix proxy routing before writing assertions. The modern GraphQL API testing guide is useful when queries, mutations, schema validation, and subscriptions share the same test strategy.
Step 1: Configure Playwright for the Live System
Create playwright.config.ts so tests share a base URL, collect traces on retries, and receive enough time for local server startup. Keep assertion timeouts shorter than the whole test timeout. A missing event should fail near the relevant assertion, not consume the entire job.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 7_000 },
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.WEB_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
video: 'retain-on-failure',
...devices['Desktop Chrome'],
},
});
A retry can reveal timing symptoms, but it must not conceal shared-state defects. We will isolate every subscription with a unique order ID rather than depend on retries for reliability. Avoid raising timeouts until you have measured the expected delivery window in the test environment.
Verify this step with npx playwright test --list. Playwright should discover the test directory without launching a browser. Then run a trivial page.goto('/') smoke check and confirm the trace configuration does not report an unknown option.
Step 2: Build a Frame Collector Before Navigation
Playwright exposes browser-created sockets through the page websocket event. Register the listener before page.goto, because many applications connect during initial JavaScript boot. Parse only text frames that are JSON; ping frames, binary data, and vendor messages should not crash the test helper.
import type { Page, WebSocket } from '@playwright/test';
type GraphQLFrame = {
id?: string;
type: string;
payload?: unknown;
};
export function collectGraphQLFrames(page: Page) {
const received: GraphQLFrame[] = [];
const sent: GraphQLFrame[] = [];
let graphqlSocket: WebSocket | undefined;
page.on('websocket', socket => {
if (!socket.url().includes('/graphql')) return;
graphqlSocket = socket;
socket.on('framesent', event => {
if (typeof event.payload !== 'string') return;
try { sent.push(JSON.parse(event.payload) as GraphQLFrame); } catch {}
});
socket.on('framereceived', event => {
if (typeof event.payload !== 'string') return;
try { received.push(JSON.parse(event.payload) as GraphQLFrame); } catch {}
});
});
return {
received,
sent,
socket: () => graphqlSocket,
};
}
The collector intentionally stores frames rather than resolving on the first one. A subscription connection may carry multiple operations, keepalive traffic, and unrelated notifications. Later assertions filter by both operation ID and payload content. That prevents a valid but irrelevant event from satisfying the test.
Verify it by attaching the helper, navigating, and polling for an acknowledgement:
const traffic = collectGraphQLFrames(page);
await page.goto('/orders/demo');
await expect.poll(() =>
traffic.received.some(frame => frame.type === 'connection_ack')
).toBe(true);
If the array stays empty while DevTools shows traffic, confirm that the app creates the socket in the page rather than a service worker. For more patterns around observable live events, see Playwright realtime notification assertions.
Step 3: Test the Full Subscription-to-UI Journey
Now prove the behavior users depend on. Generate a unique identifier, open its page, wait until the browser sends the matching subscribe operation, publish the update, and assert the status. Waiting for the subscribe frame closes a common race where the test publishes before the server has registered the consumer.
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
import { collectGraphQLFrames } from './support/graphqlFrames';
test('renders an order status pushed by GraphQL subscription', async ({ page, request }) => {
const orderId = randomUUID();
const traffic = collectGraphQLFrames(page);
await page.goto(`/orders/${orderId}`);
await expect.poll(() => traffic.sent.some(frame => {
if (frame.type !== 'subscribe') return false;
return JSON.stringify(frame.payload).includes(orderId);
})).toBe(true);
const publishResponse = await request.post(
`${process.env.TEST_API_URL ?? 'http://127.0.0.1:4000'}/test/events`,
{
headers: { authorization: `Bearer ${process.env.E2E_TOKEN}` },
data: {
type: 'ORDER_STATUS_CHANGED',
orderId,
status: 'SHIPPED',
},
},
);
expect(publishResponse.ok()).toBe(true);
await expect(page.getByTestId('order-status')).toHaveText('Shipped');
await expect.poll(() => traffic.received.some(frame =>
frame.type === 'next' &&
JSON.stringify(frame.payload).includes(orderId) &&
JSON.stringify(frame.payload).includes('SHIPPED')
)).toBe(true);
});
This test asserts the outcome first and then confirms the transport evidence. The DOM check describes the product contract. The frame check shows that GraphQL delivered the intended event and helps distinguish a subscription failure from a rendering bug. Prefer accessible locators such as getByRole when the status has a stable semantic role; use a test ID for dynamic values without a reliable label.
Verify the test by changing SHIPPED to a status your server rejects. The publisher assertion should fail or the UI should remain unchanged. Restore the valid status and run the test repeatedly with npx playwright test --repeat-each=10; unique IDs should make all runs independent.
Step 4: How to Test GraphQL Subscriptions with Playwright at Protocol Level
Use a direct client when you need exact control over connection parameters, operation disposal, and returned GraphQL errors. Running the client in page.evaluate keeps the test within a real browser WebSocket implementation. Expose a small bundle or test page that imports createClient from graphql-ws; the following assumes your test application makes it available as window.graphqlWs.createClient.
import { test, expect } from '@playwright/test';
test('receives one typed subscription result', async ({ page, request }) => {
const traffic = collectGraphQLFrames(page);
await page.goto('/subscription-harness.html');
const topic = `qa-${Date.now()}`;
const resultPromise = page.evaluate(({ url, token, topic }) => {
return new Promise<unknown>((resolve, reject) => {
const client = window.graphqlWs.createClient({
url,
connectionParams: { authorization: `Bearer ${token}` },
retryAttempts: 0,
});
const dispose = client.subscribe(
{
query: `subscription OnMessage($topic: ID!) {
messageAdded(topic: $topic) { id topic text }
}`,
variables: { topic },
},
{
next: value => { dispose(); resolve(value); },
error: reject,
complete: () => {},
},
);
});
}, {
url: process.env.GRAPHQL_WS_URL ?? 'ws://127.0.0.1:4000/graphql',
token: process.env.E2E_TOKEN ?? '',
topic,
});
await expect.poll(() => traffic.sent.some(frame =>
frame.type === 'subscribe' &&
JSON.stringify(frame.payload).includes(topic)
)).toBe(true);
const response = await request.post(`${process.env.TEST_API_URL}/test/events`, {
headers: { authorization: `Bearer ${process.env.E2E_TOKEN}` },
data: { type: 'MESSAGE_ADDED', topic, text: 'ready for review' },
});
expect(response.ok()).toBe(true);
await expect(resultPromise).resolves.toMatchObject({
data: { messageAdded: { topic, text: 'ready for review' } },
});
});
Add a declaration file for the harness global so TypeScript remains strict:
declare global {
interface Window {
graphqlWs: {
createClient: typeof import('graphql-ws').createClient;
};
}
}
export {};
The subscription document declares variables, and the assertion checks the GraphQL response shape rather than searching raw text. In a production repository, bundle the harness with Vite and exclude it from release builds, or import the client through your existing app test route. Verify the step by logging server connection metrics: one operation should start, emit once, and complete after dispose().
Step 5: Verify Authentication and GraphQL Errors
Subscription authentication often happens in connectionParams, not ordinary HTTP headers. Test an invalid credential independently from an unauthorized field or topic. A server may reject the whole socket with a close code, acknowledge the socket but reject an operation with error, or return a GraphQL result containing errors. Your assertion must match the server's documented policy.
test('rejects an invalid subscription token', async ({ page }) => {
const traffic = collectGraphQLFrames(page);
await page.addInitScript(() => {
localStorage.setItem('access_token', 'expired-token-for-e2e');
});
await page.goto('/orders/private-order');
await expect.poll(() => {
const socket = traffic.socket();
return socket?.isClosed() ?? false;
}).toBe(true);
await expect(page.getByRole('alert')).toContainText(/sign in|connection/i);
});
For field authorization, connect with a valid low-privilege user and subscribe to another tenant's topic. Expect a protocol error for that operation or a next payload with GraphQL errors, according to your schema conventions. Never merely assert that no event arrived: silence could mean bad routing, slow delivery, or a correctly enforced permission. Require explicit rejection or inspect an auditable server response.
Verify both tests against a known valid credential. The valid account should receive connection_ack; the expired one should follow the documented close path. Avoid asserting a close reason string unless it is part of your public contract because gateways may normalize it. The GraphQL query complexity security guide adds resource-abuse scenarios that complement authorization checks.
Step 6: Test Unsubscribe, Completion, and Reconnection
A subscription test is incomplete if it never proves cleanup. Navigate away or call the disposer, then assert the client sends complete for the operation. If your application owns one shared socket, the socket may remain open after an operation completes, so distinguish operation disposal from connection closure.
test('disposes the order operation when leaving the page', async ({ page }) => {
const traffic = collectGraphQLFrames(page);
await page.goto('/orders/order-cleanup-1');
await expect.poll(() =>
traffic.sent.some(frame => frame.type === 'subscribe')
).toBe(true);
const operationId = traffic.sent.find(
frame => frame.type === 'subscribe'
)!.id!;
await page.goto('/account');
await expect.poll(() => traffic.sent.some(frame =>
frame.type === 'complete' && frame.id === operationId
)).toBe(true);
});
A small waitForSubscribe helper can return the matching frame if several operations start on the same page. The essential assertion is correlation by operation ID. A complete for another active subscription is not evidence that this route cleaned up.
For reconnection, terminate the test server connection through a controlled admin endpoint, wait for the client to reconnect, then publish exactly one event. Assert one UI update, not just visibility. Configure a bounded retry policy in the application and make the test aware of it. Do not use page.setOffline(true) to claim WebSocket-only recovery if losing all network access changes unrelated application behavior.
Verify server-side cleanup using metrics or test instrumentation when available. The active subscription count should return to its baseline after navigation. For high connection counts and sustained delivery rates, move beyond functional Playwright checks and use the k6 WebSocket load testing tutorial.
Step 7: Make the Suite Deterministic in CI
Parallel workers expose subscription tests that rely on global channels, fixed users, or arbitrary sleeps. Create a unique topic per test and include it in every subscription variable, published event, and assertion. Seed authorization explicitly. Wait for a protocol milestone before triggering the producer.
Use a fixture to centralize isolation:
import { test as base } from '@playwright/test';
import { randomUUID } from 'node:crypto';
type RealtimeFixtures = { topic: string };
export const test = base.extend<RealtimeFixtures>({
topic: async ({}, use, testInfo) => {
const topic = `${testInfo.workerIndex}-${randomUUID()}`;
await use(topic);
},
});
export { expect } from '@playwright/test';
Do not use waitForTimeout(2000) as synchronization. It passes on a fast laptop and fails under a busy CI runner. Wait for connection_ack, the matching subscribe frame, the publisher response, and finally the expected DOM state. Each checkpoint describes a meaningful state transition.
Retain traces only on failure or first retry because raw frames can contain sensitive payloads. Redact tokens in server logs and never attach connection_init payloads containing secrets to public CI artifacts. If a test fails, record the operation ID, topic, publisher request ID, and server correlation ID. Those four values let you follow an event across layers without printing customer data.
Verify isolation by running npx playwright test --workers=4 --repeat-each=5. All topics should differ, and each test should render exactly one matching update. If failures appear only in parallel, inspect shared test users, event fan-out rules, and cleanup before increasing retry counts.
Which Should You Choose
Choose the UI plus real server approach for the few journeys where a missing live update would directly harm a user: chat delivery, payment status, deployment completion, or urgent alerts. It offers the strongest confidence because it covers connection creation, subscription registration, event production, payload handling, state management, and rendering. Keep the number of these tests controlled because every extra dependency expands diagnosis time.
Choose the in-page graphql-ws client for the broader protocol matrix. It is the right boundary for connection authentication, variables, aliases, multiple operations, GraphQL errors, completion, and authorization by tenant or topic. The returned objects are easier to assert precisely than DOM text.
Use frame observation as a companion rather than a standalone success criterion. Frames answer whether the browser sent subscribe, which operation ID carried a result, and whether cleanup happened. They cannot prove that the app interpreted the payload correctly.
Use a mocked transport for component states that are dangerous or expensive to create, such as malformed optional fields, a burst of 50 notifications, or a rare moderation result. Preserve at least one real integration path. A sensible suite has many resolver and component tests, a focused set of direct subscription integration tests, and a small set of end-to-end Playwright journeys.
Troubleshooting
The server never sends connection_ack -> Confirm the negotiated subprotocol is graphql-transport-ws, check that connection_init contains the expected credential shape, and verify the reverse proxy forwards WebSocket upgrades. A server using the old protocol will not understand the current message types.
The event is published but the test receives nothing -> Wait until the matching subscribe frame is observed before calling the publisher. Check that subscription variables and producer routing keys use the same topic, including case and tenant prefix.
The frame collector is empty -> Register page.on('websocket') before navigation. If a service worker owns the socket, add application-level instrumentation or use the direct client harness because page WebSocket events may not expose that connection.
The test passes alone but fails with multiple workers -> Replace fixed topics and shared mutable accounts with per-test identifiers. Confirm cleanup completes and that the event broker does not retain old events for a newly reused topic.
The UI updates twice after reconnecting -> Count matching rendered items or reducer calls. Duplicate updates usually indicate two live operations after reconnect, a missing disposer, or absent event deduplication by event ID.
CI closes the socket while local runs pass -> Inspect proxy idle timeouts and keepalive configuration. Do not add aggressive application pings solely for the test; align client, gateway, and server settings with production behavior.
Interview Questions and Answers
A strong interview explanation separates transport correctness, GraphQL operation behavior, and visible user behavior. The model answers in the structured interview section below cover handshake ordering, correlation IDs, authentication, cleanup, reconnection, and test isolation. Use those concepts to explain why one giant UI test is not an adequate subscription strategy.
When discussing tools, be precise: Playwright can observe browser WebSocket frames, control the page, call producer APIs through APIRequestContext, and run a browser-hosted client. It does not provide a GraphQL subscription assertion API. Your test composes standard WebSocket or graphql-ws behavior with Playwright assertions.
Common Mistakes
- Publishing before the server registers the subscription creates a race. Observe the matching
subscribeframe or an application-ready signal first. - Asserting only that some
nextmessage arrived allows unrelated subscriptions on a shared socket to pass the test. Match operation ID, topic, and payload fields. - Reusing one topic across tests makes parallel workers consume or display one another's data. Generate a unique identifier in a fixture.
- Testing only a mocked callback skips the WebSocket handshake, connection parameters, serialization, server authorization, and routing. Keep a real integration path.
- Treating a fixed delay as proof of no event creates slow, ambiguous negative tests. Assert an explicit authorization error, close code, or server-side rejection.
- Expecting the socket to close after every unsubscribe misunderstands multiplexing. Assert operation
complete; require socket closure only when the application's lifecycle promises it. - Logging raw connection payloads can leak bearer tokens into traces. Redact secrets and retain artifacts only as long as debugging requires.
- Using Playwright for load testing produces expensive, misleading results. Validate functional behavior in browsers and use a purpose-built WebSocket load tool for concurrency and throughput.
Where To Go Next
Extend the suite in layers. Add schema and resolver tests first, direct client tests for each protocol branch second, and only then critical UI journeys. Use Playwright API testing patterns to make producer calls and seed test state without navigating through setup screens.
Then add contract cases for nullability, union payloads, authorization, and multiple operations on one socket. If subscriptions are part of an interview or team enablement plan, review the GraphQL testing interview questions and ask engineers to explain both event correlation and teardown.
Conclusion
The reliable answer to how to test GraphQL subscriptions with Playwright is to combine boundaries. Prove one critical event through the real browser UI, cover protocol branches with a direct graphql-ws client, and observe frames to diagnose registration, delivery, and completion. Synchronize on meaningful protocol events instead of time, and isolate every test with a unique routing key.
Start with the order-status happy path, run it repeatedly in parallel, then add invalid authentication and cleanup. Once those remain deterministic in CI, expand the matrix to reconnection and rare payloads without turning every case into a costly end-to-end journey.
Interview Questions and Answers
How would you test a GraphQL subscription with Playwright?
I would attach a WebSocket listener before navigation, open a page that subscribes to a unique topic, and wait until the matching subscribe frame is sent. I would publish an event through a public or test-scoped API, assert the correlated next payload, and verify the user-visible DOM update. Finally, I would navigate away and confirm the operation is completed or disposed.
Why is waiting for the subscribe frame important?
Publishing immediately after navigation creates a race because the server may not have registered the consumer yet. The subscribe frame is a meaningful synchronization point that proves the client requested the operation. For stronger server-side certainty, I would also use an acknowledged test hook if the product provides one.
How do you correlate messages when one WebSocket carries several subscriptions?
I capture the operation ID from the outgoing subscribe frame and match incoming next, error, or complete frames by that ID. I also assert a domain identifier such as topic or order ID in the payload. This prevents unrelated traffic on a multiplexed connection from satisfying the test.
What should a GraphQL subscription authentication test assert?
It should assert the server's documented rejection path for missing, expired, or unauthorized credentials. Depending on the design, that can be a connection close, a protocol error for the operation, or a GraphQL errors payload. Merely receiving no data is ambiguous and is not a strong security assertion.
How do you prevent flaky subscription tests in parallel CI runs?
I generate a unique routing key per test and use it in subscription variables, event publication, and assertions. I wait for protocol milestones rather than fixed sleeps, avoid mutable shared users, and verify operation cleanup. On failure, I retain the operation ID, topic, and server correlation ID.
How would you test reconnection without creating duplicate updates?
I would terminate the connection through a controlled test endpoint, observe a new connection and subscription, then publish one uniquely identified event. The assertion would count exactly one rendered update and one handled event ID. I would also confirm the old operation was removed so the test detects duplicate live consumers.
Frequently Asked Questions
Can Playwright test GraphQL subscriptions directly?
Playwright has no GraphQL-specific subscription assertion API, but it can observe browser WebSocket frames, execute a graphql-ws client inside a page, trigger events through APIRequestContext, and verify the UI. Those capabilities are enough for complete functional subscription tests.
Should I mock GraphQL subscriptions in Playwright?
Mock the transport for rare UI states and deterministic component behavior, but retain real-server coverage for critical paths. A mock does not validate the handshake, connection authentication, event routing, serialization, or unsubscribe behavior.
How do I wait for a GraphQL subscription without using a timeout?
Attach a WebSocket frame collector before navigation and poll until the browser sends the matching subscribe frame. Publish the event only after that milestone, then use a locator assertion or poll for the correlated next payload.
How do I test GraphQL subscription authentication?
Send credentials through the client's documented connectionParams shape and test valid, expired, missing, and wrong-tenant cases. Assert the explicit server behavior, such as socket closure or an operation error, instead of treating silence as authorization success.
Why does my GraphQL subscription test fail only in CI?
The usual causes are publishing before registration, reused topics across workers, leaked operations, proxy idle timeouts, or shared accounts. Use unique topics, wait for subscribe, assert cleanup, and capture correlation IDs in failure artifacts.
Which GraphQL WebSocket protocol should new tests use?
Use the graphql-transport-ws subprotocol implemented by graphql-ws when that matches your server. Do not mix its message types with the retired subscriptions-transport-ws protocol because their handshakes and frames are incompatible.
Related Guides
- How to Add CI to a test framework (2026)
- How to Add logging to a test framework (2026)
- How to Add reporting to a test framework (2026)
- How to Build a BDD framework with Cucumber and Playwright (2026)
- How to Debug a failing test in VS Code in Playwright (2026)
- How to Run a single test in Playwright (2026)