Home / Blog / Playwright API mocking
Playwright API mocking: what to mock, and what to leave alone
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 calls | What reaches the service | What the test can still prove | Verdict |
|---|---|---|---|
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.