Mocking APIs in Playwright E2E Tests with route() and fulfill()
Playwright's route() and fulfill() let you mock HTTP responses in cross-browser E2E tests, while browser contexts and serial() isolate state and prevent interference.
13 Jul 2026, 07:33 UTC

Cross-browser E2E tests often break when a staging API is down or when network latency triggers timeouts you can't control. Playwright gives you a way to intercept and mock those responses without touching your server, but the pattern isn't always obvious, especially when you need sequential test execution or browser-state isolation.
The mocking building blocks
Playwright's page.route() and route.fulfill() let you intercept any HTTP request matching a selector and return a custom response instantly. The framework automatically waits for elements before actions, which eliminates the majority of flaky tests caused by timing. To mock a JSON endpoint, you register a route, then fulfill it with a payload:
page.route('**/api/user-profile', route => route.fulfill({ json: { name: 'Mock User', id: 1 } }))This works across Chromium, Firefox, and WebKit. The route matches after the test navigates, so you don't need to restart the browser between assertions.
Isolation with browser contexts
A browser context is Playwright's version of a clean browser profile. It isolates cookies, localStorage, indexedDB, and permission states, giving you a fresh slate per test file or per test suite. This means mocking a login flow in one context won't leak cookies into the next test, and you can even mock different user sessions by creating multiple contexts from the same browser.
const context = await browser.newContext()Use test.describe.context() to bind a context to every test in the block.
Sequential execution when state depends on previous tests
When your tests share mutable state—say, a cart total that carries over—test.describe.serial() runs each test one after another, preventing interference from parallel workers. This is useful when you're building on mocked responses from a prior test, but be aware: serial mode slows down your total runtime if you have many independent tests.
test.describe.serial('checkout flow', () => { test('add item to cart', async ({ page }) => { await page.route('**/api/cart', route => route.fulfill({ json: { items: ['widget'] } })) await page.goto('/') await page.click('text=Add widget') }) test('verify total updates', async ({ page }) => { // depends on the first test's mock await expect(page.locator('.total')).toHaveText('$15') })})Cross-origin caveat and how to verify it
If your app navigates to a different origin, Playwright may block network interception unless you declare the base URL in your config. Add the origin to the origins array in playwright.config.ts:
origins: ['http://localhost:3000', 'https://staging.example.com']After updating the config, run npx playwright test from your project root. In the test output log, look for the message Route matched confirming the interception took effect. If you don't see it, double-check that the URL pattern and origin match exactly.
Risk: Adding overly broad origins can inadvertently intercept requests you meant to pass through, so keep the list specific to your test environments.
Actionable checklist
- Identify the API endpoint you need to mock in your E2E flow.
- Add
page.route()withroute.fulfill()before the navigation that triggers the request. - Wrap state-dependent tests in
test.describe.serial()if previous tests set shared state. - Create a browser context with
browser.newContext()if you need cookie or storage isolation. - List any cross-origin URLs in
playwright.config.ts originsto avoid interception blocks. - Run
npx playwright testand verifyRoute matchedappears in the log.
Using Playwright's network interception for API mocking gives you offline-capable, timing-safe E2E tests across browsers, provided you respect cross-origin boundaries and choose the right isolation strategy for your test state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.