DevXTools

How to Mock APIs in Playwright: A Practical Guide

To mock APIs in Playwright, you intercept the browser's HTTP requests in an end-to-end test and answer them yourself. That lets you test states that are hard to get from a real backend, like empty lists, 500s, timeouts and slow responses, and keeps tests from failing when a shared staging API is down.

Tested with Playwright 1.64.1 (Chromium) on 2026-10-15. Every Playwright snippet on this page comes from a test file we ran, and all 17 tests passed. devxtools.dev is independent and has no relationship with Microsoft or the MSW project.

Don't mock everything, though. Keep at least a small smoke suite that hits a real backend, or your E2E tests turn into very slow unit tests that can't catch a broken API contract. (See Keeping mocks honest below.)

The minimal working example:

import { test, expect } from '@playwright/test';

test('mocks a GET request', async ({ page }) => {
  await page.route('**/api/users*', async (route) => {
    await route.fulfill({ json: [{ name: 'Ada Lovelace' }, { name: 'Alan Turing' }] });
  });
  await page.goto('/');
  await expect(page.getByRole('listitem')).toHaveText(['Ada Lovelace', 'Alan Turing']);
});

Quick start: mock a GET request with page.route() and route.fulfill()

Three rules make the example above work:

  1. Register the route before the request happens. Call page.route() before page.goto() if the app fetches on load.
  2. Make the pattern match the whole URL. **/api/users* matches http://localhost:4173/api/users?page=1&role=admin, because ** covers the origin and the trailing * covers the query string.
  3. Pass json. route.fulfill({ json }) serialises the body and sets content-type: application/json for you.

The handler has three main options: route.fulfill() answers the request, route.continue() sends it on to the network (optionally modified), and route.abort() fails it.

Matching the right requests

Glob vs regex vs predicate URLs

page.route(url, handler) accepts a glob string, a RegExp, a URLPattern, or a predicate function that receives a URL.

Glob rules in Playwright, from the docs:

  • * matches any characters except /.
  • ** matches any characters, including /.
  • ? matches a literal question mark, not "any character".
  • The glob must match the entire URL.

That last rule catches most people: '/api/users' won't match a request that has a query string.

await page.route('**/api/users*', handler);          // glob
await page.route(/\/api\/users(\?|$)/, handler);     // regex
await page.route((url) => url.pathname === '/api/users', handler); // predicate

Query parameters

Predicates are the clearest way to match on query parameters (tested):

await page.route(
  (url) => url.pathname === '/api/users' && url.searchParams.get('role') === 'admin',
  (route) => route.fulfill({ json: [{ name: 'Admin Only' }] }),
);

Matching by method (POST/PUT) and request body

A route matches every HTTP method, so check route.request() inside the handler. Use route.fallback() to pass anything you don't handle to the next matching route, or to the network if no other route matches (tested):

await page.route('**/api/orders', async (route) => {
  const req = route.request();
  if (req.method() !== 'POST') return route.fallback();
  const body = req.postDataJSON();
  if (body.item === 'out-of-stock') {
    return route.fulfill({ status: 409, json: { status: 'rejected' } });
  }
  return route.fulfill({ status: 201, json: { status: 'accepted' } });
});

page.route vs context.route

page.route() applies to one page. browserContext.route() applies to every page in the context, including popups and new tabs (tested):

test('context.route applies to every page', async ({ context }) => {
  await context.route('**/api/users*', (route) => route.fulfill({ json: [{ name: 'From context' }] }));
  const page = await context.newPage();
  await page.goto('/');
  await expect(page.getByRole('listitem')).toHaveText(['From context']);
});

Use context.route() when a popup's first request must be mocked. Playwright's docs note that page.route() doesn't intercept the first request of a popup page. When both a page route and a context route match, the page route runs first.

Mocking responses

JSON, status codes and headers

