The unifying problem is identity. A REST mock keys off the URL path, but every GraphQL query and mutation hits the same /graphql path, and a WebSocket has no per-message URL at all. Reliable mocking for both means matching on payload contents — the GraphQL operationName or query text — and, for streams, taking control of when each frame arrives instead of leaving it to the server.
Prerequisites #
| Component | Version | Notes |
|---|---|---|
| Playwright | 1.40+ |
page.route('**/graphql') + request.postDataJSON() |
| Cypress | 13+ |
cy.intercept('POST', '/graphql') with req.body routing |
| MSW | 2+ |
graphql.query/graphql.mutation handlers, ws link |
| Fixtures | JSON per operation | One file per operationName keeps stubs readable |
| App transport | graphql-ws / SSE / native WebSocket |
Determines the stream mock approach |
GraphQL requests are POSTs whose body carries { query, variables, operationName }. Routing on operationName (or a substring of query when the client omits the name) is what replaces path matching.
Step-by-step implementation #
1. Route GraphQL by operation name, not path #
Inspect the POST body and branch to the matching fixture so one handler covers the whole endpoint.
// Playwright: single /graphql route fans out by operationName
await page.route('**/graphql', async (route) => {
const { operationName } = route.request().postDataJSON();
const fixtures: Record<string, string> = {
GetUser: 'user.json',
ListOrders: 'orders.json',
};
const file = fixtures[operationName]; // unknown ops fall through deliberately
if (!file) return route.continue(); // trade-off: continue() hits the real server — keep it intentional
await route.fulfill({ path: `fixtures/${file}` });
});
Trade-off: Falling through with route.continue() keeps unstubbed operations honest but reintroduces network nondeterminism for them — only allow it for operations you have deliberately chosen not to mock. Per-operation Playwright detail lives in Mocking GraphQL Operations in Playwright.
2. Do the same in Cypress with body inspection #
Cypress matches on path then branches inside the handler on req.body.operationName.
// Cypress: alias per operation for explicit waits
cy.intercept('POST', '/graphql', (req) => {
const op = req.body.operationName;
if (op === 'GetUser') req.reply({ fixture: 'user.json' });
if (op === 'ListOrders') req.reply({ fixture: 'orders.json' });
req.alias = op; // enables cy.wait('@GetUser') for deterministic ordering
}).as('graphql');
Trade-off: Aliasing each operation lets you cy.wait('@GetUser') to gate assertions on the response, removing the race — at the cost of more verbose intercepts. This is the same aliasing discipline from Cypress Network Interception Patterns.
3. Mock WebSocket frames deterministically #
A live socket emits frames whenever the server feels like it; a mock should emit them only when the test commands it. Override the page’s WebSocket before the app connects.
// Playwright: install a controllable WebSocket before navigation
await page.addInitScript(() => {
class MockSocket extends EventTarget {
readyState = 1;
send() {}
close() {}
// expose a hook the test drives via page.evaluate
emit(data: unknown) {
this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) }));
}
}
// @ts-expect-error replace global so the app gets the mock
window.WebSocket = MockSocket; // trade-off: bypasses real handshake — fine for UI logic, not protocol tests
});
Then push frames on demand and assert between them.
// Playwright: drive scripted frames, asserting after each
await page.evaluate(() => (window as any)._sockets?.[0]?.emit({ type: 'price', value: 42 }));
await expect(page.getByTestId('price')).toHaveText('42'); // settled before next frame
Trade-off: Replacing the global WebSocket validates UI reaction logic deterministically but skips the real protocol handshake — keep a thin separate test against a real socket if the handshake itself is under test.
4. Script Server-Sent Events the same way #
SSE is one long HTTP response; fulfill it with a pre-baked event stream body.
// Playwright: fulfill an SSE endpoint with scripted events
await page.route('**/events', async (route) => {
const body = 'data: {"type":"tick","n":1}\n\n' + 'data: {"type":"tick","n":2}\n\n';
await route.fulfill({
contentType: 'text/event-stream', // browser parses these as discrete events
body, // all events delivered at once — deterministic, no inter-event timing
});
});
Trade-off: Delivering all events in one body is fully deterministic but collapses inter-event timing, so it cannot test debounced-by-arrival-time UI — fake timers alongside it if arrival cadence matters.
Configuration reference #
| Option | Accepted values | Default | Effect on flakiness |
|---|---|---|---|
| Route match key | operationName / query substring |
path only | Operation-name match prevents wrong-fixture mixups |
route.continue() for unknowns |
allow / block | n/a | Allowing reintroduces real-network nondeterminism |
Cypress req.alias |
string per op | unset | Enables gating waits, removing response races |
| WebSocket override timing | addInitScript / inline |
none | Must run before app connects or real socket wins |
| SSE delivery | batched / timed | batched | Batched is deterministic; timed needs fake timers |
Caching Clients Change What a Mock Means #
GraphQL clients cache aggressively, and that cache sits between the mock and the interface — which means a handler can be answering correctly while the component renders something else entirely.
Three behaviours cause it. Normalised caching stores entities by identifier, so a query returning an entity with the same id as a previously cached one may render merged data rather than the payload just returned. Cache-first fetch policies skip the network entirely when an entry exists, so the mock is never called and a test waiting for it hangs. And optimistic updates render a mutation’s expected result before any response arrives, so an assertion immediately after a mutation may be observing the optimistic value rather than the server’s.
The practical consequences are worth internalising because the symptoms are misleading. A mock that “does not apply” is frequently a cache hit. A test that passes alone and fails in a suite may be seeing a cache populated by an earlier test — the same state-leakage pattern as any other shared store, and one that a fresh browser context resolves only if the cache is not persisted.
Two habits handle nearly all of it: reset the client’s cache between tests as part of the same teardown that resets handlers, and be explicit about which value an assertion is checking. Where the optimistic path matters, assert both stages deliberately — the optimistic render, then the confirmed one — rather than letting the timing decide which one the test happens to see.
// Reset the client cache alongside the handler reset.
// Trade-off: clearing the cache per test removes realistic cache behaviour from
// most specs; keep one spec that exercises caching deliberately.
afterEach(async () => {
await apolloClient.clearStore();
server.resetHandlers();
});
Frequently Asked Questions #
Why does my GraphQL mock never get called? Either the client answered from cache without a network request, or the matcher did not identify the operation — a matcher on the endpoint path matches every operation, so the first registered handler may be answering instead. Check the network panel for whether a request was made at all before investigating the handler.
How should subscriptions be tested? Split the concerns. Message handling, ordering and deduplication are best verified with a controlled transport where the test decides exactly what arrives and when. Connection lifecycle — losing a connection and recovering — needs a real local server, because reconnection is the behaviour under test and a fake has none. Trying to do both with one mechanism produces a fake that is nearly as complex as the real thing.
Is mocking at the transport level better than mocking the client? Usually yes. Replacing the client’s own methods verifies that components call the client correctly and skips serialisation, cache behaviour and error mapping entirely — which is where a surprising number of defects live. Mocking at the transport keeps all of that in play while remaining deterministic.
Do batched queries break operation-level matching? They can. A batch sends several operations in one HTTP request, so a handler matching on the first operation name will answer for the whole batch. Where the client batches, the handler must inspect the array and respond per operation, or batching should be disabled in test builds — a legitimate choice provided the production configuration is exercised somewhere.
Interpreting the data #
Operation-routed mocks turn an opaque /graphql 500 into a precise signal: a failing PlaceOrder fixture isolates the mutation path without touching queries. When triaging, log the resolved operationName per request so a flake report shows exactly which operation was unmatched — an unmatched operation that fell through to the real server is the most common hidden cause of GraphQL test flakiness.
For streams, the metric is ordering correctness, not latency. A correctly scripted socket asserts the same UI state on every run regardless of machine speed; if the assertion between frames is still racy, a frame is being emitted before the previous render settled. Feed unmatched-operation counts into historical flakiness tracking analytics and escalate any operation whose fall-through rate is non-zero, since that is a determinism leak, not noise.
Why GraphQL Needs Operation-Level Matching #
URL-based interception assumes that different requests go to different places, and GraphQL breaks that assumption completely: every query, mutation and subscription posts to the same endpoint. A matcher on the path therefore catches all of them, and whichever handler was registered first answers a request that may have been intended for another.
The discriminator has to come from the request body, where the operation name and variables live. Matching on operation name gives the same specificity that a path gives in REST, and matching on variables in addition distinguishes the same query issued with different arguments — the GraphQL equivalent of a paginated endpoint returning different pages.
Two practical complications follow. Batched requests put several operations in one HTTP call, so a handler matching on the first operation name will answer for the whole batch, including operations it knows nothing about; a suite whose client batches needs handlers that inspect and respond per operation rather than per request. And persisted queries send a hash instead of the query text, which makes operation names unavailable unless the client also sends them — worth checking early, because the symptom is a matcher that never fires for reasons invisible in the request URL.
// Match on the operation, not the path — every request shares the endpoint.
// Trade-off: body inspection is more code than a URL matcher and it is the only
// thing that distinguishes one GraphQL operation from another.
await page.route('**/graphql', async (route) => {
const { operationName, variables } = route.request().postDataJSON();
if (operationName !== 'GetInvoices') return route.fallback(); // let others match
await route.fulfill({ json: { data: { invoices: fixtureFor(variables.status) } } });
});
Falling through rather than answering everything is the key habit: a handler that responds to operations it does not recognise turns a missing mock into a wrong answer, which is much harder to diagnose than an unhandled request.
Streaming Protocols and Their Distinct Failure Modes #
Real-time transports fail differently from request/response APIs, and testing them as though they were the same is why real-time features are so often the least covered part of an application.
Connection lifecycle is the first difference. A socket can open, close, and reopen; a page can be backgrounded and lose its connection; a proxy can silently drop an idle connection without either side noticing. Reconnection logic is where real-time features break most often in production, and it is invisible to any test that only pushes messages into an already-open connection.
Message ordering and duplication is the second. Real feeds deliver out-of-order updates, occasional duplicates and bursts, and each is a branch in the application’s reducer. These are trivially easy to produce with a controlled transport and nearly impossible to produce on demand from a real feed, which makes them the strongest argument for a fake in the first place.
Outbound frames are the half nobody asserts on. Subscriptions, acknowledgements and heartbeats are contracts the client must uphold, and a broken heartbeat manifests in production as connections silently dropped by an intermediary — a failure mode with no error message anywhere in the client.
The practical split is to use a controlled transport for message handling, ordering and outbound assertions, where determinism matters and fidelity does not, and a real local server for connection lifecycle, where fidelity is the entire point. Mocking WebSocket Messages in Cypress covers both halves and the reason request interception cannot see socket frames at all.
Common pitfalls & mitigations #
- Matching GraphQL on path only. Every operation hits
/graphql, so a path match returns the wrong fixture. Mitigation: branch onoperationNameinside the handler. - Letting unknown operations fall through silently. They hit the real server and reintroduce flakiness. Mitigation: log or fail fast on unmatched operations.
- Clients that omit
operationName. Some send onlyquery. Mitigation: match on a stable substring of the query string as a fallback. - Installing the WebSocket mock after the app connects. The real socket is already open. Mitigation: use
addInitScript/ register before navigation. - Asserting after firing two frames at once. The first render may not have settled. Mitigation: emit one frame, await the assertion, then emit the next.
Frequently Asked Questions #
Q: Why does my GraphQL mock return the same fixture for every query?
A: You are almost certainly matching on the /graphql path alone. All operations share that path, so the first handler wins. Read operationName from the POST body and branch to per-operation fixtures.
Q: How do I mock a GraphQL subscription that runs over WebSocket?
A: Treat it as a stream, not a request. Override the page’s WebSocket (or use a library like MSW’s ws handler) and emit scripted subscription frames on command so the UI receives deterministic updates.
Q: Can I test inter-event timing of a stream deterministically? A: Yes, by combining a scripted stream with fake timers. Emit each frame, advance the faked clock by the expected gap, and assert — this controls arrival cadence without real wall-clock waits.
Partial Errors Are the Normal Case #
REST conventions push failures into status codes, so a test asserting on a 200 has a reasonable first approximation of success. GraphQL does not work that way: a response can carry a 200 status, a populated data object and an errors array at the same time, describing a query where some fields resolved and others did not.
That partial shape is not an edge case — it is how field-level authorisation, downstream timeouts and nullable resolvers surface in normal operation. An interface that renders data without inspecting errors shows a page with silently missing sections, and a test that asserts only on the status code cannot tell that apart from a complete response.
Testing it requires fixtures that express the partial shape deliberately: data present with a null field, errors describing why, and a path pointing at the field that failed. Each such fixture exercises a branch that otherwise runs for the first time when a downstream service degrades in production.
// A realistic partial failure: 200, data present, one field errored.
// Trade-off: partial-error fixtures are more elaborate than a plain failure and
// they model what actually happens when one resolver degrades.
await route.fulfill({
status: 200,
json: {
data: { invoice: { id: 'INV-1', total: 1250, customer: null } },
errors: [{ message: 'customer service unavailable', path: ['invoice', 'customer'] }],
},
});
The assertion that matters is what the interface does: a missing section should be visibly absent or explained, not silently blank, and the rest of the page should still render. That behaviour is invisible to any suite whose fixtures are all fully successful.