Playwright architecture, selector reliability, and advanced interaction patterns.

Stubbing GraphQL Requests in Playwright

REST gives every resource its own URL, so a glob pattern is enough to isolate one endpoint. GraphQL does the opposite: a page's entire data layer — every query, every mutation, every background refetch — is funnelled through a single POST to /graphql, and the operation is described inside the request body rather than by the path. A handler registered with page.route('**/graphql', ...) therefore matches everything at once, and a stub written for one query silently answers the sign-out mutation too. This page shows how to dispatch on operationName, how to hand unmatched operations onward with route.fallback(), and how to survive batching, persisted queries and multipart uploads. It builds directly on Network Interception Basics, part of Advanced Interactions & Test Assertions.

Every GraphQL operation shares one URL Three named operations post to a single graphql endpoint where one Playwright route handler reads operationName and decides to fulfill or fall back. GetOrders GetViewer SignOut POST /graphql one URL intercept route handler postDataJSON() read operationName GetOrders: fulfill GetViewer: fulfill SignOut: fallback
The URL identifies nothing useful in GraphQL; the operation name inside the POST body is the only key a stub can dispatch on.

Root cause: the URL is not the identifier

A GraphQL client multiplexes the whole application over one transport endpoint, so page.route() — which matches on URL glob, regex or predicate — cannot distinguish a product query from a checkout mutation. The discriminator lives in the JSON body as operationName, alongside query and variables, and it is only present when the document declares a named operation. Because Playwright requires every matched route to terminate exactly once, an unconditional route.fulfill() on **/graphql does not merely stub too much: it actively corrupts every other operation the page issues, which then surfaces as a client-side type error rather than a network failure.

Minimal reproducible example

The test below stubs the orders query the obvious way and passes its first assertion, which is exactly what makes the bug expensive to find. The sign-out mutation later in the same test posts to the same URL, hits the same handler, and receives an orders payload it cannot parse.

import { test, expect } from '@playwright/test';

test('dashboard shows stubbed orders', async ({ page }) => {
  // Naive stub: matches the URL only. Every GraphQL operation on the page —
  // queries, mutations, background refetches — receives this same payload.
  await page.route('**/graphql', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ data: { orders: [{ id: 'o-1', total: 42 }] } }),
    });
  });

  await page.goto('/dashboard');

  // Passes: the orders query received the payload it expected.
  await expect(page.getByRole('row')).toHaveCount(1);

  // Fails: the SignOut mutation also matched '**/graphql' and was answered with
  // { data: { orders: [...] } }, so the client throws
  // "TypeError: Cannot read properties of undefined (reading 'signOut')"
  // and the redirect never happens.
  await page.getByRole('button', { name: 'Sign out' }).click();
  await expect(page).toHaveURL(/\/login/);
});

The failure has nothing to do with timing, so retries will not rescue it — this is a deterministic wrong answer rather than the race conditions covered in Detecting and Fixing Flaky Playwright Tests.