await route.fulfill({
  status: 201,
  headers: { 'x-request-id': 'test-123' },
  json: { id: 1 },
});
// Non-JSON:
await route.fulfill({ status: 404, contentType: 'text/plain', body: 'Not Found!' });

Error states (500, 401, timeouts)

// HTTP errors (tested)
await page.route('**/api/users*', (route) =>
  route.fulfill({ status: 500, json: { error: 'boom' } }));

// Network failure: fetch() rejects (tested)
await page.route('**/api/users*', (route) => route.abort('failed'));

// Simulate a timeout error code instead
await page.route('**/api/users*', (route) => route.abort('timedout'));

route.abort() takes an error code ('failed' by default). Others include 'timedout', 'connectionrefused' and 'internetdisconnected'.

For retry logic, make a route fail once and then succeed with times (v1.44+, tested). The most recently registered route runs first:

await page.route('**/api/users*', (route) => route.fulfill({ json: [{ name: 'Recovered' }] }));
await page.route('**/api/users*', (route) => route.fulfill({ status: 503 }), { times: 1 });

Simulating latency

There's no built-in delay option. Wait before fulfilling (tested):

await page.route('**/api/users*', async (route) => {
  await new Promise((r) => setTimeout(r, 1500));
  await route.fulfill({ json: [{ name: 'Slow Sam' }] });
});
await page.goto('/');
await expect(page.getByRole('listitem')).toHaveCount(0);       // loading state
await expect(page.getByRole('listitem')).toHaveText(['Slow Sam']);

Serving fixtures from files

await page.route('**/api/users*', (route) =>
  route.fulfill({ path: 'fixtures/users.json' })); // content type inferred from the extension

Modifying real responses with route.fetch()

Sometimes you want the real backend's response plus an edge case. route.fetch() performs the request, and you fulfil with a modified copy (tested):

await page.route('**/api/users*', async (route) => {
  const response = await route.fetch();
  const users = await response.json();
  users.push({ name: 'Injected Edge Case' });
  await route.fulfill({ response, json: users });
});

Passing response keeps the original status and headers. To change the outgoing request instead (headers, method, post data), use route.continue({ headers: { ... } }).

Record and replay with HAR files

routeFromHAR serves responses from a HAR file. With update: true, it records real traffic into the file instead (tested):

await page.routeFromHAR('hars/users.har', {
  url: '**/api/**',               // only API calls; static assets go to the network
  update: !!process.env.UPDATE_HAR,
  updateContent: 'embed',         // or 'attach' to store bodies as separate files
});

Record with UPDATE_HAR=1 npx playwright test, then commit the HAR and run normally. You can also record from the CLI: npx playwright open --save-har=example.har --save-har-glob="**/api/**" https://example.com. For contexts you create yourself, the recordHar context option does the same.

Things to know:

  • Matching is strict. The URL and method must match, and POST bodies must match too. If several entries match, the one with the most matching headers wins.
  • Unmatched requests are aborted by default. Pass notFound: 'fallback' to let them through.
  • Keep HARs small and scrubbed. Always set url to scope recording to your API. The default updateMode: 'minimal' drops data Playwright doesn't need for replay. HARs store headers and bodies verbatim, so remove tokens, cookies and personal data before committing (a quick script over the JSON works).

Reusable mocks with Playwright fixtures

Copy-pasting page.route() into every test gets old. Put default mocks in a fixture, and make the data an option so tests can override it (tested):

// tests/fixtures.ts
import { test as base } from '@playwright/test';

type User = { name: string };

export const test = base.extend<{ users: User[]; mockApi: void }>({
  users: [[{ name: 'Default User' }], { option: true }],
  mockApi: [async ({ page, users }, use) => {
    await page.route('**/api/users*', (route) => route.fulfill({ json: users }));
    await use();
  }, { auto: true }],
});
export { expect } from '@playwright/test';
// tests/users.spec.ts
import { test, expect } from './fixtures';

