Skip to main content
Back to Blog

Mock or Integrate? Playwright Network Testing Boundaries

Mocking is a decision about which contract a test asserts on, not a shortcut for speed. This guide gives you the criterion for mocking versus integrating, the exact differences between fulfill, continue, fallback, abort and fetch, and a concrete way to catch mocks that have drifted away from the real API.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Sep 5, 202612 min read
A dark schematic in which requests leave a browser pane on the left and reach a dashed vertical interception boundary marked by a ringed node. One branch turns orange, is answered by a synthetic stub panel whose reply loops straight back to the browser, and ends at a crossed-out circle showing the network was never reached. The other branch stays green, crosses the boundary, and continues through a real service panel to a node at the right edge.
Quick answer: Mock when the dependency's contract is not the thing under test, and integrate when it is. Every route.fulfill() body is a frozen claim about what the real service returns, so pair it with something that fails when the claim stops being true — a contract test, a schema assertion, or a periodic routeFromHAR re-record.

A checkout suite stays green for five months. Then production breaks because the pricing service renamed total_cents to totalMinorUnits. The tests never noticed, because the tests never called the pricing service. They called a hand-written object that still said total_cents, and they will keep saying it until someone edits that file.

Nothing in that story is a Playwright bug. The mock did exactly what it was told. The failure was in the decision to mock at all, made once, quietly, because mocking made a slow test fast — and then never revisited.

What mocking is actually choosing

The useful way to frame page.route() is not "fake vs. real". It is: which contract does this test assert on?

Every test has exactly one contract it is responsible for proving. Everything else in the test is scaffolding, and scaffolding should be cheap and deterministic. So the criterion is a single question, applied per dependency:

  • Is this dependency's request/response shape the thing that would break? If yes, hitting the real thing is the entire point. Mocking it deletes the test.
  • Or is it merely a precondition for the behaviour under test? If yes, mock it — and accept that you now own a claim that needs maintaining.

Worked through concretely: a test asserting that an empty result set renders the empty state is testing your rendering logic. The search API is a precondition. Mock it. A test asserting that the search API returns results the UI can parse is testing the integration contract. Mocking it turns the test into a tautology.

The trap is that both tests look identical in the file. Same navigation, same assertion on visible text. Only the intent differs, and intent is not executable. That is why mock drift is invisible until production surfaces it.

The five route outcomes, and how they differ

A route handler must terminate the request in exactly one way. Playwright gives you five, and the differences between three of them are load-bearing.

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

test('the five terminations', async ({ page }) => {
  await page.route('**/api/**', async route => {
    const url = route.request().url();

    // 1. fulfill — synthesise a response. The network is never touched.
    if (url.includes('/flags')) {
      return route.fulfill({ json: { newCheckout: true } });
    }

    // 2. continue — send to the network now, with optional overrides.
    //    No other matching handler runs after this.
    if (url.includes('/session')) {
      return route.continue({ headers: { ...route.request().headers(), 'x-test': '1' } });
    }

    // 3. fallback — like continue, except the NEXT matching handler
    //    gets a turn first.
    if (url.includes('/legacy')) {
      return route.fallback();
    }

    // 4. abort — fail the request with a network-level error code.
    if (url.includes('/analytics')) {
      return route.abort('blockedbyclient');
    }

    // 5. fetch — perform the request, get an APIResponse back, and
    //    fulfill separately. This is the "read then patch" path.
    const response = await route.fetch();
    await route.fulfill({ response });
  });
});

Three details from the API reference that decide whether your handlers compose:

  • route.continue() ends the chain. It sends the request to the network immediately and no other matching handler is invoked. route.fallback() is the version that defers to the next handler, which is what you want when you have split handlers by concern (GET vs POST, API vs assets).
  • Handlers run in reverse registration order. The most recently registered matching route takes precedence, so a test-local override registered after a fixture-level default wins without you having to unregister anything. Page routes also take precedence over context routes.
  • On continue(), only headers survive a redirect. The url, method and postData overrides apply to the original request only, and are not carried over to redirects it initiates.

Two more constraints worth knowing before you debug them at 2am: some request headers are forbidden and silently ignored if you try to override them (Cookie, Host, Content-Length and others — use browserContext.addCookies() for cookies), and enabling routing disables the HTTP cache for that page. The second one matters if you are measuring anything time-related in a routed test.

Patch the real response instead of fabricating one

The single highest-leverage habit in this whole area: when you only need to perturb a response, do not hand-write the whole body. Fetch the real one and edit it.