Step-by-step fix

  1. Route the endpoint, then filter inside the handler. Register await page.route('**/graphql', handler) before the first page.goto(), because a handler installed afterwards misses the requests the client fires during hydration. Keep the URL pattern broad; all selectivity belongs in the handler body, where you have access to the parsed request.
  2. Read operationName from the parsed POST body. route.request().postDataJSON() parses the payload into { operationName, query, variables }. Guard it: the method returns null when there is no request body, and it throws a SyntaxError when the content type is multipart/form-data, which is what file uploads following the GraphQL multipart request specification send. Check route.request().headers()['content-type'] first, and fall back to reading operationName from the query string for GET-based persisted queries.
  3. Fulfill with a complete GraphQL envelope. A GraphQL response is always an object with a data key, optionally accompanied by errors and extensions. route.fulfill({ status: 200, json: payload }) serializes the object and sets content-type: application/json for you; passing a bare array or a naked field value produces ServerParseError: Unexpected token < in JSON at position 0 or a null-property crash inside the client's normalizer.
  4. Delegate unmatched operations with route.fallback(), never route.continue(). route.fallback() hands the request down to the next matching handler and reaches the network only if none remain, so several narrow stubs can coexist on the same endpoint. route.continue() terminates the chain immediately and sends the request to the server, skipping every handler registered earlier. Both count as terminating the route, so calling one after the other raises Error: route.fulfill: Route is already handled!.
  5. Model GraphQL failures as HTTP 200 with an errors array. Field-level failures — authorization, validation, resolver exceptions — travel inside a successful HTTP response. Stubbing a 500 instead exercises the client's transport-error branch (ServerError: Response not successful: Received status code 500) rather than the error-rendering path your UI actually uses in production, so the assertion proves the wrong thing.
  6. Package the stub as a fixture and unroute it explicitly. Wrap registration in a fixture so each spec declares only the operations it cares about, and call await page.unrouteAll({ behavior: 'ignoreErrors' }) during teardown. Without it, an in-flight request can reach a handler after the page has closed and fail the run with route.fulfill: Target page, context or browser has been closed. Fixture composition is covered in Playwright Config & Fixtures.

Here is the dispatcher those first four steps describe, written so a single handler serves any number of stubbed operations.

import { test, expect, type Route } from '@playwright/test';

// Map operation name -> response body. Anything absent falls through untouched.
const stubs: Record<string, unknown> = {
  GetOrders: { data: { orders: [{ id: 'o-1', total: 42 }] } },
  GetViewer: { data: { viewer: { id: 'u-1', name: 'Ada' } } },
};

function operationNameOf(route: Route): string | null {
  const request = route.request();
  const contentType = request.headers()['content-type'] ?? '';
  // Multipart uploads are not JSON; postDataJSON() would throw a SyntaxError.
  if (!contentType.includes('application/json')) {
    // GET-based persisted queries carry the name in the query string instead.
    return new URL(request.url()).searchParams.get('operationName');
  }
  const body = request.postDataJSON();       // { operationName, query, variables }
  if (Array.isArray(body)) return null;      // batched payload, handled separately
  return body?.operationName ?? null;
}

