Article · Network & API Mocking for Reliable Tests

Mocking GraphQL Operations in Playwright

A single GraphQL endpoint carries every query and mutation, so Playwright's path-based page.route cannot tell one operation from another without reading the request body — a recurring snag covered more broadly in GraphQL & WebSocket Mocking for Reliable Tests. This guide shows how to intercept **/graphql, inspect request.postDataJSON().operationName, and fulfill each operation with its own fixture so tests stay deterministic regardless of how many operations share the endpoint.

10 sections URL: /network-api-mocking-for-reliable-tests/graphql-and-websocket-mocking/mocking-graphql-operations-in-playwright/
Playwright routing one GraphQL endpoint by operation name Flow from page.route intercept to reading operationName to selecting the matching fixture or falling through. page.route **/graphql postDataJSON() read operationName GetUser -> fulfill user.json ListOrders -> orders.json unknown -> route.continue()
One route intercept reads the operation name and dispatches to the matching fixture.

Root cause #

Playwright matches routes by URL glob, and every GraphQL operation is a POST to the same **/graphql URL. A naive intercept therefore returns one fixture for all operations: whatever the handler replies with, the user query, the orders query, and the place-order mutation all receive it. Tests then flake or assert against the wrong shape because the response no longer corresponds to the operation the component issued. The fix is to look past the URL into the request body, where GraphQL clients place { query, variables, operationName }, and dispatch on operationName.

Step-by-step fix #

1. Intercept the endpoint and read the operation name #

Register a single route and pull operationName from the parsed POST body.

// tests/graphql.spec.ts
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.route('**/graphql', async (route) => {
    const body = route.request().postDataJSON(); // { query, variables, operationName }
    const op = body.operationName as string;
    // dispatch handled in step 2 — cost: one parse per request, negligible
  });
});

2. Dispatch each operation to its own fixture #

Map operation names to fixture files and fulfill with the match.

// inside the route handler
const fixtures: Record<string, string> = {
  GetUser: 'fixtures/graphql/user.json',
  ListOrders: 'fixtures/graphql/orders.json',
};
const file = fixtures[op];
if (file) {
  await route.fulfill({ path: file }); // exact per-operation response — no shape mismatch
} else {
  await route.continue(); // masking risk: unstubbed ops hit the real server — keep deliberate
}

This per-operation routing mirrors the REST approach in How to Mock REST APIs in Playwright, only keyed on the body rather than the path.

3. Override a single operation per test #

Layer a tighter route on top to force an error or edge case without rewriting the base handler.

test('shows error when PlaceOrder fails', async ({ page }) => {
  await page.route('**/graphql', async (route) => {
    const { operationName } = route.request().postDataJSON();
    if (operationName === 'PlaceOrder') {
      return route.fulfill({
        status: 200, // GraphQL errors return 200 with an errors[] array
        contentType: 'application/json',
        body: JSON.stringify({ errors: [{ message: 'Out of stock' }] }),
      });
    }
    return route.fallback(); // defer other ops to the beforeEach handler — performance: no duplication
  });

  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByText('Out of stock')).toBeVisible();
});

4. Match on query text when the client omits operationName #

Some clients send only query. Fall back to a stable substring match.

const op =
  body.operationName ??
  (body.query?.includes('mutation PlaceOrder') ? 'PlaceOrder' : undefined);
// trade-off: substring matching is brittle to query reformatting — prefer named operations

Pitfalls #

  • Returning one fixture for all operations — the wrong shape reaches the component. Mitigation: branch on operationName.
  • Using route.fulfill in a per-test override without route.fallback — other operations get dropped. Mitigation: route.fallback() for non-matching ops.
  • Assuming GraphQL errors are non-200 — they return HTTP 200 with an errors[] array. Mitigation: fulfill status 200 and put the error in the body.
  • Letting unknown operations route.continue() unnoticed — silent real-network calls reintroduce flakiness. Mitigation: log or fail on unmatched operations.
  • Registering the route after navigation — the first request escapes the mock. Mitigation: route in beforeEach before page.goto.

Reliability targets #

Metric Target How to track
Unmatched-operation rate 0% Log op per request, alert on fall-through
GraphQL test flake rate < 0.5% CI history per spec
Fixture-to-operation coverage 100% of operations the page issues Diff issued ops vs fixture map
Real-network calls in mocked tests 0 Network log assertion in CI

Frequently Asked Questions #

Q: How do I read the operation name from a Playwright request? A: Call route.request().postDataJSON() inside the route handler and read the operationName field. The body also contains query and variables if you need to match on those.

Q: My per-test override drops other GraphQL operations — why? A: A page.route handler that calls route.fulfill only for the matched operation leaves the rest unhandled. Call route.fallback() for non-matching operations so the base beforeEach handler still serves them.

Q: Should a failed mutation fixture return a 4xx or 5xx status? A: Neither, in most cases. GraphQL conventionally returns HTTP 200 with an errors array in the JSON body, so fulfill with status 200 and an { errors: [...] } payload to mimic real server behavior.