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.fulfillin a per-test override withoutroute.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
beforeEachbeforepage.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.