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.

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

Naive path match returns one fixture Because every operation is a POST to the same URL, a URL-only intercept replies with one fixture for all. GetUser ListOrders PlaceOrder one fixture for all
Reading operationName from the body is what separates the three operations.

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
Base handler plus per-test override A beforeEach handler serves the base fixtures; a per-test route forces one operation's error via fallback. beforeEach handlerbase fixtures per-test routePlaceOrder → error fallback()others deferred
route.fallback() defers non-matching operations so the base handler still serves them.

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.
GraphQL errors return HTTP 200 A failed mutation fixture uses status 200 with an errors[] array, not a 4xx/5xx. PlaceOrder fails status 200errors[] in body not 4xx/5xx
Mimic real GraphQL: a 200 with an errors array, not an HTTP error status.

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
Playwright GraphQL scorecard Targets for unmatched operations, flake rate, fixture coverage, and real-network calls. 0%unmatched ops < 0.5%flake rate 100%op coverage 0real-net calls
Zero unmatched operations and zero real-network calls confirm full coverage.

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.

One Endpoint, Many Operations #

The structural difference from REST is that every operation posts to the same path, so a route matcher on the URL catches all of them and the first handler answers whatever arrives. The discriminator has to come from the request body, where the operation name and variables live.

Matching on operation name gives the specificity a path gives in REST. Adding variables to the match distinguishes the same query issued with different arguments — the equivalent of a paginated endpoint returning different pages — and is worth doing wherever variables change the response.

The handler also needs a way to decline: an operation it does not recognise should fall through to other handlers rather than being answered with an unrelated fixture. A handler that responds to everything turns a missing mock into a wrong answer, which is considerably harder to diagnose than an unhandled request.

// Branch on the operation, and fall through for anything unrecognised.
// Trade-off: body inspection is more code than a URL matcher and it is the only
// way to tell one GraphQL operation from another.
await page.route('**/graphql', async (route) => {
  const { operationName, variables } = route.request().postDataJSON();
  const fixture = FIXTURES[operationName]?.(variables);
  if (!fixture) return route.fallback();          // let another handler try
  await route.fulfill({ json: { data: fixture } });
});

Two client behaviours complicate this. Batching puts several operations in one request, so a handler matching the first will answer for all of them; the handler must inspect the array and respond per operation, or batching should be disabled in test builds with the batched path covered elsewhere. Persisted queries send a hash instead of the query text, which removes the operation name unless the client also sends it — worth checking early, because the symptom is a matcher that never fires for reasons invisible in the URL.

Errors, Partial Data and the Cache #

GraphQL error handling differs enough from REST that fixtures modelled on REST conventions miss most of it.

A response can carry a 200 status, populated data and an errors array simultaneously, describing a query where some fields resolved and others did not. That is not an edge case: it is how field-level authorisation, downstream timeouts and failing nullable resolvers surface under normal operation. An interface that renders data without inspecting errors shows silently missing sections, and a test asserting only on the status cannot tell that apart from a complete response.

The client cache adds a second layer between the mock and the render. A cache-first policy can skip the network entirely, so the route is never called and a test waiting for it hangs; a normalised cache can merge a previously cached entity into the current view, so a component renders data from an earlier test. Both present as “the mock is not working” when the mock is working perfectly.

The practical remedies are small: reset the client’s store in the same teardown that resets routes, model at least one partial-error fixture per screen that aggregates several fields, and assert on what the interface does with a missing section rather than only on the happy path. Where caching behaviour is itself the subject, keep one spec that exercises it deliberately rather than leaving every spec at the mercy of whatever the cache retained.

Falling Through Rather Than Answering Everything #

A handler that responds to any operation it receives converts a missing mock into a wrong answer, which is considerably harder to diagnose than an unhandled request. Declining explicitly — falling through so another handler or the unhandled-request policy can act — keeps gaps visible.

The same reasoning argues against a permissive catch-all for the GraphQL endpoint. Because every operation shares one path, a catch-all there silences the entire surface, and a newly added query that nobody mocked will be answered with something plausible instead of failing loudly.

Keeping fixtures in one module per operation, exported as factories that take variables, means a schema change touches one file rather than every spec that inlined the payload — and a reader can see which operation a test overrides without scrolling through a body.

Where an operation is genuinely not mocked, letting the request fail loudly beats answering it with a plausible shape nobody intended.