Skip to main content
Playwright intermediate Lesson 7 of 10

Network Interception and API Mocking

Intercept requests with page.route to mock JSON, force error states, delay responses, and patch a real API reply — then record a HAR to replay a whole backend.

page.route puts your code between the browser and the network. Anything the page asks for can be answered from a fixture, delayed, corrupted, or blocked — which is how you test the states a real backend will not produce on demand.

A self-contained app

Routing can serve the page itself, so this example needs no server:

import { test, expect } from '@playwright/test';

const HTML = `
  <h1>Orders</h1>
  <ul id="list"></ul>
  <p id="status">loading…</p>
  <script>
    fetch('/api/orders')
      .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
      .then(orders => {
        list.innerHTML = orders.map(o => '<li>' + o.ref + ' — £' + o.total + '</li>').join('');
        status.textContent = orders.length + ' orders';
      })
      .catch(e => status.textContent = 'Could not load orders: ' + e.message);
  </script>`;

test('renders the orders the API returns', async ({ page }) => {
  await page.route('https://app.test/', route =>
    route.fulfill({ contentType: 'text/html', body: HTML })
  );
  await page.route('https://app.test/api/orders', route =>
    route.fulfill({
      json: [
        { ref: 'A-1001', total: 25.5 },
        { ref: 'A-1002', total: 12.0 },
      ],
    })
  );

  await page.goto('https://app.test/');

  await expect(page.getByRole('listitem')).toHaveText(['A-1001 — £25.5', 'A-1002 — £12']);
  await expect(page.locator('#status')).toHaveText('2 orders');
});
Running 1 test using 1 worker

  ✓  1 [chromium] › tests/network.spec.ts:19:1 › renders the orders the API returns (167ms)

  1 passed (823ms)

json: sets the body and the application/json content type in one option. The domain app.test resolves to nothing — every request for it is answered by the handler, so the test never touches DNS.

The states a backend will not give you

An empty account, a server error, and a slow response are three of the most common production bugs and three of the hardest to reproduce by hand.

test('empty state', async ({ page }) => {
  await page.route('https://app.test/', r => r.fulfill({ contentType: 'text/html', body: HTML }));
  await page.route('https://app.test/api/orders', r => r.fulfill({ json: [] }));

  await page.goto('https://app.test/');
  await expect(page.locator('#status')).toHaveText('0 orders');
  await expect(page.getByRole('listitem')).toHaveCount(0);
});

test('server error is surfaced, not swallowed', async ({ page }) => {
  await page.route('https://app.test/', r => r.fulfill({ contentType: 'text/html', body: HTML }));
  await page.route('https://app.test/api/orders', r =>
    r.fulfill({ status: 500, body: 'boom' })
  );

  await page.goto('https://app.test/');
  await expect(page.locator('#status')).toHaveText('Could not load orders: HTTP 500');
});

test('the loading state is visible while the request is in flight', async ({ page }) => {
  await page.route('https://app.test/', r => r.fulfill({ contentType: 'text/html', body: HTML }));
  await page.route('https://app.test/api/orders', async route => {
    await new Promise(resolve => setTimeout(resolve, 1500));
    await route.fulfill({ json: [{ ref: 'A-1001', total: 25.5 }] });
  });

  await page.goto('https://app.test/');
  await expect(page.locator('#status')).toHaveText('loading…');
  await expect(page.locator('#status')).toHaveText('1 orders');
});
Running 3 tests using 3 workers

  ✓  1 [chromium] › tests/network.spec.ts:36:1 › empty state (151ms)
  ✓  2 [chromium] › tests/network.spec.ts:45:1 › server error is surfaced, not swallowed (163ms)
  ✓  3 [chromium] › tests/network.spec.ts:56:1 › the loading state is visible while the request is in flight (1.7s)

  3 passed (2.4s)

The third test is the one worth copying. Holding the response open for 1500ms makes the spinner assertable — without it, the loading state exists for four milliseconds and no assertion can catch it reliably.