test('uses default mocks', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('listitem')).toHaveText(['Default User']);
});

test.describe('empty state', () => {
  test.use({ users: [] });
  test('shows no users', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByRole('listitem')).toHaveCount(0);
  });
});

You can also override inside a single test by registering another page.route(). It runs before the fixture's route because it was added later. To remove routes, use page.unroute(url) or page.unrouteAll({ behavior: 'ignoreErrors' }).

Mocking WebSockets

Since v1.48, page.routeWebSocket() lets you act as the server (tested):

await page.routeWebSocket('**/ws', (ws) => {
  ws.onMessage((message) => {
    if (message === 'ping') ws.send('pong');
  });
});

Call ws.connectToServer() inside the handler if you want to proxy to the real server and modify messages instead.

Using MSW with Playwright

MSW is useful when you want one set of handlers for unit tests (Vitest/Jest), Storybook, local development and E2E.

Sharing handlers between Vitest/Jest and E2E

The official @msw/playwright package (0.7.0 at the time of writing) connects MSW handlers to Playwright's routing. It uses page.route()/context.route() internally, so you don't need the service-worker script (tested):

// mocks/handlers.ts — shared with your unit tests
import { http, HttpResponse } from 'msw';
export const handlers = [
  http.get('*/api/users', () => HttpResponse.json([{ name: 'MSW User' }])),
];
// tests/msw.setup.ts
import { test as testBase } from '@playwright/test';
import type { AnyHandler } from 'msw';
import { defineNetworkFixture, type NetworkFixture } from '@msw/playwright';
import { handlers } from '../mocks/handlers';

interface Fixtures { handlers: Array<AnyHandler>; network: NetworkFixture }

export const test = testBase.extend<Fixtures>({
  handlers: [handlers, { option: true }],
  network: [async ({ context, handlers }, use) => {
    const network = defineNetworkFixture({ context, handlers });
    await network.enable();
    await use(network);
    await network.disable();
  }, { auto: true }],
});
export { expect } from '@playwright/test';
// tests/msw.spec.ts
import { http, HttpResponse } from 'msw';
import { test, expect } from './msw.setup';

test('overrides a handler per test', async ({ network, page }) => {
  network.use(http.get('*/api/users', () => HttpResponse.json([], { status: 500 })));
  await page.goto('/');
  await expect(page.locator('#status')).toHaveText('Error 500');
});

msw 3.x is ESM-only and declares Node 22.12+ in its engines field.

The service-worker caveat

If your app itself starts MSW's browser worker (setupWorker().start(), common in dev builds), the service worker answers requests before Playwright sees them. Playwright's docs say network events and routing don't cover requests a service worker handles. You have two options:

  • Block service workers in tests: use: { serviceWorkers: 'block' } in playwright.config.ts. Then page.route() sees everything. The trade-off is that you no longer test anything that depends on the service worker, such as offline or PWA caching.
  • Let the worker mock, and don't also use page.route() for the same endpoints.

@msw/playwright avoids the problem because it doesn't use the worker.

Using a mock server instead

Use a standalone mock server when several apps or test runners share the mocks, when you need state across requests, or when mocks should come from an OpenAPI spec:

  • Prism: mocks from OpenAPI examples. We ran prism mock openapi.yaml with a local install of @stoplight/prism-cli 5.16.0, and it served the spec's example on http://127.0.0.1:4010. @stoplight/prism-cli 5.16.0 declares Node >=24.18.0 in its engines field, so use Node 24.18+ for a supported setup. Our basic prism mock run also served the example on Node 20.19.2, 22.23.3 and 24.22.0.
  • Mockoon: design routes in the desktop app, then run the same file in CI: npx @mockoon/cli start --data ./mocks.json --port 3000.
  • WireMock: stateful scenarios and record/playback. It runs in Docker.

Start the mock server alongside your app with Playwright's webServer option (it accepts an array):

