Skip to content

Blog / Playwright

Playwright

Playwright Network Mocking: page.route, HAR Replay and When Not to Mock

How to use page.route to create states your real backend won't give you on demand, and where mocking makes an end-to-end test lie.

October 23, 2026 · Blendra Team · 12 min read

You can't ask a staging backend for a 500 on demand. You can't ask it for an empty product list while other tests need a full one, or for a response that takes exactly as long as you need to check a spinner. A Playwright mock API setup gives you those states in one line each. The hard part is knowing where to stop, because every mocked response is a place where your end-to-end test no longer checks the real system.

This guide covers page.route and route.fulfill, error and slow-response states, patching a real response with route.fetch, blocking third-party noise, HAR record and replay, and waiting for the request your UI sends. It also covers the traps that make a route silently do nothing. Every example was run against a small local fixture app (one HTML page and a Node server with no dependencies) with @playwright/test 1.61 on Chromium. The specs are complete as shown; to follow along with your own app, swap in its endpoints and labels.

Key takeaways

  • Mock to reach states, not to avoid the backend. Keep at least one unmocked happy path per flow.
  • Register routes before page.goto(), or the first load has already gone to the network.
  • A glob must match the whole URL, query string included. Use a regex or ?* when there is a query.
  • Prefer route.fetch() plus one changed field over a hand-written fixture. It keeps the rest of the real contract.
  • Set serviceWorkers: 'block' if your app has a service worker, or your routes may never fire.

When mocking belongs in an end-to-end test

An end-to-end test is valuable because it crosses real boundaries: browser, frontend, API, database. Each route you add removes one of them for that request. That trade is worth it when the state you need is rare, slow or destructive to produce for real:

  • Error states. A 500, a 503 during a deploy, a dropped connection.
  • Edge data. An empty list, a zero price, a 300-character product name, a missing optional field.
  • Timing. A slow API, so you can check the loading state and that nothing double-submits.
  • Third parties. Analytics, chat widgets and ad tags that slow tests down and that you don't own.

It is the wrong trade when the mock replaces the thing the test claims to prove. A checkout test that mocks POST /api/orders proves your form renders a confirmation. It does not prove that an order exists. If you need clean, predictable data for the happy path, seed it through your API instead. Playwright test data covers seeding and cleanup.

A workable rule for most suites: one real, unmocked test for each critical flow. Add mocked tests next to it for the states the real backend can't produce on demand. Never mock the request whose outcome the test is named after.

The fixture app

The examples use a page that loads GET /api/products and shows a list, an empty state, an error banner with a Try again button, or a loading message. It also has an order form that sends POST /api/orders, and an analytics tag on a third-party host. A tiny Node server answers the API with three products. The config starts it for you:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:4177',
    // Service workers can answer requests before page.route sees them.
    serviceWorkers: 'block',
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'node server.mjs',
    url: 'http://localhost:4177',
    reuseExistingServer: !process.env.CI,
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

Playwright mock API basics: fulfill a request with your own JSON

page.route(url, handler) intercepts every request whose URL matches. Inside the handler you decide what happens: answer it yourself, let it through, or fail it.

How a Playwright route handler fulfils, continues or aborts a request

route.fulfill() answers without contacting the server. The json option serializes the body for you and sets Content-Type: application/json when you haven't set one. Here is the empty state:

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

test('shows the empty state when there are no products', async ({ page }) => {
  await page.route('**/api/products', (route) => route.fulfill({ json: [] }));

  await page.goto('/');

  await expect(page.getByText('No products yet')).toBeVisible();
  await expect(page.getByRole('listitem')).toHaveCount(0);
});

Two details matter here. The route is registered before goto, so the page's first fetch is caught. The assertions are web-first expect calls that retry until the UI settles, so the test never sleeps. If you want more on which assertion fits which state, see Playwright assertions.

fulfill also takes status, headers, contentType, body (string or Buffer) and path, which serves a file from disk.

Simulate errors, empty states and slow responses

The tests in this section go in the same file as the empty-state test above (tests/states.spec.ts), below its import line.

A 500 and the error banner

