← All guides

Mock APIs in Playwright tests β€” page.route, a hosted mock, or both

Let's start with the part most "mock API for Playwright" articles skip: page.route is genuinely excellent. It runs offline, answers in microseconds, gives every test its own isolated stub, and can run arbitrary JavaScript to build a response. If the only thing that needs to see the fake data is the browser page under test, use page.route with route.fulfill() and stop reading.

But four situations come up constantly where an in-test stub is the wrong shape, and what you actually want is a URL:

The rest of this guide is the hosted-mock patterns for those cases β€” and how to combine them with page.route, which stays useful even when the data lives at a URL. Every snippet below was run verbatim before publishing (Playwright 1.49, Node 22, Chromium; the two-worker run in Β§5 included).

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"pw-demo","preset":"ecommerce"}'
# β†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

One curl β†’ a seeded multi-resource CRUD API (products, orders, customers, reviews) with filtering, sorting, pagination, relations and CORS. Prefer a UI? One click creates the same project in the dashboard. The snippets below use the public demo project so they run with zero setup.

2. API tests with the request fixture

Playwright isn't just a browser driver β€” the request fixture is a full API test client, and pointing it at a mock means your API-contract specs run before the backend exists:

// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
  testDir: './tests',
  fullyParallel: true,
  use: {
    // NOTE the trailing slash β€” see the gotcha below.
    baseURL: 'https://mockbird.mockbird.workers.dev/m/demo/',
  },
});
// tests/api.spec.js
const { test, expect } = require('@playwright/test');

test('lists products with pagination', async ({ request }) => {
  const res = await request.get('products?_page=1&_limit=5');
  expect(res.status()).toBe(200);
  expect(Number(res.headers()['x-total-count'])).toBe(30);   // real header, not a fixture
  const products = await res.json();
  expect(products).toHaveLength(5);
  expect(products[0]).toHaveProperty('price');
});

test('handles a 503 from the API', async ({ request }) => {
  const res = await request.get('products?mock_status=503');
  expect(res.status()).toBe(503);
});

Output when we ran it: 2 passed (1.5s). That ?mock_status=503 is a simulation flag β€” any endpoint will return any status you ask for, which turns "how does my client handle a 503" into a one-line test.

Gotcha (we hit it): if your baseURL contains a path (…/m/demo), a request for '/products' resolves against the host root and silently drops /m/demo β€” every call 404s. Standard URL-resolution rules. Fix: trailing slash on the baseURL + relative paths ('products', no leading slash).

3. Browser tests: rewrite the host, keep app code production-identical

