Subtopic · Network & API Mocking for Reliable Tests

GraphQL & WebSocket Mocking for Reliable Tests

GraphQL multiplexes many operations over a single endpoint and WebSockets stream events with no request/response boundary, so neither fits the URL-and-method mocking model that powers most stubs — a gap that makes them frequent offenders within Network & API Mocking for Reliable Tests. This guide covers matching GraphQL responses by operation name rather than path, returning per-operation fixtures, and driving WebSocket and Server-Sent Event streams deterministically so real-time UI assertions stop racing the socket.

11 sections 1 child guides URL: /network-api-mocking-for-reliable-tests/graphql-and-websocket-mocking/
Operation-name routing and deterministic stream control Diagram showing a single GraphQL endpoint routed by operation name to fixtures, and a controlled WebSocket emitting scripted frames. POST /graphql inspect operationName GetUser -> user.json ListOrders -> orders.json PlaceOrder -> error.json Scripted WebSocket / SSE open frame 1 frame 2 close emit frames on command = no timing race
GraphQL stubs route on operation name; stream stubs emit scripted frames when the test says so.

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 on operationName inside 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 only query. 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.

Explore next

Child guides in this section