test('renders a discount badge when the API returns one', async ({ page }) => {
  await page.route('**/api/v1/cart', async route => {
    const response = await route.fetch();
    const json = await response.json();

    // Only the field under test is synthetic. Everything else —
    // field names, casing, nesting, extra keys — stays real.
    json.discount = { code: 'SPRING20', amountMinorUnits: 2000 };

    await route.fulfill({ response, json });
  });

  await page.goto('/cart');
  await expect(page.getByTestId('discount-badge')).toHaveText('SPRING20');
});

Passing response alongside json makes fulfill() reuse the real status and headers and override only the body, so you are not also inventing a content type. Drift is now bounded to the one field you touched. If the API renames discount, this test fails — which is the behaviour a fully hand-written mock cannot give you.

route.fetch() has options a hand-rolled fetch would not: timeout defaults to 30 seconds and maxRetries defaults to 0, and even when raised it only retries ECONNRESET — never an HTTP status code.

HAR record and replay

When a page makes twenty calls and you care about none of them individually, hand-writing twenty handlers is the wrong shape. page.routeFromHAR() records the whole conversation once and replays it.

// Record: run once with update: true against a real environment.
// The HAR is written to disk when the browser context closes.
await page.routeFromHAR('./hars/checkout.har', {
  url: '**/api/**',
  update: true,
});

// Replay: drop `update` (or set it to false). Now the file is authoritative.
await page.routeFromHAR('./hars/checkout.har', {
  url: '**/api/**',
  notFound: 'abort',
});

The matching rules are stricter than most people expect, and this is where HAR replay quietly stops covering what you think it covers:

  • Replay matches URL and HTTP method strictly. For POST requests it also matches the POST payload strictly. Change one byte of the request body in your application code and the entry no longer matches.
  • If several recorded entries match, the one with the most matching headers wins.
  • notFound defaults to 'abort'. Set it to 'fallback' to let unmatched requests reach the network — useful while a HAR is incomplete, dangerous as a permanent setting, because it hides the fact that your recording has gone stale.
  • updateMode defaults to 'minimal', which records only what replay needs and omits timings, sizes, cookies and security details. Pass 'full' if you want a HAR that is also useful for diagnostics.
  • A HAR path ending in .zip stores payloads as separate entries; updateContent: 'attach' writes them as separate files, 'embed' inlines them into the HAR.

You can also record outside the test runner, which is often faster for a first capture:

npx playwright open --save-har=checkout.har --save-har-glob="**/api/**" https://staging.example.com

A HAR is still a mock. Its advantage is that re-recording is a command rather than an editing session, which makes refreshing it something a person will actually do.

Mocks rot, and nothing tells you

A mock is a claim: "the real service, given this request, returns this." Claims decay. The test suite has no mechanism to notice, because a test whose dependency is mocked cannot observe the dependency changing. Green means "the mock and the UI agree", which was already true when you wrote it.

Three mitigations, in increasing order of cost and confidence.

1. Assert the shape of your fixture against the live response. One test per mocked endpoint, in its own project, that hits the real API and validates the same schema your mock is built from. It does not exercise the UI. It exists to fail when the shape moves.

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

export const CartSchema = z.object({
  id: z.string(),
  totalMinorUnits: z.number().int(),
  currency: z.string().length(3),
  lines: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
});

// Runs against staging, not against a mock. The UI tests build their
// fixtures from CartSchema, so this failing means every mock is now a lie.
test('the cart contract still holds', async ({ request }) => {
  const response = await request.get('/api/v1/cart');
  await expect(response).toBeOK();
  CartSchema.parse(await response.json());
});

2. Validate at the point of mocking. If your fixtures are generated from the schema rather than typed by hand, an incompatible schema change breaks the build instead of producing a passing test. Parse the mock body through the schema inside the route handler and let a mismatch throw.

3. Re-record HARs on a schedule. A nightly job running the recording projects with update: true turns drift into a diff in version control. A pull request that changes checkout.har is a conversation; a silently stale JSON literal is not.

The rule that keeps this honest: no endpoint may be mocked in the UI suite unless something else in CI calls it for real. Not the same test — anything. If nothing touches it, the mock is unfalsifiable and the coverage it appears to give you is fictional.

Failures you cannot stage for real

This is where mocking stops being a compromise and becomes the only tool. You cannot ask a production-grade service to return a 500 on demand, and you should not try.

// A 500 with the error envelope your backend actually emits.
test('shows a retry affordance on server error', async ({ page }) => {
  await page.route('**/api/v1/cart', route =>
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'internal_error', traceId: 'abc123' }),
    }));

  await page.goto('/cart');
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

// A dropped connection, not an HTTP error. Different code path in most clients.
test('shows the offline banner when the request never lands', async ({ page }) => {
  await page.route('**/api/v1/cart', route => route.abort('internetdisconnected'));

  await page.goto('/cart');
  await expect(page.getByRole('alert')).toContainText('connection');
});

