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.

Diagram comparing a fast Playwright API mock with a real check of the external contract.

Practical rule

  • route.fulfill checks how the UI handles a known state.
  • route.fetch keeps a real request and changes the response before the browser receives it.
  • routeFromHAR replays 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:

  1. route.fulfill covers UI states and runs on every change.
  2. route.fetch or HAR covers a variation that needs a more realistic network conversation.
  3. 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