level 03 / network-interception

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();
});
Key insight:

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

Reference

page.route(pattern, handler)
Intercept matching requests on this page
route.fulfill({ status, json })
Respond with a mock
route.abort()
Block the request
route.continue({ headers })
Pass through, optionally modified
route.fetch()
Execute the real request (for modify pattern)
page.routeFromHAR(path, opts)
Replay recorded traffic
context.route(pattern, handler)
Intercept for all pages in context
page.unroute(pattern)
Remove a route handler

Interview questions