test('stubs one operation and lets the rest through', async ({ page }) => {
  await page.route('**/graphql', async (route) => {
    const name = operationNameOf(route);
    const stub = name ? stubs[name] : undefined;

    // fallback() passes the request to the next matching handler, and to the
    // network only when none is left. continue() would skip those handlers.
    if (!stub) return route.fallback();

    // json sets content-type: application/json automatically.
    await route.fulfill({ status: 200, json: stub });
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('cell', { name: 'o-1' })).toBeVisible();
});

Because Playwright evaluates matching handlers in reverse registration order, the most recently registered stub wins, and each one that declines passes the request to the one beneath it. That ordering is what lets a spec-level stub override a project-wide default without either of them knowing about the other: a global fixture can install a catch-all that fulfills every operation with an empty envelope, and an individual test can register a narrower handler afterwards that intercepts one query and lets the rest drop through. The pattern only holds if every declining handler uses route.fallback(), since a single route.continue() anywhere in the chain short-circuits the remaining handlers and sends the request to a backend the test may not even have running.

How route.fallback walks the handler chain A single GraphQL request passes the most recently registered handler, falls back to a matching cart stub that fulfills it, and never reaches the oldest handler. One request: operationName = AddToCart route #3 registered last operationName does not match route #2 cart stub match: fulfill() ends the chain route #1 catch-all logger never reached on this request route.fallback() skipped fulfilled response status 200 JSON handlers run in reverse registration order
Each declining handler calls route.fallback() to pass the request down; the first handler that fulfills terminates the chain for good.

The fixture form below adds the two remaining steps: error envelopes and deterministic teardown. Put it in a shared fixtures.ts and import test from there in every spec that needs a stub.

import { test as base, expect } from '@playwright/test';

type GraphQLFixtures = {
  stubOperation: (name: string, body: unknown) => Promise<void>;
};

export const test = base.extend<GraphQLFixtures>({
  stubOperation: async ({ page }, use) => {
    await use(async (name, body) => {
      // One handler per stubbed operation; later registrations take priority.
      await page.route('**/graphql', async (route) => {
        if (route.request().postDataJSON()?.operationName !== name) {
          return route.fallback();
        }
        await route.fulfill({ status: 200, json: body });
      });
    });
    // Drop handlers before the page closes, so a late in-flight request cannot
    // fail the run with "Target page, context or browser has been closed".
    await page.unrouteAll({ behavior: 'ignoreErrors' });
  },
});

test('renders the permission error banner', async ({ page, stubOperation }) => {
  // A GraphQL failure is HTTP 200 with an errors array — not a 5xx.
  await stubOperation('GetOrders', {
    data: { orders: null },
    errors: [
      { message: 'Forbidden', path: ['orders'], extensions: { code: 'FORBIDDEN' } },
    ],
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('alert')).toHaveText(/Forbidden/);
});

Troubleshooting variants

Batched operations arrive as a JSON array

Clients configured with a batching transport — Apollo's BatchHttpLink, or graphql-request's batch mode — coalesce several operations issued within a short window into a single POST whose body is an array of operation objects. postDataJSON() returns that array, so body.operationName is undefined and every stub silently declines. The response must also be an array of results in the same order the operations were sent; answering with a single object makes Apollo throw Server response was missing for query 'GetOrders'. Detect the array shape first and map over it.

import { test, expect } from '@playwright/test';

test('answers a batched GraphQL payload', async ({ page }) => {
  await page.route('**/graphql', async (route) => {
    const body = route.request().postDataJSON();
    if (!Array.isArray(body)) return route.fallback();

    // The client pairs results with requests positionally, so preserve order
    // and emit exactly one entry per operation in the batch.
    const results = body.map((op: { operationName: string }) =>
      op.operationName === 'GetOrders'
        ? { data: { orders: [] } }
        : { data: null, errors: [{ message: `unstubbed: ${op.operationName}` }] },
    );

    await route.fulfill({ status: 200, json: results });
  });

  await page.goto('/dashboard');
  await expect(page.getByText('No orders yet')).toBeVisible();
});

The same operation is requested twice with PersistedQueryNotFound

Automatic persisted queries send only a SHA-256 hash of the document on the first attempt, omitting query entirely. A server that has never seen the hash replies with {"errors":[{"message":"PersistedQueryNotFound"}]}, and the client immediately retries the identical operation with the full document attached. A stub that counts invocations, or one wired to expect(handler).toHaveBeenCalledTimes(1), will see two hits. Worse, with useGETForHashedQueries: true the first attempt is a GET with no body at all, so postDataJSON() returns null and your dispatcher declines it. Read operationName from new URL(request.url()).searchParams whenever the body is empty, and treat repeat invocations as normal.

route.fulfill: Route is already handled!

Playwright allows exactly one terminal call per route. This error means a code path reached fulfill(), continue(), fallback() or abort() twice — most often because a guard clause called route.fallback() without return, letting execution fall through to the fulfill below it. Use return route.fallback(); rather than a bare call, and never mix a top-level try/catch that retries the whole handler. The related message route.continue: Route is already handled! appears when a waitForResponse() helper and a stub both try to service the same request; see Intercepting and Modifying Network Requests for the single-termination rule in full.

Choosing between fulfill, fallback, fetch, and abort

Not every GraphQL test wants a fully synthetic payload. A hand-written stub freezes the shape of the response at the moment you wrote it, so a schema change that renames a field or tightens a nullability constraint will not fail the test — the UI keeps rendering the stale mock and the regression escapes to production. That is the standing cost of stubbing, and it is why the decision should be made per operation rather than per suite. Stub the operations whose data is incidental to the assertion, such as feature-flag lookups or the user menu, and let the operation actually under test reach a real resolver wherever your environment allows it.

When you need real server data with one field pinned — a price, a feature flag, a timestamp — call route.fetch() to perform the real request, mutate the parsed JSON, and pass the result back through route.fulfill({ response, json }). That keeps the schema honest while removing the one value that made the assertion unstable. Reserve route.abort() for transport-failure simulations, where you want the client's offline branch rather than a GraphQL error. The matrix below summarizes when each outcome is the right terminal call.

Comparing the four terminal route outcomes A matrix listing fulfill, fetch plus fulfill, fallback and abort against the situation each suits and whether a real network request is made. Route outcome When to use it Real network route.fulfill() fixed data and error states no request sent route.fetch() + fulfill patch one field of live data one real request route.fallback() defer to the next handler next handler decides route.abort() simulate a transport failure connection refused
Pick the terminal call by how much of the real backend the assertion still needs; only fallback leaves the decision open.

For suites that stub dozens of operations, recording the traffic once and replaying it is cheaper to maintain than hand-written payloads — see Recording and Replaying HAR Files. Handlers registered on the context rather than the page apply to popups and new tabs as well, which matters when a mutation opens a payment window; the scoping rules are in Browser Contexts & Isolation.

Verification

Prove the stub is doing what you believe in three independent ways. First, assert on rendered output rather than on the handler: await expect(page.getByRole('cell', { name: 'o-1' })).toBeVisible() only passes if the fulfilled payload travelled through the client cache into the DOM, which no amount of handler bookkeeping can fake. Second, count invocations deliberately. Increment a counter inside the handler and assert it at the end of the test — a value of zero means the handler was registered after the request fired, and a value of two on a single query usually means a persisted-query retry or a client-side refetch you had not accounted for.

Third, read the trace. Run with --trace on and open the network panel described in Reading Network and Console Tabs in Traces: fulfilled requests appear with the status and body you supplied, so you can compare the served envelope against what the client expected without adding logging. If the panel shows a request reaching the real server, a handler called route.continue() where it should have called route.fallback().

One last check worth running before you trust a GraphQL stub in CI: navigate with the stub in place and confirm the page still renders correctly when the operation is not stubbed. Server-rendered frameworks resolve some queries on the server, where page.route() has no reach, so a test that only passes with the stub installed may be asserting on data the browser never requested. Pairing UI assertions with an independent API check, as in Asserting on API Responses Alongside UI, keeps that gap visible.

Frequently Asked Questions

How do I stub only one GraphQL operation without affecting the rest?

Register a broad page.route('**/graphql', ...) handler, parse the body with route.request().postDataJSON(), and compare operationName against the single operation you care about. If it does not match, return route.fallback() so the request continues down the handler chain and eventually to the server. Each stubbed operation can live in its own handler, because Playwright tries matching handlers newest-first and a declining handler costs nothing.

Should a stubbed GraphQL failure return HTTP 500?

Only if you are specifically testing the transport-error path. Resolver errors, authorization denials and validation failures all arrive as HTTP 200 with a populated errors array, and most clients route those to a completely different branch than a network failure. Stub { data: { field: null }, errors: [{ message, path, extensions }] } with status 200 to exercise the code your users actually hit, and reserve 500 or route.abort() for the outage scenario.

Why does my handler never fire on the first page load?

Route handlers only apply to requests issued after registration, so any page.route() call that comes after page.goto() misses the queries the client fires during hydration. Move registration ahead of navigation, or into a fixture that runs before the test body. If it still never fires, check whether the framework resolves that query during server rendering, in which case the request never leaves the server and no browser-level interception can see it.

Can Playwright stub GraphQL subscriptions?

Subscriptions travel over WebSocket rather than HTTP, so page.route() never sees them. Use page.routeWebSocket() to intercept the socket, then send framed messages matching the graphql-transport-ws protocol — a connection_ack followed by next payloads keyed to the subscription id the client sent in its subscribe message. Close with a complete frame so the client tears the subscription down cleanly.

Back to overview