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.
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();
});
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. - 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. - Keep fixtures beside the test. Store mock payloads as typed objects or JSON files imported into the spec, so a contract change is a single edit and the diff is reviewable.
- 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. - 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.
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.
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 three 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.
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.