Home / Blog / Playwright API mocking

Playwright API mocking: what to mock, and what to leave alone

Quick answer

Playwright intercepts network calls with page.route, and the hard part is not the syntax, it is the choosing. Mock what you cannot control or cannot reproduce: third-party services, error paths the real system will not produce on demand, and endpoints too slow to run a suite against. Leave your own critical paths real. A suite that mocks its own backend tests its fixtures.

Most suites collect their mocks one bad afternoon at a time. A payment iframe breaks a checkout spec, so somebody routes it. A search endpoint cold-starts past the point anyone will wait, so somebody routes that too. Nobody wrote a policy; it accumulated. What follows is a rule you can apply to the next endpoint before it joins the pile.

Why is "what to mock" the harder half?

A suite can be wrong about the network in two directions.

Mock nothing, and the build goes red on the morning a payment sandbox is slow, an ad host is down or a maps provider is rate-limiting your CI egress address. Your product did not change. Somebody still spends the morning reading a stack trace that belongs to a company they do not work for.

Mock everything, and the build is green through all of that. It is also green through your own outage. The order service can be returning 500 to every customer while the checkout spec passes, because the checkout spec has not spoken to the order service since somebody added a fixture in March.

Playwright holds no opinion here — it routes what you tell it to route, and the documentation carries a single paragraph of judgement across the whole set. How far to take the replacing is argued over by people who ship working suites. Both cases are set out, and neither is settled, in the article on Playwright practices. This page is narrower: what each kind of interception does to the test holding it, and what that test can still prove afterwards.

How does page.route work?

page.route(pattern, handler) registers a handler against a URL pattern. From that point on, every request the page makes that matches the pattern stalls until the handler does something with it. The handler is given a Route, and the Route is the whole surface: fulfil it, abort it, send it on, fetch it yourself, or hand it to the next handler in the chain.

browserContext.route does the same for every page in the context, which is what you want for popups and for anything the test opens later.

Playwright 1.62 is the version every sample here runs on. They were exercised against a small local application built for this page: an item list, an error banner with a retry button, and an analytics tag served from a second origin.

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

test('the list renders whatever the items endpoint returned', async ({ page }) => {
  await page.route('**/api/v1/items', async route => {
    await route.fulfill({
      status: 200,
      json: [{ id: 21, name: 'Strawberry' }],
    });
    // The other two things this handler could have done with the same request:
    //   await route.abort();     // the request never leaves the browser
    //   await route.continue();  // the real service answers it
  });

  await page.goto('/');
  await expect(page.getByText('Strawberry')).toBeVisible();
});
Playwright 1.62 · TypeScript

Each of those endings changes what the test is able to prove once it passes.

The handler callsWhat reaches the serviceWhat the test can still proveVerdict
route.fulfill() Nothing That the interface renders the response you wrote The most useful call on the list and the easiest to overspend
route.abort() Nothing; the request fails inside the browser That the page survives without that resource Right for scenery, wrong for anything an assertion reads
route.continue() The real request, with your header, method, URL or body overrides applied Whatever a real response supports, plus the effect of the override Reach for it when the request is the thing you need to change; other matching handlers are skipped once you do
route.fetch() The real request, made from inside your handler Everything, because you are holding the real response before you decide what to do with it The only call that keeps a real answer inside a mocked test
route.fallback() Nothing yet; the next matching handler decides Whatever that handler leaves provable How several handlers share one pattern without fighting over it

Which requests should you mock?

Each case below comes with the qualifying test that puts one endpoint in it or leaves it out. An endpoint that fits none of them stays real.

A third party you cannot control

Payments, maps, analytics, chat widgets, identity providers. The qualifying test: if that service had an outage right now, would this test failing tell you anything you would act on? If the answer is no, mock it.

The official documentation agrees on this case. Under the heading Avoid testing third-party dependencies, the best-practices guide opens with "Only test what you control." The advice is narrower than that sentence alone, though: do not test things you do not own, and use the Network API to guarantee the response you need. It never says replace everything external.

Verdict: mock it, and register the route on the context if more than one spec needs it.

An error path the real system will not produce on demand

