Article · Network & API Mocking for Reliable Tests

How to Mock REST APIs in Playwright

External network dependency is one of the fastest ways to destabilize an end-to-end suite: third-party latency, rate limits, and shifting payloads all surface as intermittent failures that have nothing to do with your UI. Playwright's routing engine lets you intercept HTTP traffic and return deterministic responses, decoupling the test from the backend. This guide sits under Playwright Route Mocking Strategies in Network & API Mocking for Reliable Tests, and it walks the setup, dynamic payloads, CORS preflight, and the registration-timing trap.

12 sections URL: /network-api-mocking-for-reliable-tests/playwright-route-mocking-strategies/how-to-mock-rest-apis-in-playwright/
Register the route before navigation A route registered before page.goto captures the request and fulfills a deterministic payload; registered after, the request escapes. page.route()register first page.goto()request fires fulfilled deterministically register after goto → the first request escapes the mock
Registration order is the invariant — the route must exist before the request fires.

Core Route Interception Setup #

page.route() accepts a string, glob, or regex matcher and hands each matching request to a handler. Register the route before navigation, then terminate the handler with route.fulfill() for a stub or route.continue() for real traffic.

Matcher to terminal action A string, glob, or regex matcher routes the request to fulfill or continue. matcherstring / glob / regex route.fulfill → stub route.continue → real
Every matched request must end in fulfill or continue, or it hangs to timeout.
// tests/api-mocking.spec.js — register before goto so nothing escapes.
const { test, expect } = require('@playwright/test');

test('loads user list from mock', async ({ page }) => {
  await page.route('**/api/v1/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json', // set it, or the frontend parser breaks
      body: JSON.stringify([{ id: 1, name: 'Test User' }]),
    });
  });
  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(1);
});

Dynamic Payload Generation & Contract Validation #

Static stubs rot as contracts evolve. Inspect route.request() to build a context-aware response — for example, echoing a path parameter — and validate the shape against your OpenAPI schema so a drifted contract fails the test instead of silently passing. This is the API Contract Validation in E2E Tests discipline applied at the route layer.

Context-aware response from the request The handler extracts a path parameter from route.request().url() and builds a matching response. /orders/42route.request().url() extract id = 42 { id: 42 }matching stub
Reading the request lets one handler serve many parameterized responses.
// Extract the path parameter to build a context-aware response.
test('loads order detail from dynamic mock', async ({ page }) => {
  await page.route(/\/api\/v1\/orders\/.*/, async route => {
    const orderId = route.request().url().split('/').pop();
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: orderId, status: 'fulfilled' }), // echoes the requested id
    });
  });
  await page.goto('/orders/42');
  await expect(page.getByTestId('order-status')).toHaveText('fulfilled');
});

Handling CORS & Pre-flight OPTIONS Requests #

Fulfilling a route bypasses browser CORS, but a real cross-origin app still issues OPTIONS preflights that your handler must answer, and route.continue() to a cross-origin backend still triggers CORS. Fulfill the preflight explicitly with the right Access-Control-Allow-* headers.

Branch on the HTTP method OPTIONS preflight gets a 204 with CORS headers; GET gets a stub; writes continue to the backend. request method? OPTIONS → 204 + CORS GET → stub write → continue
Answer the preflight explicitly so the browser lets the real request through.
// Answer preflight explicitly; stub reads; forward writes for integration coverage.
await page.route('**/api/**', async route => {
  if (route.request().method() === 'OPTIONS') {
    await route.fulfill({ status: 204, headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    }});
  } else if (route.request().method() === 'GET') {
    await route.fulfill({ status: 200, body: '{}', contentType: 'application/json' });
  } else {
    await route.continue(); // keep writes real to exercise the backend
  }
});

Troubleshooting Route Registration Timing #

If a mock never fires, the route was almost certainly registered after the request. Pair page.route() with page.waitForResponse() inside a Promise.all so navigation and interception are synchronized, and prefer regex with explicit parameter boundaries over loose globs.

Synchronize navigation and response Promise.all pairs waitForResponse with goto so the assertion sees the intercepted response. route registered Promise.allwaitForResponse + goto status 200
Pairing the wait with navigation confirms the interception happened before you assert.
test('route registered before navigation', async ({ page }) => {
  await page.route('**/api/data', route => route.fulfill({
    status: 200, body: '{"items": []}', contentType: 'application/json',
  }));
  const [response] = await Promise.all([
    page.waitForResponse('**/api/data'), // synchronise on the real interception
    page.goto('/dashboard'),
  ]);
  expect(response.status()).toBe(200);
});

Common Pitfalls #