Blocking requests

test('the page works without analytics', async ({ page }) => {
  await page.route(/analytics|googletagmanager|hotjar/, route => route.abort());
  await page.route('https://app.test/', r => r.fulfill({ contentType: 'text/html', body: HTML }));
  await page.route('https://app.test/api/orders', r => r.fulfill({ json: [] }));

  await page.goto('https://app.test/');
  await expect(page.locator('#status')).toHaveText('0 orders');
});
  ✓  1 [chromium] › tests/network.spec.ts:70:1 › the page works without analytics (144ms)

  1 passed (798ms)

Blocking third-party scripts is worth doing suite-wide. They are slow, they are flaky, and none of your assertions depend on them:

// tests/fixtures.ts — an auto fixture applied to every test
blockThirdParty: [
  async ({ context }, use) => {
    await context.route(/\.(png|jpg|woff2)$/, r => r.abort());       // images and fonts
    await context.route(/doubleclick|segment\.io|sentry\.io/, r => r.abort());
    await use();
  },
  { auto: true },
],

context.route applies to every page in the context, including popups.

Patching a real response

Sometimes you want the real API and one changed field. route.fetch performs the request, then you edit the result:

test('a flag the backend does not have yet', async ({ page }) => {
  await page.route('**/api/features', async route => {
    const response = await route.fetch();
    const json = await response.json();
    json.betaCheckout = true;
    await route.fulfill({ response, json });
  });

  await page.goto('/checkout');
  await expect(page.getByRole('button', { name: 'Express checkout' })).toBeVisible();
});
  ✓  1 [chromium] › tests/features.spec.ts:4:1 › a flag the backend does not have yet (1.3s)

  1 passed (2.0s)

Passing response keeps the original status and headers; json replaces only the body.

You can also modify the request on its way out:

await page.route('**/api/**', route =>
  route.continue({ headers: { ...route.request().headers(), 'x-test-run': 'true' } })
);

Asserting on what was sent

Handlers see the request, so they can capture it:

test('the form posts what the user typed', async ({ page }) => {
  const posted: unknown[] = [];

  await page.route('https://app.test/', r => r.fulfill({
    contentType: 'text/html',
    body: `<button onclick="fetch('/api/orders',{method:'POST',
             headers:{'content-type':'application/json'},
             body:JSON.stringify({ref:'A-1003',total:9.99})})">Order</button>`,
  }));

  await page.route('https://app.test/api/orders', async route => {
    posted.push(route.request().postDataJSON());
    await route.fulfill({ status: 201, json: { ok: true } });
  });

  await page.goto('https://app.test/');
  await page.getByRole('button', { name: 'Order' }).click();

  await expect.poll(() => posted).toEqual([{ ref: 'A-1003', total: 9.99 }]);
});
  ✓  1 [chromium] › tests/network.spec.ts:88:1 › the form posts what the user typed (203ms)

  1 passed (872ms)

expect.poll rather than a bare expect, because the click returns before the fetch reaches the handler.

Waiting for a response

const [response] = await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/orders') && r.status() === 200),
  page.getByRole('button', { name: 'Refresh' }).click(),
]);

expect((await response.json()).length).toBe(2);
  ✓  1 [chromium] › tests/network.spec.ts:104:1 › refresh refetches (1.2s)

Use it when you need the response body itself. When you only need the UI to settle, assert on the UI — waitForResponse succeeds the moment bytes arrive, which is before React has rendered anything, and tests built on it drift back into raciness.

Recording and replaying a HAR

For a page with fifty requests, hand-writing mocks is not worth it. Record once:

test.use({
  contextOptions: {
    recordHar: { path: 'har/orders.har', urlFilter: '**/api/**' },
  },
});

Then replay:

test.beforeEach(async ({ page }) => {
  await page.routeFromHAR('har/orders.har', { url: '**/api/**', update: false });
});

