Network Interception & Mocking
route(), fulfill, abort, modify — plus HAR recording. Control every network request your app makes and test states the backend can't give you.
Why intercept the network
Some states are nearly impossible to produce through the real backend: empty
result sets, 500 errors, slow responses, feature flags, third-party outages.
page.route() lets the test own the network layer and manufacture exactly
the state it needs.
route(): the three verbs
await page.route('**/api/products', async route => {
// 1. fulfill — answer with a mock, request never reaches the server
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock Widget', price: 9.99 }]),
});
});
await page.route('**/analytics/**', route => {
// 2. abort — block the request entirely (kill trackers, ads)
return route.abort();
});
await page.route('**/api/user', async route => {
// 3. continue — let it through, optionally modified
await route.continue({
headers: { ...route.request().headers(), 'x-test-run': 'true' },
});
});
Mocking states the backend can’t give you
import { test, expect } from '@playwright/test';
test('empty cart shows call-to-action', async ({ page }) => {
await page.route('**/api/cart', route =>
route.fulfill({ status: 200, json: { items: [], total: 0 } })
);
await page.goto('/cart');
await expect(page.getByText('Your cart is empty')).toBeVisible();
await expect(page.getByRole('link', { name: 'Start shopping' })).toBeVisible();
});import { test, expect } from '@playwright/test';
test('API failure shows recoverable error', async ({ page }) => {
await page.route('**/api/orders', route =>
route.fulfill({ status: 500, json: { error: 'Internal Server Error' } })
);
await page.goto('/orders');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});import { test, expect } from '@playwright/test';
test('slow search shows loading state', async ({ page }) => {
await page.route('**/api/search**', async route => {
await new Promise(r => setTimeout(r, 2000)); // inject latency
await route.continue();
});
await page.goto('/');
await page.getByPlaceholder('Search').fill('widgets');
await expect(page.getByTestId('search-spinner')).toBeVisible();
});route.fulfill({ json: obj }) is shorthand for status 200 + JSON content type + stringified body. Use it for compact mocks.
Modify a real response
await page.route('**/api/products', async route => {
const response = await route.fetch(); // real request goes out
const data = await response.json();
data.products[0].stock = 0; // surgically alter one field
await route.fulfill({ response, json: data }); // serve modified version
});
This pattern — fetch real, mutate, fulfill — keeps mocks honest: everything stays real except the one field under test.
HAR recording and replay
// Record: run against the real backend once, save all traffic
const context = await browser.newContext({
recordHar: { path: 'fixtures/checkout.har', urlFilter: '**/api/**' },
});
// Replay: serve every API call from the HAR file — no backend needed
await page.routeFromHAR('fixtures/checkout.har', {
url: '**/api/**',
update: false, // true = re-record instead of replay
});
HAR replay turns an integration suite into a hermetic one: deterministic,
fast, runnable offline. Re-record (update: true) when the API contract
changes.
Scope and ordering
// Context-level route — applies to every page in the context
await context.route('**/ads/**', route => route.abort());
// Page-level routes are checked before context-level ones.
// Last registered route matching the URL wins — register general
// handlers first, specific overrides after.
await page.unroute('**/api/cart'); // remove a handler