page.route, a hosted mock, or bothLet'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:
route.fulfill body means maintaining a second backend in your test files.page.route only sees browser requests. Server-side fetches sail straight past it (we prove this in Β§6 with a runnable test).fullyParallel workers is exactly the kind of shared-fixture problem snapshots solve in one header (Β§5).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).
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.
request fixturePlaywright 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.
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).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.
route.fallback gotchaNow 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();
});
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.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.
page.route never sees server-side fetchesThis 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.
?mock_delay=3000 β a real 3-second response, so toBeVisible races are honest. Recipes: loading & error states guide.?mock_chaos=0.4 fails 40% of requests with real 5xx/429s (chaos-failed writes are not applied, so retries are safe). Inject it per-test with the route.fallback pattern from Β§4.?mock_sse=1 streams the list as Server-Sent Events; NDJSON too.page.route + fulfill | MSW (Node/browser) | Mockbird (hosted) | |
|---|---|---|---|
| Works offline / in airgapped CI | β | β | β needs network |
| Response latency | ~0 ms | ~0 ms | real network RTT (tens of ms) |
| Arbitrary JS logic per response | β | β | partial β templated custom routes |
| Backend-not-built-yet data, seeded & realistic | hand-written | hand-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 scenarios | DIY per test | DIY | β 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.
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.