// Malformed payload: valid HTTP, invalid JSON. Proves the parser has a guard.
test('survives a truncated response body', async ({ page }) => {
  await page.route('**/api/v1/cart', route =>
    route.fulfill({ status: 200, contentType: 'application/json', body: '{"id":"c_1","lin' }));

  await page.goto('/cart');
  await expect(page.getByTestId('cart-error')).toBeVisible();
});

route.abort() takes an error code, and the choice is not cosmetic — 'timedout', 'internetdisconnected', 'connectionreset', 'connectionrefused' and 'blockedbyclient' surface differently to the page. A fetch client that special-cases offline state will not take that branch if you abort with the default 'failed'.

Slow responses need one extra thing, because route.fulfill() has no delay option. The options are body, contentType, headers, json, path, response and status — that is the complete list. Latency comes from sleeping inside the handler:

test('shows a skeleton while the cart is loading', async ({ page }) => {
  await page.route('**/api/v1/cart', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000));
    await route.fulfill({ json: { id: 'c_1', totalMinorUnits: 0, currency: 'GBP', lines: [] } });
  });

  const navigation = page.goto('/cart');
  await expect(page.getByTestId('cart-skeleton')).toBeVisible();
  await navigation;
  await expect(page.getByTestId('cart-skeleton')).toBeHidden();
});

Use times: 1 on page.route() when you want the failure to happen only on the first attempt, which is how you test a retry that eventually succeeds:

// Fail once, then let the real service answer the retry.
await page.route('**/api/v1/cart', route => route.fulfill({ status: 503 }), { times: 1 });

When the route never fires

Three causes, each with a different fix, and they are easy to confuse because the symptom is identical: the handler simply never runs.

A service worker is intercepting first. Playwright's routing does not see requests a service worker has taken over. Mock Service Worker is the common case, but any app-level service worker does it too. Set serviceWorkers: 'block' in your context options. This is also why MSW and page.route() do not compose — pick one.

The glob does not match the whole URL. Playwright's glob patterns must match the entire URL including protocol and host, not a substring. '/api/v1/cart' matches nothing unless baseURL resolves it. '**/api/v1/cart' matches. Note also that * matches anything except / while ** matches across path separators, and that ? matches a literal question mark rather than any single character — which surprises people writing query-string patterns. Reach for a RegExp when the pattern gets fiddly.

Another handler got there first. Because the most recent registration wins and continue() ends the chain, a broad handler registered later will swallow requests a narrow earlier one was meant to receive. Switch the broad one to fallback(), or remove routes explicitly with page.unroute() or page.unrouteAll({ behavior: 'wait' }) — the 'wait' behaviour waits for in-flight handlers to finish rather than leaving them to throw into nothing.

Apply this now

Grep your suite for route.fulfill and list the distinct endpoints being mocked. For each one, write down which contract the mocking test is asserting on. Any endpoint where the answer is "the endpoint's own contract" is a test you should convert to an integration test today.

Then check the remaining list against CI: for each mocked endpoint, name the test that calls it for real. The endpoints with no such test are your drift exposure. Add one schema assertion per endpoint — that is usually under an hour of work and it is the difference between a suite that catches a rename and one that reports the rename as a production incident.

Frequently asked questions

Should I route on the page or the context?

Use browserContext.route() for things that should apply everywhere, including popups and newly opened pages — blocking analytics, blocking images. Use page.route() for the specific mock a test needs. Page routes take precedence when a request matches both, so a page-level override cleanly beats a context-level default.

Should I hand-write the mock body or patch a real one?

Patch a real one via route.fetch() whenever the real service is reachable from the test environment, because it bounds drift to the fields you edit. Hand-write only when the real response cannot be obtained — a failure mode you are simulating, or a service that does not exist in the test environment yet.

Is a HAR better than individual route handlers?

Different jobs. HAR is for reproducing a whole session cheaply and re-recording it later. Individual handlers are for the one endpoint whose behaviour a test deliberately controls. HAR replay matching is strict on method and POST body, so it is a poor fit for tests that vary their requests.

Can I mock away login to speed up the suite?

Prefer storageState — sign in once for real, reuse the resulting cookies and local storage. Mocking the auth endpoints means your tests never prove that authentication works, and auth is a contract that changes without warning.

How do I simulate a request that hangs forever?

Return a promise from the handler that never resolves and let the test's own timeout govern, or abort with 'timedout' if you want the client to see a network-level timeout immediately. The two exercise different code paths: one leaves the request pending, the other fails it.

Primary references