Mocking APIs in Playwright Tests

A Playwright suite that reaches a real API is slow, flaky and occasionally destructive. This page sets up route-level mocking that covers the very first request, blocks anything unmocked, records what the application sent, and keeps four parallel workers from tripping over each other.

Context: routes are a stack, and order decides everything

page.route and context.route register interceptors that Playwright evaluates most recent first. That single fact explains most confusion in this area: a broad **/api/** handler added after a specific **/api/orders one shadows it completely, and nothing warns you.

The second fact is about timing. Routes only apply to requests made after registration, so anything registered inside a test body arrives too late for the navigation that test already performed. Registration belongs in a fixture that runs before page.goto.

Registration order decides which route wins Two stacks of route registrations. In the correct order the specific orders route is registered first and the catch-all last, so the catch-all sits on top and is evaluated first but only matches what the specific route did not claim. In the wrong order the catch-all is registered first, the specific route is registered later and therefore evaluated first, which works — while a catch-all registered last shadows nothing. The diagram shows the failure case where a broad route registered after a specific one aborts requests the specific route was meant to fulfil. Correct — specific first, catch-all last 2 · route('**/api/**') → abort ← evaluated first 1 · route('**/api/orders*') → fulfil The catch-all claims only what no specific route already handled — because Playwright falls through on route.fallback() and stops on fulfil or abort. Wrong — catch-all registered last with no fallback 2 · route('**/api/**') → abort unconditionally 1 · route('**/api/orders*') → fulfil (never reached) The catch-all aborts everything before the specific route is consulted, so every spec fails with net::ERR_BLOCKED_BY_CLIENT and no explanation. The fix is not reordering registration — it is making the catch-all check whether anything else matched, using route.fallback(). A catch-all that always aborts must be registered before the specific routes, not after.

Solution

1. An auto fixture that runs before navigation

// e2e/fixtures.ts
import { test as base, expect, type Route } from '@playwright/test';
import orders from '../src/mocks/generated/orders.json';

export interface ApiLog { url: string; method: string; body: unknown }

export const test = base.extend<{ apiLog: ApiLog[] }>({
  apiLog: [
    async ({ context }, use) => {
      const log: ApiLog[] = [];

      // Catch-all FIRST, and it falls through rather than aborting outright.
      await context.route('**/api/**', async (route: Route) => {
        await route.fallback();
      });

      await context.route('**/api/orders*', async (route) => {
        log.push({
          url: route.request().url(),
          method: route.request().method(),
          body: route.request().postDataJSON?.() ?? null,
        });
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({ items: orders.slice(0, 20), total: orders.length }),
        });
      });

      // A final guard, registered last so it is evaluated first, that only
      // blocks what nothing else claimed.
      await context.route('**/api/**', async (route) => {
        if (route.request().url().includes('/api/orders')) return route.fallback();
        await route.abort('blockedbyclient');
      });

      await use(log);
    },
    { auto: true },
  ],
});

export { expect };

{ auto: true } is what makes this run for every spec without each one remembering to request the fixture. Fixtures run before the test body, and therefore before any page.goto inside it.

route.fallback() is the piece that makes layering work: it passes the request to the next matching route rather than handling it, so a broad handler can inspect without claiming.

2. Fail loudly on anything unmocked

The final guard above aborts with blockedbyclient, which surfaces in the test as a failed request rather than a silent success. That is the behaviour you want in CI — an endpoint nobody mocked must not quietly reach a real service, the same guarantee onUnhandledRequest: 'error' gives in MSW.

Make the failure legible by asserting no request was blocked:

// e2e/fixtures.ts (addition)
page.on('requestfailed', (request) => {
  if (request.failure()?.errorText === 'net::ERR_BLOCKED_BY_CLIENT') {
    throw new Error(`Unmocked API request: ${request.method()} ${request.url()}`);
  }
});

The thrown error names the URL, which turns “some spec fails with a blank list” into a one-line fix.

3. Assert on the requests, not only on the pixels

// e2e/orders.spec.ts
import { test, expect } from './fixtures';

test('creates an order with the payload the API expects', async ({ page, apiLog }) => {
  await page.goto('/orders');
  await page.getByRole('button', { name: 'New order' }).click();
  await page.getByLabel('Amount').fill('42.50');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByText('Order placed')).toBeVisible();

  // The rendered success message does not prove the payload was right.
  const posts = apiLog.filter((r) => r.method === 'POST');
  expect(posts).toHaveLength(1);
  expect(posts[0].body).toMatchObject({ totalMinor: 4250, currency: 'GBP' });
});