A 500, a 429, a timeout, a malformed payload, an empty list on a screen that has never been empty in staging. The qualifying test: can you put the backend into that state on purpose, repeatably, in CI? If you cannot, route.fulfill with the status you need is the only way that branch ever runs.

Plenty of suites have no coverage of their own error handling, and this is the tool that writes it. The status is the cheap half of the sample below; the assertion on the visible error state is the test.

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

test('a 500 from the items endpoint shows the retry banner', async ({ page }) => {
  await page.route('**/api/v1/items', route => route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: '{"error":"internal"}',
  }));

  await page.goto('/');

  const banner = page.getByRole('alert');
  await expect(banner).toContainText('We could not load your items.');
  await expect(banner.getByRole('button', { name: 'Try again' })).toBeEnabled();
});
Playwright 1.62 · TypeScript

When that 500 arrives, the application swaps the list for the alert and the retry button becomes clickable. None of that branch was reachable from a healthy staging environment.

Verdict: mock it. This is the case where a mock adds coverage instead of trading it away.

An endpoint too slow to run a suite against

A report that takes eleven seconds to build, a search index that cold-starts, an ad script that blocks paint. The qualifying test: is this endpoint the subject of the test, or scenery behind it? Scenery gets a canned body or the two lines below.

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

test('the item list renders with the analytics tag blocked', async ({ page }) => {
  await page.route('**/collect.js?*', route => route.abort());

  await page.goto('/');
  await expect(page.getByText('Strawberry')).toBeVisible();
});
Playwright 1.62 · TypeScript

Be precise about the pattern. Playwright's globs match the entire URL rather than a fragment of it, and ? means a literal question mark, so **/collect.js will not touch .../collect.js?id=demo. We ran both forms against a live page while writing this: the pattern without the query never fired once.

Blocking analytics and ad calls makes a run faster, and speed is the whole of what it buys. It is not load testing. Playwright measures one real browser doing one thing well, it is not a load tool, and no engagement here sells one — if the question underneath is how the application behaves under traffic, we are the wrong company for it.

Verdict: fine, provided somebody writes down which coverage left with it. Aborting a request deletes a branch of the test, quietly and permanently.

A state you cannot reach any other way

An account on day 400 of a subscription, a feature-flag combination that exists for one customer, a locale with a currency nobody in the office spends. The qualifying test: is there a seam in the API that lets you set this up for real? If there is, use it — creating the state through the service and then driving the browser leaves you a test that still means something, and that is where test data comes from. Calling an API directly with request and APIRequestContext, with the API as the subject of the test, belongs to the guide next door.

Verdict: use the seam where one exists; mock and leave a comment saying why where none does.

Which requests must stay real?

One rule covers this, and it is short. Never mock the call the test is about.

A checkout test that stubs POST /orders proves that a button is wired to a function. It does not prove that an order was created, that the price was right, or that inventory moved. It will pass on the day the order service is down, wearing the name of the test everybody in the room trusts. The general form: the endpoint carrying the business outcome the test is named after stays real, every time.

The two versions answer different questions, and both questions are worth asking. A mocked checkout test answers given this response, does the interface do the right thing. A checkout test against a real backend answers does an order come out of the other end. Only the second goes red when the order service breaks, so a suite holding none of the second kind has bought its speed with the thing it was installed to do.

The tell. If you cannot say what would have to break in production for a test to go red, that test is not covering anything you care about. Run that question over the specs that were quickest to make green; those are where the answer is uncomfortable.

Where the line falls is not always obvious, and a page that pretends otherwise loses the reader who has a hard one in front of them. A payment provider is a third party and belongs in the first case above. The order your own service writes does not. The line runs at the edge of the system you deploy, and where that edge is genuinely unclear, as it is for a service another team in the same company owns on the same release train, the question worth asking is which team gets paged when it breaks. If the answer is yours, keep it real.

When should you use a HAR file instead?

When one screen sits on eight endpoints, hand-writing eight fixtures is an afternoon you get to repeat every time the screen changes. page.routeFromHAR and browserContext.routeFromHAR record the lot in a single pass: run once against the real service with update: true, commit the file, then replay with update: false.

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

