"E2E tests don't have to hit your real backend. They just have to behave as if they did."
Why Intercept Network in E2E
An E2E test that hits a real API depends on three things being true at test time: the API is up, the API is fast, and the API returns deterministic data. Skip any one of those and you've got flake. page.route() lets you intercept outbound HTTP and respond from the test, removing all three failure modes for the cases where the backend's behavior isn't what you're trying to test.
When you DO want to test the real backend (integration end-to-end, contract verification), don't intercept. The two test modes serve different purposes and live alongside each other.
The Four Things You Can Do With a Route
When page.route(pattern, handler) fires, the handler receives a Route object with four exit paths:
route.fulfill({ status, body, contentType }) — respond with a fake response. The request never hits the network.
route.continue({ headers, postData }) — modify the request and send it to the real server.
route.abort() — fail the request. Useful for testing error paths.
route.fallback() — defer to the next matching route handler (route handlers stack).
Pattern Matching
The pattern can be:
A glob: '**/api/users/*'
A regex: /\/api\/users\/\d+/
A predicate function on the URL.
Glob is the easy default. Use regex when you need stricter matching (e.g., only numeric ids). Predicate when the match depends on more than the URL (method, headers).
Scoping — page vs context vs Browser
Three levels of scoping:
page.route() — only intercepts requests from this Page. Good default.
context.route() — intercepts from every Page in the context (rare; useful for shared-state browser contexts).
browser.route() — doesn't exist; you'd lift to a fixture instead.
Routes added in test.beforeEach last for the test; routes added in a fixture's setup last as long as the fixture.
Mock the network when the test is about the UI; don't mock when the test is about the network. A test that asserts 'the cart shows three items when the API returns three items' is a UI test — mock. A test that asserts 'the checkout flow completes against staging' is a contract test — don't mock.
HAR Files — Record Once, Replay Forever
For complex flows where mocking by hand is tedious, Playwright can RECORD a HAR file (an archive of every request/response) once against the real backend, then replay it on every subsequent run. The result: realistic responses with zero per-request mock code.
Use the HAR pattern when the backend is complex but stable; switch back to manual route.fulfill when you need to test variant responses (errors, edge cases).
Code
Two tests, same endpoint, different responses·typescript
// Fulfilling a request — the most common pattern
import { test, expect } from '@playwright/test';
test('shows the user list when API returns three', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Pippa', email: 'pippa@example.com' },
{ id: 2, name: 'Dad', email: 'dad@example.com' },
{ id: 3, name: 'Mom', email: 'mom@example.com' },
]),
});
});
await page.goto('/users');
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('Pippa')).toBeVisible();
});
test('shows the empty state when API returns nothing', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
});
});
await page.goto('/users');
await expect(page.getByText(/no users yet/i)).toBeVisible();
});
// Modifying a real request — continue() with overrides
import { test } from '@playwright/test';
test('injects an x-test header on outbound requests', async ({ page }) => {
await page.route('**/api/**', async (route, request) => {
const headers = {
...request.headers(),
'x-test-run': 'pippa-quest-suite',
};
await route.continue({ headers });
});
await page.goto('/');
// The real API receives every request with the extra header attached.
// Useful for tagging test traffic in observability.
});
HAR pattern — record once, replay always·typescript
// HAR record/replay — when manual mocking would be tedious
import { test, expect } from '@playwright/test';
// First run — record
test('records the user-list flow', async ({ page, context }) => {
test.skip(!process.env.RECORD, 'Only runs with RECORD=true');
await context.routeFromHAR('e2e/.har/users-flow.har', {
update: true, // record this run into the HAR
url: '**/api/**',
});
await page.goto('/users');
// ... run the flow once against the real backend
});
// Every subsequent run — replay
test('replays the user-list flow from HAR', async ({ page, context }) => {
await context.routeFromHAR('e2e/.har/users-flow.har', {
url: '**/api/**',
});
await page.goto('/users');
await expect(page.getByRole('listitem')).toHaveCount(3);
});
Pick an E2E test in your suite that hits the real backend. Add a page.route() that mocks the main API endpoint for that test. Confirm the test still passes when you've stopped the real backend (kill the dev server temporarily). Then write a SECOND test against the same component but with the API mocked to return a 500 — confirm your error UI renders correctly. Notice how the second test would have been hard to write against a real backend.
Hint
If your test still hits the network after adding the route, your pattern probably isn't matching. Use Playwright's --debug mode to see which requests are flying and what URLs they have, then narrow your glob to match the exact path.
Progress
Progress is local-only — sign in to sync across devices.