The toHaveLength(1) assertion is doing real work: a double-submit bug renders exactly the same success message and sends two orders.

4. Keep parallel workers isolated

Playwright gives each spec its own context by default, so routes and storage do not leak. What does leak is anything shared outside the browser — a mock server on a fixed port, a fixture file written during a test, a database.

Where a shared backend is unavoidable, scope it per worker:

// e2e/fixtures.ts (addition)
export const test = base.extend<{ scope: string }>({
  scope: [
    async ({ context }, use, testInfo) => {
      const scope = `w${testInfo.workerIndex}_${testInfo.testId}`;
      await context.setExtraHTTPHeaders({ 'x-mock-scope': scope });
      await use(scope);
    },
    { auto: true },
  ],
});
What is isolated by default, and what is not A four-row table listing resources against whether Playwright isolates them per spec. Routes, cookies, storage and service workers are isolated automatically because each spec gets its own browser context. A shared mock server, a fixture file written during a run and a shared database are not isolated and need a per-worker scope, a temporary directory, or a per-worker schema respectively. Resource Isolated by default? If not, do this routes, cookies, storage, workers yes — per context nothing a shared mock server on a fixed port no per-worker scope header a fixture file written during a run no write to testInfo.outputDir a shared database no a schema per worker index Everything in the top row is free; everything below it is why a suite passes at one worker and fails at four.

Verification

npx playwright test --workers=1        # baseline
npx playwright test --workers=4        # must produce identical results
npx playwright test --repeat-each=2    # proves state is reset, not just initialised
npx playwright test --trace on         # the trace shows which layer served each request

The trace viewer’s network panel is the fastest way to confirm a response came from a route rather than the network: a fulfilled request shows no remote address.

Gotchas and edge cases

  • Glob patterns include the query string. '**/api/orders' does not match /api/orders?limit=20. Use '**/api/orders*', or pass a predicate and compare new URL(request.url()).pathname yourself, which is clearer once patterns get complicated.

  • route.fulfill bypasses CORS entirely. That is convenient and it means a CORS misconfiguration is invisible to the suite. Keep one spec that hits the real preflight path — or assert on the request headers — so a missing Access-Control-Allow-Origin is not discovered in production.

  • page.route does not cover popups or new tabs. A route registered on the page is scoped to that page, so an OAuth popup or a target-blank link opens uncovered and reaches the network. Register on context whenever the flow can open a second page.


Playwright features worth using here Three features that make request-level assertions cheap: the trace viewer's network panel, request event listeners for building a log, and the ability to fail a test from a listener rather than from an assertion. Each turns a class of silent failure into a loud one. The trace viewer shows which layer served each request a fulfilled request has no remote address Request event listeners build a log without touching routes assert count and payload afterwards Failing from a listener throw on a blocked request names the URL instead of a blank page The third is the difference between 'the list is empty' and 'GET /api/prefs was never mocked'.

Fixtures, not navigation, for setup

The largest single lever on a Playwright suite’s speed and stability is where the setup happens. Most suites do their setup through the interface — logging in, navigating, filling forms — and most of that is unnecessary once a mock layer is in place.

Consider a spec about the order detail screen. Driven through the UI it logs in, waits for the dashboard, navigates to orders, waits for a list, clicks a row and waits for a detail view — six waits before the assertion, each of which can fail for reasons unrelated to what is being tested. Every one of those failures reports the wrong thing.

With the mock in place, the same spec can navigate directly to the detail URL with the session already seeded through storage state and the order already present in the mock’s store. One navigation, one wait, one assertion, and any failure is about the screen under test.

Three mechanisms make that practical:

Storage state for authentication. Playwright can save and restore a logged-in context, so the login flow runs once for the whole suite rather than once per spec. The login flow itself still deserves its own spec — just not fifty of them.

Seeded mock state instead of UI-created state. A record that a spec needs can be put into the mock’s store directly. Creating it through the interface tests the create flow, which is a different spec’s job.

Direct navigation instead of clicking through. If a URL is addressable, go to it. Clicking through to reach it tests the navigation, which again is a different spec.

The rule that falls out: each spec should exercise exactly one thing through the interface and arrange everything else out of band. Suites written that way are several times faster and, more importantly, fail for one reason at a time.

← Back to Browser Test Runner Integration