test('the list renders from the recorded HAR', async ({ page }) => {
  await page.routeFromHAR('./hars/items.har', {
    url: '**/api/v1/items',
    update: false,
  });

  await page.goto('/');
  await expect(page.getByText('Strawberry')).toBeVisible();
});
Playwright 1.62 · TypeScript

On replay the request never leaves the browser. Editing the recorded body changes what the page renders, which is the one check worth running after a first recording — it tells you the file is being served and the network is not. The notFound option decides what happens to a request the file has no answer for: it aborts by default, or falls through to the next handler when you set it to fallback.

A HAR is a photograph of one deployment on one afternoon, and it starts going out of date the moment it is taken. The update option makes re-recording a one-line change; it does not make re-recording happen. Somebody has to own a scheduled job that re-records against a live environment and reads the diff, or the HAR quietly becomes the largest stale fixture in the repository.

Verdict: right when many endpoints sit behind one screen, wrong when the fixture has to be legible in a code review — nobody reviews a HAR.

What goes wrong with mocks?

The mock drifts, and the suite hides a breaking change

The API renames name to title, ships it, and every mocked test stays green because the fixture still says name. The application is broken for customers and the suite is reporting on a version of the service that no longer exists.

This is the most expensive item here and its symptom is that there is no symptom. Nothing goes red, nothing slows down, no log line appears anywhere. You find out from a customer, or from the one test somebody never got round to mocking.

A route left registered

Register routes in a beforeEach and never remove them, or register them on the context, and the mock outlives the test that wanted it. You see it as a spec that is green on its own and red once the file runs, or the other way round, and it sends people hunting through test ordering when the cause is a handler nobody took down.

page.unroute removes one handler. page.unrouteAll removes all of them and takes a behavior option of wait, ignoreErrors or default, deciding what happens to handlers still running when you pull the rug. browserContext.unroute and browserContext.unrouteAll are the context equivalents.

The better repair is structural. Put the route in a fixture that owns it, registering before the test and tearing down after, so leaking stops being possible.

Two handlers, and the one you expected did not run

This rule is documented in a single place — under route.fallback on the Route API page, and not on the network guide anyone would open first. "When several routes match the given pattern, they run in the order opposite to their registration." The sentence after it gives the reason: "That way the last registered route can always override all the previous ones."

So the handler you added last runs first, and route.fallback() is how it passes the request down to the one registered before it. route.continue() passes nothing down: it sends the request straight out and the remaining handlers never run.

A second precedence rule lives on the BrowserContext page — a page route takes precedence over a context route when a request matches both. We ran both rules against a live page while writing this. Three handlers on one pattern fired last-registered first, and a page route beat a context route on the same URL.

The pattern did not match what you thought it matched

Globs here match the entire URL, not a substring of it, so a pattern written as a fragment matches nothing at all. A single * stops at a / and ** does not. ? is a literal question mark rather than a single-character wildcard, and that is the one that bites, because a query string is exactly where people reach for ?.

A route that silently never fires looks identical to a mock that is working. The test goes green either way, off the real response, and stays green until the real response changes. If a mock appears to have no effect, prove the handler fired before you debug anything else.

The mock is invisible because a service worker got there first

A tool that installs its own service worker takes over the page's requests before Playwright's routing sees them, so the handler registers, matches nothing and reports nothing. The documented fix is to set serviceWorkers to 'block' in the context options. The symptom is routing that does nothing at all, with no error anywhere, and it sends people through their glob patterns for an afternoon before anybody thinks of it.

How do you catch a mock that has drifted?

Nothing inside a mocked test can tell you the mock is wrong. The test and the fixture agree with each other by construction, and agreement is what a passing test looks like. Catching drift takes something that touches the real service.

A schema check against the real response. The cheapest of the three and the one to reach for first. route.fetch() performs the real request from inside the handler and hands back the response, so the test can assert the shape of what the service returned and then fulfil with the fixture anyway.

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

const fixture = [{ id: 21, name: 'Strawberry', price: 340 }];

