# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-05T13:30:00.000Z

- Updated: 2026-09-04T11:08:05.722Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries

- Tags: playwright, network-mocking, api-testing, test-strategy, contract-testing, typescript

**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

- [Playwright — Mock APIs](https://playwright.dev/docs/mock): the fulfil-versus-patch pattern, HAR record and replay, HAR matching strictness, and WebSocket mocking

- [Playwright — Network](https://playwright.dev/docs/network): context versus page routing, glob pattern semantics, and the service worker interception caveat

- [Playwright — Route](https://playwright.dev/docs/api/class-route): the complete option lists for `fulfill`, `continue`, `fallback` and `fetch`, plus the `abort` error codes

- [Playwright — Page](https://playwright.dev/docs/api/class-page): `route` precedence and `times`, `routeFromHAR` options, and `unrouteAll` behaviours

- [Playwright — APIResponseAssertions](https://playwright.dev/docs/api/class-apiresponseassertions): `toBeOK()` for the contract tests that keep mocks honest

## Continue reading on AutomationTester.in

- [Playwright Trace Viewer as a Root-Cause Workflow, Not a Video Player](https://automationtester.in/blog/automation-tutorials/playwright-trace-viewer-root-cause-workflow)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug Playwright Strict-Mode Locator Failures](https://automationtester.in/blog/automation-tutorials/debug-playwright-strict-mode-locator-failures)
- [Playwright Auto-Waiting vs Business-State Waiting](https://automationtester.in/blog/automation-tutorials/playwright-auto-waiting-vs-business-state-waiting)
- [Playwright Test Architecture: Start with Risk, Not Pages](https://automationtester.in/blog/automation-tutorials/playwright-test-architecture-risk-not-page-objects)

Source: [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries) by Shashank Rawlani.
