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.
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 },
],
});
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 comparenew URL(request.url()).pathnameyourself, which is clearer once patterns get complicated. -
route.fulfillbypasses 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 missingAccess-Control-Allow-Originis not discovered in production. -
page.routedoes 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 oncontextwhenever the flow can open a second page.
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.
Related
- Using MSW with Cypress Component Tests — the same problem in the other popular runner
- Browser Test Runner Integration — choosing between runner-level and in-page interception
- Per-Pull-Request Mock Stacks — running this suite against an isolated per-branch stack
← Back to Browser Test Runner Integration