test('the items endpoint still returns the fields the fixture assumes', async ({ page }) => {
  await page.route('**/api/v1/items', async route => {
    const response = await route.fetch();
    expect(response.status()).toBe(200);

    const live = await response.json();
    expect(Array.isArray(live)).toBe(true);
    for (const row of live) {
      expect(typeof row.id).toBe('number');
      expect(typeof row.name).toBe('string');
      expect(typeof row.price).toBe('number');
    }

    await route.fulfill({ json: fixture });
  });

  await page.goto('/');
  await expect(page.getByText('Strawberry')).toBeVisible();
});
Playwright 1.62 · TypeScript

When the endpoint drifts, this fails on the schema line and the report names the field. We renamed name to title on the service and re-ran it: the first error printed was an expected string against a received undefined at the row.name assertion, ahead of the interface assertion further down. Hand-written checks on the two or three fields the fixture depends on are enough here; adding a JSON-schema library is a dependency decision and this page takes no position on it.

One caveat comes with route.fetch(). It makes a real request from inside the handler, so the test now waits on the real service, and on whichever clock is running while it waits.

A consumer contract test. The right answer when the API belongs to another team in the same company. The suite publishes what it expects of the endpoint, and the endpoint's own build fails when it stops honouring that, so the drift is caught in the API's pipeline instead of in yours — which is where catching it is cheap.

A run with no mocks at all. The practical move, and the one most teams can have inside a week. A second Playwright project with its own use block, pointed at a real environment and running the specs that were written without fixtures, so drift surfaces on a schedule instead of at an incident.

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

export default defineConfig({
  projects: [
    {
      name: 'mocked',
      use: { ...devices['Desktop Chrome'], channel: 'chrome', baseURL: 'http://localhost:3300' },
    },
    {
      name: 'live',
      testIgnore: /.*\.mocked\.spec\.ts/,
      use: { ...devices['Desktop Chrome'], channel: 'chrome', baseURL: process.env.LIVE_BASE_URL },
    },
  ],
});
Playwright 1.62 · TypeScript

testIgnore keeps the specs that only make sense mocked, the 500 fixture earlier among them, out of the live run. npx playwright test --project=live runs the second project on its own, from CI, on whatever schedule you pick.

None of this replaces testing the service itself. A suite that mocks carefully still needs tests that call the API directly and assert on what comes back, and setting those up alongside the unmocked project is Playwright API testing.

Questions

Should I mock my own API in end-to-end tests?

Not the endpoint the test is named after. A checkout test that stubs the order call proves a button calls a function, and it will stay green through an order-service outage. For the rest of the page, whether that is the feature flags in the header, a recommendations panel or a notification count, a mock is reasonable, and worth writing down so the next reader knows what stopped being covered.

How do I mock an error response in Playwright?

Call route.fulfill with the status you need, inside a page.route handler on that endpoint's URL pattern, then assert on what the interface does about it: the error banner, the retry button, the fallback list. The status is the easy half. The assertion on the visible error state is what makes it a test.

How do I stop a mock leaking into other tests?

page.unroute removes one handler and page.unrouteAll removes all of them, with a behavior option covering handlers that are still running. The structural repair is better: register the route inside a fixture that tears it down after the test, so a leak becomes impossible instead of merely discouraged by a convention nobody enforces.

Is HAR replay better than writing mocks by hand?

It is less to write and more to keep current. One recording captures every response behind a screen, which beats hand-writing eight fixtures. It also produces a file nobody reads in review, and it goes stale exactly as a fixture does, with the difference that re-recording it is a job somebody has to schedule and nobody owns by default.

How do I know my mocks still match the real API?

Nothing inside a mocked test can tell you, because the test and the fixture agree with each other by construction. It takes something outside: a schema check that calls the real service with route.fetch before fulfilling, a consumer contract test the API's own build runs, or a scheduled run of the suite with the mocks switched off.

How much of your suite is mocked right now?

Tell us what share of your specs register a route, and name the endpoint you would least want to bet on — the one where you are not confident the fixture still matches production. An engineer here can then say whether the suite is carrying a fixture problem or a coverage one, and which of them is worth a week of somebody's time.