Skip to main content

Network — route mocking and inspection

Source: tests/saucedemo/network.cookbook.ts

Mental model

page.route(pattern, handler) registers an interceptor. Matching requests go through your handler instead of straight to the network. The handler MUST call one of route.fulfill, route.abort, route.continue, or route.fetch + route.fulfill — otherwise the request hangs.

Block requests

test('block images', async ({ page }) => {
await page.route('**/*.{png,jpg,gif,webp,svg}', route => route.abort());
await page.goto('/');
});

test('simulate failure', async ({ page }) => {
await page.route('**/api/payment', route => route.abort('failed'));
// ...
});

Mock with fulfill

test('mock API response', async ({ page }) => {
await page.route('**/api/inventory', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock product' }]),
})
);

await page.goto('/');
});

Simulate slow network

test('slow API', async ({ page }) => {
await page.route('**/*', async route => {
await new Promise(r => setTimeout(r, 1000));
await route.continue();
});
await page.goto('/');
});

Modify real responses

Make the real request, then alter what comes back:

test('inject custom header', async ({ page }) => {
await page.route('**/*', async route => {
const response = await route.fetch();
await route.fulfill({
response,
headers: { ...response.headers(), 'x-injected': 'true' },
});
});
await page.goto('/');
});

test('augment JSON response', async ({ page }) => {
await page.route('**/api/me', async route => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({
response,
body: JSON.stringify({ ...body, featureFlag: true }),
});
});
});

Assert requests are made

test('verify login API call', async ({ page }) => {
await page.goto('/');

const requestPromise = page.waitForRequest('**/api/login');
await page.getByTestId('login-button').click();
const request = await requestPromise;

expect(request.method()).toBe('POST');
expect(request.postDataJSON()).toMatchObject({ username: 'standard_user' });
});

test('verify response', async ({ page }) => {
const responsePromise = page.waitForResponse(
r => r.url().includes('/api/me') && r.status() === 200
);
await page.goto('/profile');
const response = await responsePromise;
expect((await response.json()).email).toMatch(/@/);
});

Match-once with { times: N }

The old routeOnce API has been unified. Use { times }:

test('first call fails, retry succeeds', async ({ page }) => {
await page.route('**/api/data', route => route.abort('failed'), { times: 1 });
await page.goto('/'); // UI retries; the retry hits the real network
});

Patterns to know

NeedPattern
Block trackers`context.route(/analytics
Force a feature flagFetch real, inject into JSON body
Test retry behavior{ times: 1 } + abort → next call passes through
Capture an API bodypage.waitForResponse('**/api/x').then(r => r.json())
Replay recorded trafficpage.routeFromHAR('fixtures.har', { url: '**/api/**' })

Anti-patterns

Don'tWhy
Register route AFTER the actionThe request fires before your handler is installed
Handler that doesn't call fulfill/abort/continueRequest hangs forever
body: { json: true } (object)Must be a string — use JSON.stringify(...)
page.on('request', ...) to assert a request was madeRace condition — use waitForRequest BEFORE the action

Deep dive

For HAR recording/playback, context-level routes, and request modification recipes: Network Reference.