test('dashboard renders from the recorded session', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('row')).toHaveCount(12);
});
  ✓  1 [chromium] › tests/dashboard.spec.ts:8:1 › dashboard renders from the recorded session (684ms)

  1 passed (1.4s)

Run with update: true against a live backend to re-record after an API change. The HAR is a normal file in git, so a diff shows exactly what the API changed — which is a useful review artefact in its own right.

Handler order and cleanup

Later routes are consulted first, so a specific route can shadow a general one:

await page.route('**/api/**', r => r.fulfill({ json: {} }));            // general
await page.route('**/api/orders', r => r.fulfill({ json: [{ ref: 'A' }] })); // wins

Fall through explicitly with route.fallback(), and remove handlers when a test needs the real network again:

await page.unroute('**/api/orders');
await page.unrouteAll({ behavior: 'ignoreErrors' });

Practice

1. Mock a 404 and assert the app shows a "not found" message rather than a spinner.
await page.route('https://app.test/api/orders', r => r.fulfill({ status: 404, body: '' }));
await page.goto('https://app.test/');
await expect(page.locator('#status')).toHaveText('Could not load orders: HTTP 404');
  ✓  1 [chromium] › tests/network.spec.ts:118:1 › not found (149ms)

  1 passed (801ms)

Testing each status separately is worth it: plenty of front ends handle 500 and leave 404 spinning forever, because the catch block only ever ran in one code path.

2. Abort the API request entirely. How does that differ from a 500?
await page.route('https://app.test/api/orders', r => r.abort('failed'));
await expect(page.locator('#status')).toContainText('Could not load orders: Failed to fetch');
  ✓  1 [chromium] › tests/network.spec.ts:126:1 › aborted request (152ms)

An abort rejects the fetch promise, so r.ok is never consulted — it exercises the network-failure path rather than the HTTP-error path. Both need coverage; apps commonly handle one and not the other.

3. Delay the response by 3 seconds and assert the spinner then the result.
await page.route('https://app.test/api/orders', async route => {
  await new Promise(r => setTimeout(r, 3000));
  await route.fulfill({ json: [{ ref: 'A-1001', total: 25.5 }] });
});

await page.goto('https://app.test/');
await expect(page.locator('#status')).toHaveText('loading…');
await expect(page.locator('#status')).toHaveText('1 orders', { timeout: 10_000 });
  ✓  1 [chromium] › tests/network.spec.ts:134:1 › slow response (3.2s)

  1 passed (3.9s)

The second assertion needs a timeout above the delay, or it gives up at 5s on a response that arrives at 3s plus render time — a subtle trap when you raise the delay later.

4. Capture every request the page makes and print the API calls.
page.on('request', req => {
  if (req.url().includes('/api/')) console.log(req.method(), req.url());
});
await page.goto('https://app.test/');
GET https://app.test/api/orders

  ✓  1 [chromium] › tests/network.spec.ts:146:1 › logs api calls (158ms)

page.on('request') observes without intercepting, so it is safe to leave in a debugging fixture. To fail a test on an unexpected call, push the URLs into an array and assert on it at the end.

Next: traces — the recording that tells you why a test failed in CI.

Frequently Asked Questions

What does page.route do?
It registers a handler for requests matching a URL pattern, letting you fulfil them with your own response, abort them, or let them continue with modified headers or body. Handlers are checked most-recently-registered first, and the first one that does not fall through wins.
Should I mock the API in end-to-end tests?
Mock the responses you cannot produce reliably — a 500, a timeout, an empty account, a third-party widget. Keep a small set of tests running against the real backend, or you end up testing a fixture that drifted from the API months ago.
How do I wait for a specific network response?
Use `page.waitForResponse(url => …)` started before the action that triggers it, usually inside a `Promise.all`. In most tests you do not need it at all — asserting on the rendered result waits for the same thing and reads better.
What is HAR replay in Playwright?
`routeFromHAR` serves responses from a recorded HTTP archive, so a whole session's traffic can be replayed offline. Record with `update: true` against the real backend, commit the HAR, and the suite runs deterministically without one.