Mocking API Responses with Playwright
End-to-end tests that hit a live backend inherit every weakness of that backend: variable latency, shifting seed data, rate limits, and outages unrelated to the code under test. Playwright lets you intercept any HTTP request the page issues and answer it yourself with route.fulfill(), so the UI renders against a fixed payload you control. This page shows how to mock JSON APIs deterministically, when to mock versus hit the real service, and how to keep mocks from drifting out of sync with production contracts.
Root cause: tests inherit backend non-determinism
A request issued by the page resolves against whatever the network returns at that instant. The same test can pass at 9am and fail at 5pm because the seed database was reset, a feature flag flipped, or the staging API timed out under load. None of that is a defect in the front-end behavior you are trying to assert. Interception breaks the dependency: Playwright registers a handler on the browser context, and every matching request is paused before it leaves the browser so your handler decides the outcome. This is the foundation of Network Interception Basics, which sits under Advanced Interactions & Test Assertions.
The interception point matters. Playwright hooks the request at the browser level, not inside your application code, so the app's own fetch wrapper, retry logic, error boundaries, and JSON parsing all run exactly as they do in production. That is the difference between a route mock and a module-level stub: the stub replaces your networking layer and therefore stops testing it, while the route mock leaves the whole client stack intact and only swaps the bytes that come back over the wire. Handlers registered with context.route() apply to every page and popup in that context, which makes them a natural fit for the shared setup patterns described in Browser Contexts & Isolation.
Minimal reproducible example
The test below renders a dashboard that fetches /api/orders. Instead of a real backend, the route handler returns a fixed array, so the assertion on row count is deterministic on every run.
import { test, expect } from '@playwright/test';
test('dashboard renders mocked orders', async ({ page }) => {
// Register the handler BEFORE navigation so the first fetch is intercepted.
await page.route('**/api/orders', async (route) => {
// fulfill() answers the request without it ever reaching the network.
await route.fulfill({
status: 200,
contentType: 'application/json',
// body must be a string; stringify the mock object.
body: JSON.stringify([
{ id: 1, customer: 'Acme', total: 120 },
{ id: 2, customer: 'Globex', total: 80 },
]),
});
});
await page.goto('/dashboard');
// The grid is now driven entirely by the mock — no flakiness from the API.
await expect(page.getByRole('row')).toHaveCount(3); // header + 2 data rows
await expect(page.getByText('Globex')).toBeVisible();
});
Ordering is the whole trick. page.route() only installs a handler; it does not wait for anything, and it cannot retroactively catch a request that has already been dispatched. The diagram below traces the six events in the test above, from handler registration through the assertion, and shows exactly where the request stops.
Step-by-step fix
- Register the route before the request fires. Call
page.route(urlGlob, handler)(orcontext.route()for every page in the context) beforepage.goto()or the action that triggers the fetch. A handler registered after the request has already left does nothing. - Match the right URL. Use a glob like
**/api/ordersor aRegExp. Match on the path, not the full origin, so the mock survives environment changes between local, staging, and CI base URLs. When several handlers could match, Playwright runs the most recently registered one first, so an override registered inside a single test wins over a project-wide default. - Fulfill with a complete response. Pass
status,contentType, and a stringifiedbody. Mirror the real content type (application/json) so the app's parsing path is exercised exactly as in production. Add any headers the client reads —cache-control, pagination headers, or the CORS headers a cross-origin fetch requires — because a missing header fails the request inside the browser before your code ever sees it. - Keep fixtures typed and beside the test. Store mock payloads as typed objects or JSON files imported into the spec, ideally typed with the same interface the application uses. A contract change then becomes a compile error rather than a silent runtime mismatch, and the diff stays reviewable. Sharing them through a fixture, as in Playwright Config & Fixtures, keeps every spec on one copy.
- Vary the response when the flow demands it. A single static payload cannot express pagination, polling, or an optimistic update followed by a refetch. Close over a counter in the handler and return a different fixture per call, so the second request reflects the state the first one created.
- Assert on rendered state, not the mock. Verify what the user sees (
getByRole('row'), visible text) rather than re-asserting the payload you just wrote — otherwise the test only proves your mock equals itself. Web-first assertions retry until the DOM catches up, which removes the need for arbitrary waits. - Unroute when a later step needs the real API. Call
page.unroute('**/api/orders')to remove the handler mid-test if a subsequent step must hit the live service, and preferpage.unrouteAll({ behaviour: 'ignoreErrors' })during teardown so in-flight requests do not error after the page closes.
Choosing between fulfill, continue, and HAR replay
Interception offers more than the binary of mock-or-real. route.fulfill() invents the response, route.continue() forwards the request with optional edits, route.fallback() hands it to the next matching handler, and a recorded HAR file replays a real session's traffic verbatim. Each trades determinism against contract fidelity, and a mature suite uses more than one.
In practice the split is roughly ninety-ten. The bulk of the suite fulfills every application request and finishes in seconds because nothing waits on a network round trip. A nightly job then reruns the critical paths with the handlers removed, so a breaking schema change surfaces within a day. HAR replay sits between the two and is worth the extra file management when a flow touches a dozen endpoints; see Recording and Replaying HAR Files for the capture-and-update loop. GraphQL needs one more consideration, since every operation shares a single URL and must be matched on the request body instead — that is covered in Stubbing GraphQL Requests in Playwright.
Deciding which requests deserve a mock
Not every request wants the same treatment. Sort them by who owns the endpoint and what the test is actually asserting, and the decision falls out in one step.
Third-party endpoints — analytics beacons, payment tokenizers, map tiles, feature-flag services — should be fulfilled unconditionally. You cannot fix them when they break, their rate limits are not yours to raise, and a test that fails because a vendor had a bad minute teaches nobody anything. First-party endpoints get the split treatment described above. Static assets are a third case entirely: aborting images and fonts shaves seconds off every navigation, a technique explored in Blocking Images and Fonts to Speed Up Scraping, though leave them alone if the suite also does visual comparison.
Serving a different payload on each call
Pagination, polling, and refetch-after-mutation flows all need the second response to differ from the first. The handler is an ordinary closure, so keep the call index in scope and index into an array of fixtures.
import { test, expect } from '@playwright/test';
// Same shape the application declares, so a contract change is a type error.
interface Order { id: number; customer: string; total: number }
test('loading a second page appends rows', async ({ page }) => {
const pages: Order[][] = [
[{ id: 1, customer: 'Acme', total: 120 }],
[{ id: 2, customer: 'Globex', total: 80 }],
];
let call = 0;
await page.route('**/api/orders*', async (route) => {
// Serve fixtures in order; clamp so an extra request reuses the last page.
const body = pages[Math.min(call, pages.length - 1)];
call += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
// Signal to the UI whether a "Load more" button should stay enabled.
headers: { 'x-has-more': call < pages.length ? 'true' : 'false' },
body: JSON.stringify(body),
});
});
await page.goto('/dashboard');
await expect(page.getByRole('row')).toHaveCount(2); // header + first page
await page.getByRole('button', { name: 'Load more' }).click();
// The assertion retries until the second fixture has rendered.
await expect(page.getByRole('row')).toHaveCount(3);
expect(call).toBe(2); // exactly two fetches, no accidental duplicate
});
Asserting on call is worth the extra line: it catches a component that double-fetches on mount, a class of bug invisible when every response is identical. Pair this with the response-level checks in Asserting on API Responses Alongside UI when the payload itself is part of the contract under test.
Troubleshooting variants
The handler never fires
The glob did not match. Log every request with page.on('request', r => console.log(r.url())) and confirm the exact URL, including query strings. Remember globs match the full URL, so prefix with **/ to ignore the origin. If the request is issued from a service worker, register the route on the context and ensure the worker is not serving a cached response.
Mock works locally but the page shows a loading spinner in CI
The app likely issued the request before page.route() was registered because navigation started earlier in CI timing. Register the route immediately after creating the page and before any goto(). For requests fired by a third-party script, widen the glob or intercept the specific endpoint rather than the bundle. If the spinner appears only under load, the real cause may be a timing assumption rather than the mock — the diagnosis steps in Detecting and Fixing Flaky Playwright Tests separate the two.
The mocked response is served but the UI stays empty
The bytes arrived and the client rejected them. Check the browser console for a JSON parse error or a CORS failure: a cross-origin fetch needs access-control-allow-origin on the fulfilled response, and a contentType of text/plain will make some clients skip parsing entirely. A mismatched envelope has the same effect — if production returns { data: [...] } and the fixture returns a bare array, the component renders nothing while the network tab looks perfectly healthy.
Mock drifts from the real contract
A green test against a stale shape is worse than no test. Periodically run a contract check that lets one request route.continue() to the real API and validates the response against the same schema your mock uses (for example with a shared zod schema). Pair this with Intercepting and Modifying Network Requests when you need to assert on outgoing payloads too.
Verification
Confirm the mock is in force four ways. First, the assertion on rendered rows is stable across repeated runs (npx playwright test --repeat-each=10). Second, open the trace with --trace on and inspect the Network tab in the Playwright Trace Viewer — fulfilled requests are flagged as served from the route handler, not the server. Third, take the backend offline and rerun: a correctly mocked test still passes, proving zero live dependency. Fourth, watch the wall-clock time; a suite that drops from minutes to seconds after mocking is direct evidence that the network is no longer on the critical path, and any spec that stays slow is still reaching something you meant to intercept.
Frequently Asked Questions
Should I mock every request in end-to-end tests?
No. Mock third-party and unstable dependencies to remove flakiness, but keep at least one suite that exercises the real API so contract drift is caught. Mock for breadth and speed; hit the real service for confidence.
What is the difference between route.fulfill() and route.continue()?
route.fulfill() answers the request entirely from your handler so it never reaches the network. route.continue() lets the request proceed to the real server, optionally with modified headers, method, or post data.
Does a route registered on the page affect other tests?
No. Routes are scoped to the page or context they were registered on, and each test gets a fresh context by default, so handlers do not leak between tests.
How do I mock an error response or a slow endpoint?
Fulfill with the status you want — { status: 500, contentType: 'application/json', body: '{"error":"boom"}' } — to exercise error boundaries and retry logic. For latency, await a timer inside the handler before fulfilling, which lets you assert that a skeleton or spinner appears while the request is outstanding.
Which handler wins when two routes match the same URL?
The most recently registered handler runs first, so a per-test override placed after a shared setup route takes precedence. If that handler calls route.fallback() instead of fulfilling, the next matching handler gets its turn, which is how you layer a narrow exception on top of a broad default.