// playwright.config.ts
export default defineConfig({
  use: { baseURL: 'http://localhost:4173' },
  webServer: [
    { command: 'npx @stoplight/prism-cli mock openapi.yaml', url: 'http://127.0.0.1:4010/api/users' },
    { command: 'API_URL=http://127.0.0.1:4010 npm run preview', url: 'http://localhost:4173' },
  ],
});

We ran this two-server pattern with Playwright 1.64.1 and Prism 5.16.0: Playwright waited for Prism's readiness URL (it returns the spec's 200 example), then started the app, and the test rendered the mocked users. If your config file isn't next to the spec, set cwd on each entry.

The trade-off is control: per-test edge cases (a single 500, a delay) are easier with page.route(). Many teams combine the two, with a mock server for the baseline and page.route() for edge cases.

Troubleshooting: "Playwright mock API not working"

  • Route registered too late. Register routes before page.goto() or before the action that triggers the request.
  • Glob doesn't match the full URL. '/api/users' doesn't match /api/users?page=1, and * doesn't cross /. Use **/api/users* or a predicate. To debug, log every request with page.on('request', r => console.log(r.method(), r.url())).
  • baseURL surprises. If baseURL is set, a pattern that doesn't start with * is resolved against it, so '/api/users' becomes an exact http://localhost:4173/api/users match.
  • Service worker. MSW's browser worker, or a PWA worker, hides requests from route(). Set serviceWorkers: 'block'.
  • Handler order. The last registered handler runs first. A generic route added later shadows a specific one added earlier. Use route.fallback() to pass requests along.
  • Popups. Use context.route().
  • CORS. In our Chromium and Firefox tests, a cross-origin fetch with a custom header (which triggers a preflight) was fulfilled by route.fulfill() without any CORS headers, and the handler never saw an OPTIONS request. If you route.continue() to a real server or point at a mock server, CORS headers are that server's job. We couldn't launch WebKit on our Linux test machine (missing system libraries), so WebKit is untested.
  • API tests vs mocks. Playwright's request fixture (APIRequestContext) makes requests from Node, not the page, so page.route() doesn't apply to it. Use it to test real APIs, not to mock them.

Coming from Cypress? cy.intercept() maps to page.route(), with the same "last defined wins" precedence. See Playwright vs Cypress.

Keeping mocks honest

Mocks drift from the real API without anyone noticing. Three ways to catch that:

  1. Generate mocks from the OpenAPI spec (Prism) so a spec change breaks the mock.
  2. Run contract tests such as Pact (consumer-driven contracts) between frontend and backend. Our Pact guide is in progress.
  3. Keep a small unmocked smoke suite against staging.

For mocking at the unit and integration level in Node, see MSW vs Nock, and browse all mocking and test-data tools.

FAQ

How do I mock an API response in Playwright?

Call await page.route('**/api/path*', route => route.fulfill({ json: data })) before navigating. The route intercepts matching requests and returns your data.

How do I mock a POST request in Playwright?

Register a route for the URL, check route.request().method() === 'POST', read the body with postDataJSON(), and fulfill based on it. Call route.fallback() for other methods.

How do I mock an API with query parameters in Playwright?

Use a predicate: page.route(url => url.pathname === '/api/users' && url.searchParams.get('role') === 'admin', handler). Or use a glob ending in * that covers the query string.

Why is my Playwright mock not working?

Usually it's one of these: the route was registered after the request fired, the glob doesn't match the full URL, a service worker is handling the request, or a later route is shadowing yours. See the troubleshooting checklist above.

Can I use MSW with Playwright?

Yes. The official @msw/playwright package runs your MSW handlers through Playwright's routing, so the same handlers work in unit tests and E2E.

page.route vs browserContext.route: what's the difference?

page.route covers one page. context.route covers every page in the browser context, including popups. Page routes take precedence when both match.



Sources (checked 2026-10-15)

Find tools for your stack

Answer a few questions to find tools that match your needs.