REST-mocking anti-patterns and fixes Late registration, broad globs, missing OPTIONS, and hardcoded dynamic fields each map to a fix. register after navigation register before goto broad **/api/** glob regex with boundaries ignore OPTIONS preflight fulfill preflight explicitly hardcode timestamps/UUIDs seeded/echoed dynamic fields
Each red habit reintroduces flakiness; the green fix keeps the mock precise and timely.
  • Registering routes after navigation causes missed interceptions.
  • Broad globs (**/api/**) mask unrelated failures and inflate false passes.
  • Forgetting OPTIONS preflight when testing cross-origin flows.
  • Hardcoding timestamps or UUIDs breaks assertions on dynamic fields.

FAQ #

Why do my Playwright API mocks fail intermittently in CI? Almost always a race between route registration and request dispatch. Call page.route() before navigation and synchronize with page.waitForResponse().

Can I mock GraphQL with page.route()? Yes — GraphQL uses one POST endpoint, so intercept the URL and read route.request().postData() to match operationName, per Mocking GraphQL Operations in Playwright.

How do I verify the mock actually triggered? Use page.waitForResponse() with a predicate on response.url() and response.status() before asserting on the DOM.

Reliability Metrics #

REST-mocking impact scorecard Targets for flakiness reduction, execution-time gain, maintenance overhead, and CI stability. 60–85%flakiness ↓ 40–70%faster suite low–medmaintenance highCI stability
Deterministic payloads drive the flakiness and speed gains that make CI stable.
Metric Impact
Flakiness reduction 60–85% fewer network-dependent failures
Execution time 40–70% faster without external latency
Maintenance overhead Low–medium; periodic OpenAPI sync
CI stability score High — deterministic payloads remove rate-limit variance

Sequences, Counters and Where Their State Lives #

Many realistic scenarios are sequences rather than single responses: fail then succeed, rate-limit then allow, return page one then page two. Expressing them requires the handler to remember what it has already done, and where that memory lives determines whether the test is isolated.

A counter declared inside the test body has the test’s lifetime and cannot affect anything else. The same counter at module scope is shared by every test in the file — and, because handlers are frequently registered in a shared fixture, occasionally by every test in the run. That is the shared-mutable-state problem in miniature, and it produces the classic signature of a test that passes alone and fails in the suite.

// Sequence state belongs to the test, not to the module.
// Trade-off: declaring it inline is slightly more verbose than a module-level
// helper and is the only version that cannot leak into the next test.
test('recovers after a transient failure', async ({ page }) => {
  let attempt = 0;
  await page.route('**/api/invoices', async (route) => {
    attempt += 1;
    if (attempt === 1) return route.abort('connectionfailed');
    return route.fulfill({ status: 200, json: { invoices: [{ id: 'INV-1' }] } });
  });
  // …
});

The same reasoning applies to any per-scenario data a handler closes over: a queue of responses, a mutable store, a recorded list of requests. If it survives the test, it will eventually answer for a test that expected something else.

Fulfil, Modify, Abort or Pass Through #

A route handler has four distinct powers, and reaching for the weakest one that does the job keeps tests both readable and resilient.

Fulfilling replaces the response with a literal. It is the most deterministic and the most explicit — a reader sees exactly what the application receives — which makes it right for error cases, empty states and any payload small enough to read inline.

Modifying lets the real request proceed and alters the response on the way back: injecting a field, changing a status, truncating a list. It keeps the fidelity of a genuine payload while changing the one thing under test, at the cost of depending on the real service being available — so it belongs in a small number of specs rather than as a default.

Aborting models network failure and accepts an error code, which lets a test distinguish a refused connection from a name-resolution failure and check that the interface does not present one as the other.

Passing through states explicitly that a particular host is meant to be reached. It is far preferable to a permissive global setting because it is bounded, visible in the code, and auditable in review.

// Choose the weakest power that expresses the scenario.
// Trade-off: modifying a real response is more faithful and reintroduces a
// dependency on the real service; fulfil unless fidelity is the point.
await page.route('**/api/invoices', (route) =>
  route.fulfill({ status: 200, json: { invoices: [] } }));            // empty state

await page.route('**/api/rates', (route) => route.abort('connectionfailed')); // failure

Scoping, Cleanup and Fixtures #

Where a route is registered determines both what it covers and how long it lives, and mixing the two scopes without a rule produces suites where nobody can predict which handler answers.

Page-level routes cover one page and disappear with it, which suits anything a single test needs. Context-level routes apply to every page created from that context — including popups, which is often exactly what an authentication window requires — and they persist for the context’s lifetime. In suites that create contexts themselves rather than relying on the per-test fixture, an unremoved context route leaks into later tests and answers requests with a scenario from a test that finished minutes ago.

The layering rule follows from precedence: the most recently registered matching handler wins. A fixture supplying the realistic baseline should therefore register first, and a test’s own scenario second. A catch-all intended to abort unexpected traffic must register first of all, or it swallows the specific handlers meant to run.

Practically, that means the baseline belongs in a fixture — so every spec starts from the same picture of the API without restating it — and scenario behaviour belongs inline in the test, where a reader can see it beside the assertion it supports. Scenario behaviour hidden in a fixture is the most common reason a spec cannot be understood on its own.

Keep the Fixture Small Enough to Read #

A payload beyond a screenful belongs in a file referenced by path rather than inline, and a payload beyond a few hundred kilobytes belongs in a recorded archive with its own review. Readability is what keeps a mock honest: an unread fixture is an unverified assumption about the API.

Registering the baseline in a fixture and the scenario in the test keeps every spec readable on its own, which matters more than it sounds: a reader who cannot tell what the API returned cannot judge whether the assertion is correct.