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 |
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.
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.