The cleanest setup is an env var (API_URL=https://…/m/demo npx playwright test). But if you can't β€” or shouldn't β€” touch the app's config, page.route composes beautifully with a hosted mock: the app keeps calling its real API host, and the test rewrites the host at the network layer:

// tests/ui.spec.js
test.beforeEach(async ({ page }) => {
  await page.route('https://api.acme-shop.test/**', route => {
    const u = new URL(route.request().url());
    route.continue({
      url: 'https://mockbird.mockbird.workers.dev/m/demo' + u.pathname + u.search,
    });
  });
});

test('renders products from the mock', async ({ page }) => {
  await page.goto('/catalog');
  await expect(page.locator('#list li')).toHaveCount(3);
});

Yes, route.continue({ url }) may point at a different host β€” we verified the rewritten request really reaches the mock (asserted via page.waitForResponse on the /m/demo URL, status 200) rather than just watching the UI look right. App code under test: byte-identical to production.

4. Composing routes: the route.fallback gotcha

Now add a per-test failure on top of the host rewrite β€” say the error state. The obvious code has a subtle bug:

// ❌ looks right, passes for the WRONG reason
await page.route('**/products*', route =>
  route.continue({ url: route.request().url() + '&mock_status=503' }));

When several routes match, Playwright runs the most recently registered handler first β€” and route.continue() finalizes the request. The beforeEach host-rewrite never runs, the request goes to the (dead) real host, the fetch fails… and your "shows error UI" assertion passes anyway, while testing a DNS failure instead of a 503. We caught this exact false-positive while writing the guide, by asserting on the response:

// βœ… fallback hands off to the next matching route (the host rewrite)
test('shows the error state on a 503', async ({ page }) => {
  await page.route('**/products*', route =>
    route.fallback({ url: route.request().url() + '&mock_status=503' }));

  const resP = page.waitForResponse(r => r.url().includes('/m/demo/products'));
  await page.goto('/catalog');
  expect((await resP).status()).toBe(503);                        // the mock really sent it
  await expect(page.getByText('Something went wrong')).toBeVisible();
});
Rule of thumb: in a chain of routes, every handler except the last one to act should use route.fallback(), not continue(). And when a test's whole point is "the server said 503", assert the response status, not just the UI β€” UIs show the same error for a network failure.

5. Parallel workers, isolated data scenarios β€” no restore races

Stateful stubs and fullyParallel don't mix: worker A restores "empty state" while worker B is mid-assertion on "30 products". Snapshot pinning dissolves the problem β€” any request carrying X-Mockbird-Snapshot: name is answered read-only from that frozen snapshot, live data untouched, so every worker can pin its own scenario on the same project:

// tests/scenarios.spec.js β€” runs with --workers=2, fully parallel
test.describe('empty state', () => {
  test.use({ extraHTTPHeaders: { 'X-Mockbird-Snapshot': 'empty' } });

  test('API returns no products', async ({ request }) => {
    expect(await (await request.get('products')).json()).toEqual([]);
  });
});

test.describe('edge cases', () => {
  test.use({ extraHTTPHeaders: { 'X-Mockbird-Snapshot': 'edge-cases' } });

  test('handles a 100+-char name and price 0', async ({ request }) => {
    const products = await (await request.get('products')).json();
    expect(products.some(p => p.name.length > 100)).toBe(true);
    expect(products.some(p => p.price === 0)).toBe(true);
  });
});

Output when we ran it: 2 passed on two workers. The demo project ships those two snapshots (empty, edge-cases) so this file runs as-is; on your own project you save snapshots of whatever states your specs need. extraHTTPHeaders applies to both page network traffic and the request fixture, so browser specs pin the same way. Mutating specs still want the save/restore pattern β€” full setup in the deterministic test data guide.

6. The blind spot: page.route never sees server-side fetches

This one bites hard in Next.js / SvelteKit / Remix apps: your route handlers and loaders fetch data on the server, and page.route intercepts browser traffic only. We proved it with a 12-line SSR server (fetches the API server-side, renders HTML) and this test:

test('page.route never sees server-side fetches', async ({ page }) => {
  let intercepted = false;
  await page.route('**/products*', route => { intercepted = true; route.continue(); });

  await page.goto('http://localhost:4599/');
  await expect(page.locator('li')).toHaveCount(2);   // the data rendered fine...
  expect(intercepted).toBe(false);                   // ...but the route NEVER fired
});

Result: 1 passed. The page shows API data and the interception counter stays at zero β€” if your mocking strategy is "page.route everything", your SSR paths are silently testing against production. A hosted mock is the fix that needs no interception at all: the server reads its API base URL from config, so tests boot the app with it pointed at the mock β€”

// playwright.config.js
webServer: {
  command: 'API_URL=https://mockbird.mockbird.workers.dev/m/demo npm start',
  port: 3000,
},

β€” and now both server-side and browser-side fetches hit the same mock, with the same data, and the simulation flags (mock_status, mock_delay, mock_chaos) work in loaders too, where page.route could never inject them.

7. Quick hits: latency, chaos, streaming

Honest comparison

page.route + fulfillMSW (Node/browser)Mockbird (hosted)
Works offline / in airgapped CIβœ”βœ”βœ˜ needs network
Response latency~0 ms~0 msreal network RTT (tens of ms)
Arbitrary JS logic per responseβœ”βœ”partial β€” templated custom routes
Backend-not-built-yet data, seeded & realistichand-writtenhand-writtenβœ” generated, stateful CRUD
Server-side (SSR/loader) fetches✘ (Β§6)βœ” in the same Node process onlyβœ” any process, any language
Same data for teammates / curl / mobile / QA linksβœ˜βœ˜βœ” it's a URL
Parallel-worker data scenariosDIY per testDIYβœ” snapshot pinning, one header
State persists across page loads / processesβœ˜βœ˜βœ” real persistence

They compose: we still used page.route in half the snippets above β€” for host rewrites and per-test flag injection, the two things it's best at. If your whole test surface is browser-only and the backend already exists, page.route alone (or MSW, which can share handlers with dev) is a fine answer, and this table says so.

Try it now

npm i -D @playwright/test
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"pw-demo","preset":"ecommerce"}'

…or skip the terminal: create the same seeded project in one click, then point baseURL at it. Free, no signup required β€” the project is anonymous and claimable later. Full docs.

Unit/component layer next to your e2e suite? The Vitest guide uses the same demo project, snapshots and chaos params β€” one dataset across both suites.

Environment used for verification: Playwright 1.49.1, Node 22, bundled Chromium, run against the live demo project on 2 Aug 2026 β€” including the two-worker parallel run and the SSR interception counter. If a snippet here doesn't work, that's a bug: tell us.