Core Route Interception Setup #
page.route() accepts a string, glob, or regex matcher and hands each matching request to a handler. Register the route before navigation, then terminate the handler with route.fulfill() for a stub or route.continue() for real traffic.
// tests/api-mocking.spec.js — register before goto so nothing escapes.
const { test, expect } = require('@playwright/test');
test('loads user list from mock', async ({ page }) => {
await page.route('**/api/v1/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json', // set it, or the frontend parser breaks
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
});
});
await page.goto('/users');
await expect(page.getByRole('listitem')).toHaveCount(1);
});
Dynamic Payload Generation & Contract Validation #
Static stubs rot as contracts evolve. Inspect route.request() to build a context-aware response — for example, echoing a path parameter — and validate the shape against your OpenAPI schema so a drifted contract fails the test instead of silently passing. This is the API Contract Validation in E2E Tests discipline applied at the route layer.
// Extract the path parameter to build a context-aware response.
test('loads order detail from dynamic mock', async ({ page }) => {
await page.route(/\/api\/v1\/orders\/.*/, async route => {
const orderId = route.request().url().split('/').pop();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: orderId, status: 'fulfilled' }), // echoes the requested id
});
});
await page.goto('/orders/42');
await expect(page.getByTestId('order-status')).toHaveText('fulfilled');
});
Handling CORS & Pre-flight OPTIONS Requests #
Fulfilling a route bypasses browser CORS, but a real cross-origin app still issues OPTIONS preflights that your handler must answer, and route.continue() to a cross-origin backend still triggers CORS. Fulfill the preflight explicitly with the right Access-Control-Allow-* headers.
// Answer preflight explicitly; stub reads; forward writes for integration coverage.
await page.route('**/api/**', async route => {
if (route.request().method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}});
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, body: '{}', contentType: 'application/json' });
} else {
await route.continue(); // keep writes real to exercise the backend
}
});
Troubleshooting Route Registration Timing #
If a mock never fires, the route was almost certainly registered after the request. Pair page.route() with page.waitForResponse() inside a Promise.all so navigation and interception are synchronized, and prefer regex with explicit parameter boundaries over loose globs.
test('route registered before navigation', async ({ page }) => {
await page.route('**/api/data', route => route.fulfill({
status: 200, body: '{"items": []}', contentType: 'application/json',
}));
const [response] = await Promise.all([
page.waitForResponse('**/api/data'), // synchronise on the real interception
page.goto('/dashboard'),
]);
expect(response.status()).toBe(200);
});
Common Pitfalls #
- Registering routes after navigation causes missed interceptions.
- Broad globs (
**/api/**) mask unrelated failures and inflate false passes. - Forgetting
OPTIONSpreflight when testing cross-origin flows. - Hardcoding timestamps or UUIDs breaks assertions on dynamic fields.
FAQ #
Why do my Playwright API mocks fail intermittently in CI?
Almost always a race between route registration and request dispatch. Call page.route() before navigation and synchronize with page.waitForResponse().
Can I mock GraphQL with page.route()?
Yes — GraphQL uses one POST endpoint, so intercept the URL and read route.request().postData() to match operationName, per Mocking GraphQL Operations in Playwright.
How do I verify the mock actually triggered?
Use page.waitForResponse() with a predicate on response.url() and response.status() before asserting on the DOM.
Reliability Metrics #
| Metric | Impact |
|---|---|
| Flakiness reduction | 60–85% fewer network-dependent failures |
| Execution time | 40–70% faster without external latency |
| Maintenance overhead | Low–medium; periodic OpenAPI sync |
| CI stability score | High — deterministic payloads remove rate-limit variance |
Sequences, Counters and Where Their State Lives #
Many realistic scenarios are sequences rather than single responses: fail then succeed, rate-limit then allow, return page one then page two. Expressing them requires the handler to remember what it has already done, and where that memory lives determines whether the test is isolated.
A counter declared inside the test body has the test’s lifetime and cannot affect anything else. The same counter at module scope is shared by every test in the file — and, because handlers are frequently registered in a shared fixture, occasionally by every test in the run. That is the shared-mutable-state problem in miniature, and it produces the classic signature of a test that passes alone and fails in the suite.
// Sequence state belongs to the test, not to the module.
// Trade-off: declaring it inline is slightly more verbose than a module-level
// helper and is the only version that cannot leak into the next test.
test('recovers after a transient failure', async ({ page }) => {
let attempt = 0;
await page.route('**/api/invoices', async (route) => {
attempt += 1;
if (attempt === 1) return route.abort('connectionfailed');
return route.fulfill({ status: 200, json: { invoices: [{ id: 'INV-1' }] } });
});
// …
});
The same reasoning applies to any per-scenario data a handler closes over: a queue of responses, a mutable store, a recorded list of requests. If it survives the test, it will eventually answer for a test that expected something else.
Fulfil, Modify, Abort or Pass Through #
A route handler has four distinct powers, and reaching for the weakest one that does the job keeps tests both readable and resilient.
Fulfilling replaces the response with a literal. It is the most deterministic and the most explicit — a reader sees exactly what the application receives — which makes it right for error cases, empty states and any payload small enough to read inline.
Modifying lets the real request proceed and alters the response on the way back: injecting a field, changing a status, truncating a list. It keeps the fidelity of a genuine payload while changing the one thing under test, at the cost of depending on the real service being available — so it belongs in a small number of specs rather than as a default.
Aborting models network failure and accepts an error code, which lets a test distinguish a refused connection from a name-resolution failure and check that the interface does not present one as the other.
Passing through states explicitly that a particular host is meant to be reached. It is far preferable to a permissive global setting because it is bounded, visible in the code, and auditable in review.
// Choose the weakest power that expresses the scenario.
// Trade-off: modifying a real response is more faithful and reintroduces a
// dependency on the real service; fulfil unless fidelity is the point.
await page.route('**/api/invoices', (route) =>
route.fulfill({ status: 200, json: { invoices: [] } })); // empty state
await page.route('**/api/rates', (route) => route.abort('connectionfailed')); // failure
Scoping, Cleanup and Fixtures #
Where a route is registered determines both what it covers and how long it lives, and mixing the two scopes without a rule produces suites where nobody can predict which handler answers.
Page-level routes cover one page and disappear with it, which suits anything a single test needs. Context-level routes apply to every page created from that context — including popups, which is often exactly what an authentication window requires — and they persist for the context’s lifetime. In suites that create contexts themselves rather than relying on the per-test fixture, an unremoved context route leaks into later tests and answers requests with a scenario from a test that finished minutes ago.
The layering rule follows from precedence: the most recently registered matching handler wins. A fixture supplying the realistic baseline should therefore register first, and a test’s own scenario second. A catch-all intended to abort unexpected traffic must register first of all, or it swallows the specific handlers meant to run.
Practically, that means the baseline belongs in a fixture — so every spec starts from the same picture of the API without restating it — and scenario behaviour belongs inline in the test, where a reader can see it beside the assertion it supports. Scenario behaviour hidden in a fixture is the most common reason a spec cannot be understood on its own.
Keep the Fixture Small Enough to Read #
A payload beyond a screenful belongs in a file referenced by path rather than inline, and a payload beyond a few hundred kilobytes belongs in a recorded archive with its own review. Readability is what keeps a mock honest: an unread fixture is an unverified assumption about the API.
Registering the baseline in a fixture and the scenario in the test keeps every spec readable on its own, which matters more than it sounds: a reader who cannot tell what the API returned cannot judge whether the assertion is correct.