test('shows an error banner when the API returns 500', async ({ page }) => {
  await page.route('**/api/products', (route) =>
    route.fulfill({ status: 500, json: { error: 'internal' } }),
  );

  await page.goto('/');

  await expect(page.getByRole('alert')).toContainText("Couldn't load products");
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

Fail once, then recover

The times option limits how often a route is used. After one use it is removed, and the next request goes to the real server. That makes retry flows easy to test:

test('recovers when a retry succeeds', async ({ page }) => {
  // The first call fails once; the route is then removed and the real API answers.
  await page.route('**/api/products', (route) => route.fulfill({ status: 503 }), { times: 1 });

  await page.goto('/');
  await expect(page.getByRole('alert')).toBeVisible();

  await page.getByRole('button', { name: 'Try again' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(3);
  await expect(page.getByRole('alert')).toBeHidden();
});

A network failure, not an HTTP error

A 500 is a response. A dropped connection is not, and many apps handle the two differently: fetch resolves on a 500 but rejects on a network error. route.abort() produces the second kind. It takes an error code such as 'connectionrefused', 'timedout' or 'internetdisconnected':

test('shows the error banner when the network fails', async ({ page }) => {
  await page.route('**/api/products', (route) => route.abort('connectionrefused'));

  await page.goto('/');

  await expect(page.getByRole('alert')).toBeVisible();
});

A slow response, without sleeping

The common advice is to setTimeout inside the handler for a few seconds. A fixed delay either wastes seconds on every run or, on a slow runner, ends before the assertion runs. A better way is to hold the request open until the test has checked the loading state, then release it:

test('shows a loading state while the API is slow', async ({ page }) => {
  let release!: () => void;
  const gate = new Promise<void>((resolve) => (release = resolve));

  await page.route('**/api/products', async (route) => {
    await gate; // hold the request until the test says so
    await route.continue();
  });

  await page.goto('/');
  await expect(page.getByRole('status')).toHaveText('Loading products…');

  release();
  await expect(page.getByRole('listitem')).toHaveCount(3);
  await expect(page.getByRole('status')).toBeHidden();
});

The loading state is guaranteed to be on screen when you assert it, and the test takes as long as the app needs and no longer. The same gate works for testing that a submit button is disabled while a request is in flight. Timing bugs like this are a big source of intermittent failures; flaky Playwright tests goes deeper.

Change one field of a real response (route.fetch)

Hand-written fixtures drift. The API adds a required field, renames price to amount, or nests it under pricing. Your fixture keeps the old shape, and the mocked test stays green while production breaks.

route.fetch() avoids most of that. It sends the request to the real server and gives you the response. You change only what the test is about and answer with the rest untouched:

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

test('labels a zero price as Free', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    const response = await route.fetch(); // the real backend answers
    const products = await response.json();
    // Fail if the backend dropped or renamed the field, instead of quietly putting it back.
    expect(products[0]).toHaveProperty('price', expect.any(Number));
    products[0].price = 0; // change one field, keep the rest real
    await route.fulfill({ response, json: products });
  });

  await page.goto('/');

  await expect(page.getByRole('listitem').first()).toHaveText('Trail shoes – Free');
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

Passing response keeps the real status and headers; json replaces only the body. The other two products stay exactly as the backend sent them.

Assert the field exists before you overwrite it. Without that line, the patch quietly puts back a field the backend dropped. If the API renames price to amount, products[0].price = 0 writes price onto the first item again, and the first-item assertion still passes. Whether anything else fails then depends on how your UI handles a missing price. With the guard, the test fails at the source and names the field:

Error: expect(received).toHaveProperty(path, value)
Expected path: "price"
Received path: []
Expected value: Any<Number>
Received value: {"amount": 89.5, "id": "p1", "name": "Trail shoes"}

The cost of this technique is that the test needs a running backend. For a pure error-state test, a full fulfill is fine.

Block third-party noise

Analytics, session replay and chat widgets add requests you don't control. They slow the page, and an outage on their side can fail your test. Abort them at the context level, so popups and new tabs are covered too:

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

// Match hosts, not words, so first-party paths like /api/tracking-numbers stay untouched.
const BLOCKED_HOSTS = ['analytics.invalid', 'hotjar.com', 'segment.io'];

test.beforeEach(async ({ context }) => {
  // Context-level, so popups and new tabs are covered too.
  await context.route(
    (url) => BLOCKED_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith('.' + host)),
    (route) => route.abort(),
  );
});

test('product list renders without third-party tags', async ({ page }) => {
  const tagFailed = page.waitForEvent('requestfailed', (request) =>
    request.url().startsWith('https://analytics.invalid/'),
  );

  await page.goto('/');

  await expect(page.getByRole('listitem')).toHaveCount(3);
  // Chromium reports route.abort() as net::ERR_FAILED. Without the route,
  // this host fails DNS instead, with net::ERR_NAME_NOT_RESOLVED.
  expect((await tagFailed).failure()?.errorText).toBe('net::ERR_FAILED');
});

The matcher is a function that receives a URL, so it can compare hostname exactly. Keep it that way. A word pattern like /tracking|segment/ looks tidy, but it also aborts your own /api/tracking-numbers or /api/segments, and the app fails in ways that look unrelated. Block hosts, not words.

The last assertion checks the error text, not just that the request failed. The fixture's analytics host ends in .invalid, which never resolves, so the request would fail even with no route. Only the text tells an abort apart from a DNS error. The exact string is Chromium's; Firefox and WebKit report aborts differently.

Record and replay with HAR

A HAR file is a JSON log of requests and responses. page.routeFromHAR() can write one from real traffic and later answer requests from it. That is useful when an API has many endpoints and you want a realistic, frozen snapshot instead of hand-written fixtures.

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

// Record once, then replay:
//   UPDATE_HAR=1 npx playwright test har.spec.ts
//   npx playwright test har.spec.ts
const update = !!process.env.UPDATE_HAR;

// Tags this test's requests so the fixture server can count them.
// Record and replay use different tags, so a long-running server can't mix them up.
test.use({ extraHTTPHeaders: { 'x-test-id': update ? 'har-record' : 'har-replay' } });

test('product list from a recorded HAR', async ({ page, request }) => {
  await page.routeFromHAR('hars/products.har', {
    url: '**/api/**',
    update,
    updateContent: 'embed',
  });

  await page.goto('/');
  await expect(page.getByRole('listitem')).toHaveCount(3);

  if (!update) {
    const stats = await (await request.get('/__stats')).json();
    expect(stats['har-replay']).toBeUndefined(); // the backend never saw the call
  }
});

With update: true, requests go to the network and the HAR is written when the context closes. Without it, matching requests are served from the file. The last assertion proves it: the fixture server counts calls per x-test-id tag, and in replay it sees none under har-replay. The counts live in the server's memory, which is why record and replay use different tags; a server you started by hand and left running would otherwise still hold the recording run's call. To check that this assertion can fail, point url at a pattern that matches nothing: the real API answers, and the test fails with Received: 1.

Options worth knowing:

  • url limits which requests come from the HAR. Scope it to your API, or you replay your own HTML and scripts too.
  • notFound defaults to 'abort': a request that matches url but has no entry in the HAR fails. Set 'fallback' to send those to the network instead.
  • updateContent: 'embed' keeps bodies inside the .har. Without it, each body is written as a separate file next to the HAR, which is easy to forget when you commit.
  • Matching uses the URL and method, and for POST requests the body too, so a POST with a timestamp or random id in its payload won't match a recording.

You can also record from the CLI while you click through the app: npx playwright open --save-har=products.har --save-har-glob="**/api/**" http://localhost:4177.

The trade-off: a HAR is a snapshot. It doesn't fail when the API changes, so it drifts like any fixture. Re-record on a schedule, review the diff like code, and strip tokens and personal data before you commit it. HAR files contain request headers, which can include cookies and Authorization values.

Wait for the request your UI makes

Mocking is not the only reason to touch the network. Often you want to check what the UI sent. page.waitForRequest() and page.waitForResponse() let you do that without mocking anything. Start waiting before the action and await the promise after it, or you can miss a fast request:

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

test('sends the quantity the user typed', async ({ page }) => {
  await page.goto('/');

  // Start waiting before the click, await after it.
  const orderRequest = page.waitForRequest(
    (req) => req.url().endsWith('/api/orders') && req.method() === 'POST',
  );
  const orderResponse = page.waitForResponse('**/api/orders');

  await page.getByLabel('Quantity').fill('3');
  await page.getByRole('button', { name: 'Place order' }).click();

  expect((await orderRequest).postDataJSON()).toMatchObject({ productId: 'p1', quantity: 3 });
  expect((await orderResponse).status()).toBe(201);
  await expect(page.getByText('Order ord_1001 confirmed')).toBeVisible();
});

Use a predicate when the method matters. A URL glob alone would also match a GET /api/orders that fires on the same page. postDataJSON() parses JSON and form-encoded bodies, and toMatchObject ignores fields you didn't list.

Pitfalls that make a route do nothing

Registering the route after navigation

This fragment is the most common mistake. The page fetched its data during goto, so the route arrives too late:

// Fragment: the wrong order
await page.goto('/');
await page.route('**/api/products', (route) => route.fulfill({ json: [] }));
// The list already shows real data. The mock only affects later requests.

Register routes first, or in a beforeEach or a fixture, so every test gets them before it navigates.

Service workers

A request a service worker answers never reaches page.route. Playwright's network docs use Mock Service Worker as the example, because MSW installs its own worker. Set serviceWorkers: 'block' in use, as in the config above, unless the test is about the worker itself.

Glob matching rules

A glob must match the entire URL. * stops at /, ** crosses it, and ? is a literal question mark, not "any character". The consequence: '**/api/products' does not match /api/products?page=2. This test passes, which proves it:

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

test('a glob must match the whole URL, query string included', async ({ page }) => {
  const hits: string[] = [];
  await page.route('**/api/products', (route) => {
    hits.push(route.request().url());
    return route.fulfill({ json: [] });
  });
  await page.goto('/');

  await page.evaluate(() => fetch('/api/products?page=2'));

  expect(hits).toEqual(['http://localhost:4177/api/products']); // ?page=2 was not matched
});

Use '**/api/products?*' for the query form, or a regex such as /\/api\/products(\?|$)/ for both. When several routes match, the most recently registered one runs first, and page routes run before context routes. route.fallback() hands the request to the next matching handler.

Unmatched requests go to the real network

If your app calls an endpoint you forgot to mock, the request quietly reaches the real server, or fails against a server that isn't there. The test then breaks for a reason that has nothing to do with it. Register a loud catch-all first, so every specific route registered later wins over it. This test sits in the same file as the glob test above:

test('a catch-all registered first fails loudly on unmocked API calls', async ({ page }) => {
  // Registered first, so every later, more specific route wins over it.
  await page.route('**/api/**', (route) =>
    route.fulfill({ status: 599, json: { error: `unmocked: ${route.request().url()}` } }),
  );
  await page.route('**/api/products', (route) => route.fulfill({ json: [] }));

  await page.goto('/');
  await expect(page.getByText('No products yet')).toBeVisible();

  const status = await page.evaluate(async () => (await fetch('/api/orders', { method: 'POST', body: '{}' })).status);
  expect(status).toBe(599);
});

The unusual status makes the cause obvious in the trace.

Smaller traps

  • Routing disables the HTTP cache for the page or context, so a mocked run can make more requests than a real one.
  • Popups: page.route misses the first request of a popup. Use context.route instead.
  • Redirects: the handler runs once, for the first URL. Fulfilling with a 3xx gives you no second chance to intercept the target.
  • Mocks that hide contract breaks: type your fixtures against the same types the frontend uses, or patch real responses with route.fetch, and keep the unmocked happy-path test.

Mocked tests next to a recorded suite (Blendra)

Blendra records real flows in a real browser and exports them as Playwright tests that run against your own environments, chosen with TEST_ENV. It does not mock the network, and the exported specs contain no page.route calls. That is deliberate: the recorded journeys are your unmocked happy paths. Error, empty and slow states still need mocked tests, so write those by hand next to the exported suite, using the patterns above.

For the API side, Blendra's API tests send real requests and check status, headers, body, JSON paths and response time per environment. They export to tests/api/ beside your browser journeys, as Playwright request specs in the default TypeScript export. See the API tests overview.

FAQ

Should I use page.route or context.route?

Use page.route for one page in one test. Use context.route for anything that should apply to every page in the context, including popups and links opened in new tabs. Blocking third parties is the typical context-level case. If both match, the page route runs first.

Can I mock WebSockets?

Yes. page.routeWebSocket() intercepts a WebSocket connection. You can answer messages yourself, or call connectToServer() to reach the real server and change or drop messages on the way.

How do I assert a request body?

Start page.waitForRequest() before the action, await it afterwards, and check postDataJSON() with toMatchObject, as in the order example above. If you are already mocking that endpoint, you can also read route.request().postDataJSON() inside the handler and push it to an array you assert on.

Is a mocked test still an end-to-end test?

Partly. It still drives the real UI in a real browser, but it no longer checks the backend for that request. Name and tag mocked tests so nobody reads a green run as proof that the API works, and keep at least one unmocked test per critical flow.

MockingPlaywrightTypeScript

Start with the flow you test by hand every release.

Start free →