An E2E test can stay green without making a single call to the service you
think it is checking. This happens when page.route() intercepts the request
and returns fixed JSON. The browser proved that the screen reacts to that JSON.
It did not prove that the API accepts the method, headers, body, or response
shape.
The answer is not to abandon mocks. Give each test a smaller question. Use
route.fulfill to check UI states with deterministic responses. Use route.fetch
when you need the real response and want to change one detail. Use HAR when a
known network conversation should be replayed. Then run a separate check against
staging, a sandbox, or a provider-verified contract.
This article starts from the Playwright end-to-end testing guide and narrows the problem: how to isolate the network without turning isolation into false confidence.

Practical rule
route.fulfillchecks how the UI handles a known state.route.fetchkeeps a real request and changes the response before the browser receives it.routeFromHARreplays recorded traffic, but depends on matching the URL, method, and payload.- A separate test must check the real contract. The mock cannot do that job alone.
What does an API mock actually prove?
A mock proves how the client behaves when it receives the response you chose.
Playwright's Mock APIs guide shows that
route.fulfill can stop the request and return data defined in the test. That
is useful: the browser flow does not depend on a third party's availability,
latency, or state on every run.
The missing evidence matters just as much. The test will not discover that an
endpoint changed from POST to PUT, that the server now requires a header,
that the schema removed a field, or that authentication expired. The mock
answers before any of those conditions can appear.
That is not a Playwright defect. It is the boundary of the test level. A UI test asks whether a person can complete an action after receiving a state. A contract or integration test asks whether two systems still agree on a message. The overview of software testing types helps separate those questions before you choose a tool.
False positives appear when a team calls the first test an "API test". The name
promises coverage that the code does not have. Prefer names that declare the
boundary, such as checkout shows an error when payment returns 503 and
sandbox accepts the payment creation contract.
The same problem appears in AI-generated Playwright tests: a plausible sequence and a green assertion do not prove that the intended dependency was exercised.
When should you use route.fulfill, route.fetch, or HAR?
Choose the interception method by deciding what must stay under control. Playwright's Network guide separates intercepting a request, modifying it, aborting it, and changing its response. The code difference is small, but the evidence each test provides is different.
| Technique | Does the request reach the API? | Best question | Main risk |
|---|---|---|---|
route.fulfill |
No | Does the UI handle this known state? | The fixture can drift from the real contract. |
route.fetch + route.fulfill |
Yes, during the test | Does the UI handle a real response with a controlled variation? | The test inherits external instability and data. |
routeFromHAR |
No, it uses the file | Does the flow work with a known network conversation? | The HAR becomes stale or stops matching the current payload. |
request or APIRequestContext |
Yes, in an API test | Does the service accept the request and return the expected shape? | It needs a controlled environment and test credentials. |
The Playwright Mock APIs guide documents the first three paths. For the fourth, APIRequestContext can call endpoints, prepare state, and check the service without going through the UI. This split usually produces a smaller suite with more local failures.
Do not choose HAR only because recording a session feels quick. It works well when a flow needs several stable requests and the file can be reviewed with the test. For a single error state, an explicit fixture shows what the person sees and why the test passes.
How do you create a deterministic Playwright API mock?
The test below checks a screen that loads recommendations from
/api/recommendations. It is illustrative: adapt the URL, HTML, and contract to
your application. The important parts are registering the route before
page.goto(), keeping the fixture small, and proving that the expected request
went through the interceptor.
// tests/recommendations.mock.spec.ts
import { test, expect } from "@playwright/test";
test("shows recommendations when the API returns data", async ({ page }) => {
let intercepted = false;
await page.route(/\/api\/recommendations\?userId=test-user$/, async (route) => {
intercepted = true;
await route.fulfill({
status: 200,
contentType: "application/json",
json: {
items: [{ id: "book-1", title: "Testing at the Boundary" }],
},
});
});
await page.goto("/recommendations?userId=test-user");
await expect(page.getByRole("heading", { name: "Testing at the Boundary" }))
.toBeVisible();
expect(intercepted).toBe(true);
});
The status and contentType fields are not decoration. They make the response
the application receives explicit. Add separate cases for an empty list, an
invalid response, and a temporary error. Do not put every state into one test
with conditionals. Each failure should say which behavior stopped working.
If the screen needs authentication, keep login in the context fixture and let the mock represent only the API controlled by the scenario. A giant JSON file copied from production mixes setup data, contract, and scenario. Start with the smallest object that proves the UI decision and add fields only when the app actually reads them.
How can route.fetch preserve the real API signal?
route.fetch makes the original request and returns a response that you can
change before calling route.fulfill. Playwright's Route API
documents this path for cases where the real call matters but one response
detail needs to be controlled.
test("shows the empty state with a real response", async ({ page }) => {
await page.route("**/api/recommendations", async (route) => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({
response,
json: { ...body, items: [] },
});
});
await page.goto("/recommendations?userId=test-user");
await expect(page.getByText("No recommendations yet")).toBeVisible();
});
This test asks a different question from the first one. It lets the API participate but forces a variation that may be difficult to produce safely in the environment. Use it to explore the integration and response handling, not as a replacement for the deterministic test that must run without the network.
There is a cost: the test can now fail because of authentication, network,
usage limits, or a service change. Keep route.fetch in a smaller integration
layer or a separate run. The fast path of the suite should keep using
route.fulfill for known states.
How do you replay a conversation with routeFromHAR?
HAR is useful when a flow depends on several requests and the recorded file can
be reviewed as an artifact. Playwright's mock guide recommends recording the
HAR, versioning it with the test, and disabling updates during normal runs.
Matching considers the URL and method. For POST, the payload matters too.
test("replays the recorded recommendations flow", async ({ page }) => {
await page.routeFromHAR("./hars/recommendations.har", {
url: "**/api/recommendations",
update: false,
});
await page.goto("/recommendations?userId=test-user");
await expect(page.getByRole("heading", { name: "Testing at the Boundary" }))
.toBeVisible();
});
Review a HAR like code. Remove cookies, tokens, personal data, and responses that do not belong to the scenario. If the test passes only because an old HAR answers a pattern that is too broad, you gained speed and lost diagnosis. A useful guard is to fail when no HAR entry matches instead of letting the app continue with an unexpected response.
How do you prove that the mock did not hide the contract?
The simplest approach is to split the layers. The browser suite checks visible
behavior with route.fulfill. An API suite uses request or an independent
APIRequestContext against staging, a sandbox, or a provider-verified contract.
When two teams evolve an endpoint separately, consumer-driven contract testing
checks consumer expectations against the provider, as explained by PactFlow's
contract testing guide.
// tests/recommendations.contract.spec.ts
import { test, expect } from "@playwright/test";
test("staging keeps the recommendations contract", async ({ request }) => {
const response = await request.get("/api/recommendations?userId=test-user");
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body).toEqual(
expect.objectContaining({
items: expect.any(Array),
}),
);
});
This test needs a staging baseURL and disposable data. Do not point an
automated suite at production just because the endpoint is public. If there is
no sandbox, put the real call behind a manual step or create an explicit
contract that the provider can verify.
The desired matrix is small:
route.fulfillcovers UI states and runs on every change.route.fetchor HAR covers a variation that needs a more realistic network conversation.- An API or contract test checks the method, authentication, schema, and service state in a separate run.
Do not count the three as the same coverage. They may use the same endpoint name, but they answer different questions.
When the dependency has its own public surface, contract tests for a TypeScript MCP server offer the same boundary lesson: transport and schema need proof separate from the screen's behavior.
Which failures make this pattern misleading?
The first mistake is registering a route that is too broad. **/api/** can
capture the request the test was meant to observe and return success for the
wrong endpoint. Use a narrow expression or glob, and check the method, URL
parameters, and body when those values are part of the contract.
The second is forgetting that SSR and calls outside the browser happen in
another process. page.route() intercepts traffic from the browser context. It
does not automatically change a server-side render request. A recent
r/Playwright discussion about Server Component APIs
draws attention to this boundary. Control the server process, use a test proxy,
or test that layer with a suitable tool.
The third is letting a service worker hide the request. Playwright's Network
guide explains that you may need
serviceWorkers: "block" when network events do not appear as expected. Confirm
the topology before changing the mock.
The fourth is treating retries as approval. Playwright's Retries guide
classifies a test that fails on the first run and passes after a retry as
flaky. More retries may keep the pipeline moving, but they do not prove that
the mock, fixture, or environment is correct.
A checklist for reviewing an API mock
Before accepting the test, answer these questions:
- Does the test say whether it checks UI behavior, integration, or the contract?
- Is the route registered before navigation?
- Does the route pattern include the method, URL, and relevant parameters?
- Does the fixture contain only the fields the scenario needs?
- Is there an error case with a coherent status and body?
- Does the test prove that the expected route was intercepted?
- Is there another test that calls staging, a sandbox, or the contract provider?
- Are cookies, tokens, and personal data out of the HAR and reports?
- Does a network failure appear as an integration failure instead of a green screen?
- Does the first retry produce a flakiness signal instead of hiding it?
If the last answer is no, the test can still be useful. Give it a name and a place in the suite that match what it really proves.
Frequently asked questions
Does mocking the API make an E2E test useless?
No. A mock keeps the browser in states that are difficult to create and makes the run repeatable. It does not replace an integration or contract test. Use the mock for UI behavior and an independent call to check whether the real service still accepts the agreement.
Is route.fetch always better than route.fulfill?
No. route.fetch preserves a real call and can inherit instability, external
data, and authentication. route.fulfill is better for a deterministic case,
such as a 503 error or an empty list. Choose based on the signal the test needs,
not on which option looks closer to production.
Does a HAR replace a staging environment?
No. HAR replays recorded traffic and helps test the client without the network. It does not show that the current provider accepts the request. Version the HAR as a fixture and keep a separate check in staging, a sandbox, or the service contract.
How do you mock an API used during SSR?
page.route() does not automatically reach calls made by the server. Control
the dependency in the rendering process, point it to a test service, or test the
server API at its own boundary. Then use Playwright to check the HTML and the
behavior the browser actually receives.
Sources consulted
- Playwright, "Mock APIs", retrieved 2026-08-11
- Playwright, "Best Practices", retrieved 2026-08-11
- Playwright, "Network", retrieved 2026-08-11
- Playwright, "Route", retrieved 2026-08-11
- Playwright, "APIRequestContext", retrieved 2026-08-11
- Playwright, "Retries", retrieved 2026-08-11
- PactFlow, "What is consumer-driven contract testing?", retrieved 2026-08-11