Mocking a network request in Playwright takes three lines, but the harder question is which requests to mock: a mocked payment endpoint makes the payment flow test pass while hiding the exact failure mode users actually see. page.route() with route.fulfill() controls the full response; route.fallback() lets you mock one endpoint surgically while letting everything else hit the real server. This article covers the complete API, the error state patterns that are only testable with mocking, and the specific categories of tests where mocking produces false confidence instead of coverage.
Why you'd want to mock network requests
The case for mocking isn't about avoiding real testing. It's about testing the right thing at the right level.
When a UI test hits a real API, you're at the mercy of network latency, server state, and third-party uptime. A test that checks how the table renders when the /api/items endpoint returns an empty array is a front-end test. It should not require a specific database state to pass. Mocking lets you decouple that concern entirely.
The three main reasons to mock:
Speed is the obvious one. A test that intercepts network calls and returns a pre-built response runs in milliseconds instead of waiting for a real round trip.
Reliability is the bigger one. Tests that hit real backends fail for reasons unrelated to what you're testing: the staging environment is down, a migration ran, someone deleted the test data. Mocked responses are deterministic by definition.
Error states are the most underrated reason. You can't reliably trigger a 503 or a network timeout against a real server in a test suite. With page.route(), you produce those conditions on demand.
page.route(): the intercept pattern
page.route() takes a URL pattern (string, glob, or regex) and a handler function. Every matching request goes through the handler before it hits the network.
import { test, expect } from '@playwright/test';
test('intercepts a network request', async ({ page }) => {
await page.route('https://lab.becomeqa.com/api/items', route => {
// Handler receives the route — you decide what to do with it
console.log('Request intercepted:', route.request().url());
route.continue(); // Pass through unchanged
});
await page.goto('https://lab.becomeqa.com');
});The handler receives a Route object with four main methods. fulfill() returns a mock response, abort() blocks the request entirely, continue() lets it pass through, and fallback() defers to the next matching handler. You'll use all four.
Glob patterns work the way you'd expect:
// Match any request to the /api/ path
await page.route('**/api/**', route => route.continue());
// Match a specific endpoint regardless of origin
await page.route('**/api/items', route => route.continue());page.route() only intercepts requests made by that specific page. If you need to intercept requests across multiple pages in a context, use browserContext.route() instead.Returning mock JSON responses with fulfill()
route.fulfill() short-circuits the request and returns whatever response you specify. This is the workhorse of UI mocking.
test('table renders with mocked API data', async ({ page }) => {
const mockItems = [
{ id: '1', destination: 'Tokyo', status: 'planned', notes: 'Cherry blossom season' },
{ id: '2', destination: 'Lisbon', status: 'completed', notes: '' },
];
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockItems),
});
});
await page.goto('https://lab.becomeqa.com');
// Login steps omitted for brevity
await expect(page.getByText('Tokyo')).toBeVisible();
await expect(page.getByText('Lisbon')).toBeVisible();
});You control every part of the response: status code, headers, content type, and body. If the app checks response headers (like Content-Type), include them explicitly.
For larger mock payloads, load them from a JSON fixture file:
import { readFileSync } from 'fs';
import path from 'path';
test('table renders with fixture data', async ({ page }) => {
const fixtureBody = readFileSync(
path.join(__dirname, 'fixtures/items.json'),
'utf-8'
);
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: fixtureBody,
});
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByRole('table')).toBeVisible();
});This keeps test files readable when the mock data is complex. A realistic payment record or a deeply nested user profile doesn't belong inline.
Blocking requests with abort()
Sometimes you want to verify what happens when a request can't complete at all: an image that fails to load, a third-party analytics script that times out, or a non-critical API call that the app should handle gracefully.
test('app shows error state when API is unreachable', async ({ page }) => {
await page.route('**/api/items', route => {
route.abort('failed'); // Simulates a connection failure
});
await page.goto('https://lab.becomeqa.com');
// Login, navigate to the items view...
// The app should show an error message, not crash
await expect(page.getByText('Unable to load items')).toBeVisible();
await expect(page.getByRole('table')).not.toBeVisible();
});The abort() method accepts an error code: 'failed' for a generic connection error, 'timedout' for timeout behavior, 'blockedbyclient' to simulate an ad-blocker-style block. Use 'timedout' to test timeout handling specifically:
test('shows timeout message after slow response', async ({ page }) => {
await page.route('**/api/items', route => {
route.abort('timedout');
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByText('Request timed out')).toBeVisible();
});Blocking is also useful for speeding up tests by dropping requests you know are irrelevant. Blocking third-party analytics, font CDNs, or tracking scripts can shave seconds off test suites:
test.beforeEach(async ({ page }) => {
// Block analytics requests — they're irrelevant and slow
await page.route(/google-analytics\.com|segment\.io/, route => route.abort());
});Modifying requests in flight with continue()
route.continue() passes the request through to the server but lets you override any part of it first: URL, method, headers, or body. This is useful for injecting auth headers without modifying the app code, or for testing how the backend handles specific header combinations.
test('injects auth header into every API request', async ({ page }) => {
await page.route('**/api/**', route => {
route.continue({
headers: {
...route.request().headers(),
'Authorization': 'Bearer test-token-for-e2e',
},
});
});
await page.goto('https://lab.becomeqa.com/dashboard');
await expect(page.getByRole('table')).toBeVisible();
});You can also rewrite the request URL, which is handy for redirecting production API calls to a staging environment without changing any config:
test('redirects API calls to staging', async ({ page }) => {
await page.route('https://api.production.com/**', route => {
const newUrl = route.request().url().replace(
'api.production.com',
'api.staging.becomeqa.com'
);
route.continue({ url: newUrl });
});
await page.goto('https://lab.becomeqa.com');
});continue() with custom headers, always spread route.request().headers() first. Replacing headers entirely will drop things like Content-Type and Accept that the server might require.Intercepting and inspecting with waitForRequest and waitForResponse
Sometimes the point isn't to mock anything. It's to verify that a specific request actually happened, or to capture the response data for assertion. page.waitForRequest() and page.waitForResponse() return promises that resolve when a matching request or response is seen.
test('clicking Save sends the correct payload', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
// Login steps...
// Set up the listener BEFORE triggering the action
const requestPromise = page.waitForRequest(request =>
request.url().includes('/api/items') && request.method() === 'POST'
);
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Destination').fill('Berlin');
await page.getByRole('button', { name: 'Save' }).click();
const request = await requestPromise;
const payload = request.postDataJSON();
expect(payload.destination).toBe('Berlin');
expect(payload.status).toBeDefined();
});The critical detail: set up the listener before triggering the action. If you await the button click first and then await waitForRequest, the request might have already fired and you'll be waiting for a request that will never come.
waitForResponse works the same way, but resolves with the response:
test('payment form shows success message after API confirms', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/payment');
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/payments') && response.status() === 200
);
await page.getByLabel('Card Number').fill('4111111111111111');
await page.getByRole('button', { name: 'Pay' }).click();
const response = await responsePromise;
const body = await response.json();
expect(body.status).toBe('success');
await expect(page.getByText('Payment confirmed')).toBeVisible();
});Testing error states: 500s, 401s, and timeouts
Error state testing is where page.route() really earns its place. These are scenarios that are nearly impossible to trigger reliably against a real backend, but straightforward to mock.
test('shows error banner on server failure', async ({ page }) => {
await page.route('**/api/items', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('https://lab.becomeqa.com');
// Login...
await expect(page.getByRole('alert')).toContainText('Something went wrong');
});test('redirects to login when session expires', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/items', route => {
requestCount++;
if (requestCount === 1) {
// First request succeeds — user is "logged in"
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: '1', destination: 'Madrid', status: 'planned' }]),
});
} else {
// Subsequent requests return 401 — session expired
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'Session expired' }),
});
}
});
await page.goto('https://lab.becomeqa.com');
// Verify initial load works, then trigger a refresh...
await page.reload();
await expect(page).toHaveURL(/\/login/);
});test('shows retry button after network timeout', async ({ page }) => {
await page.route('**/api/items', async route => {
// Delay then abort — simulates a slow network that times out
await new Promise(resolve => setTimeout(resolve, 8000));
route.abort('timedout');
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({
timeout: 15000,
});
});route.fallback() for partial mocking
route.fallback() lets a handler step aside and let the next matching handler (or the real network) take over. This is the right tool when you want to mock specific endpoints while letting everything else hit the real server.
test('mocks only the payments endpoint', async ({ page }) => {
// First handler: mock the payments endpoint
await page.route('**/api/payments', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'success', transactionId: 'mock-txn-001' }),
});
});
// Second handler: let everything else through
await page.route('**', route => route.fallback());
await page.goto('https://lab.becomeqa.com');
// Real login, real data loading — only payments is mocked
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.goto('https://lab.becomeqa.com/payment');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Payment confirmed')).toBeVisible();
});route.fallback() is what separates surgical mocking from all-or-nothing mocking. You can swap out one brittle external dependency while keeping the rest of the test grounded in real behavior.
Multiple handlers for the same route are evaluated in reverse registration order. Last registered, first evaluated. When a handler calls fallback(), Playwright moves to the previously registered handler.
// Registered first — acts as the default
await page.route('**/api/**', route => route.continue());
// Registered second — evaluated first
await page.route('**/api/payments', route => {
route.fulfill({ status: 200, body: JSON.stringify({ status: 'success' }) });
});When NOT to mock
Mocking has a cost: your tests are only as good as your mock data. If the real API returns a shape you didn't anticipate in your fixture, your mocked tests will pass while production breaks.
There are categories of tests where mocking actively harms you:
Contract tests need to hit the real API. If you're verifying that a front-end request matches the backend's expected contract (field names, required headers, response shape), a mock can't catch the mismatch. That's exactly what the real API is for. Integration tests for critical flows should use the real backend. The login flow, the payment flow, the data submission that drives the core business need real integration to catch the actual failure modes. Mock them and you're testing confidence, not behavior. When debugging a real bug, mocking prevents you from seeing the actual problem. If users are reporting an issue with the payment confirmation page, the last thing you want is a mocked payment endpoint hiding the real response.The practical rule: mock to make a test deterministic and fast when the network request is not the thing you're testing. Don't mock when the request itself, or the server that handles it, is under test. A complete test suite uses both: real API calls for integration tests and contracts, mocked responses for UI rendering and error state tests.
FAQ
Doespage.route() affect requests that start before the handler is registered?
No. Handlers only intercept requests that are initiated after registration. Always call page.route() before page.goto() or before the action that triggers the request.
Yes. Playwright evaluates handlers in reverse registration order. Use route.fallback() to pass control to the next handler. Use page.unroute() to remove a handler when you no longer need it.
route.fulfill() doesn't support streaming natively. It sends the full body at once. For streaming scenarios, you'll need a local test server or a tool like msw (Mock Service Worker) integrated alongside Playwright.
Should mock data live in test files or fixture files?
Short mock objects (2–3 fields) are fine inline. Anything larger or reused across tests belongs in a fixtures/ directory. This keeps test files focused on behavior, not data setup.
page.route() and Service Workers for mocking?
page.route() intercepts at the Playwright level, before the browser's network stack. Service Workers intercept inside the browser. For Playwright tests, page.route() is simpler, more reliable, and doesn't require setup in the app code. Service Workers are useful when you need the mock to persist across full page navigations or affect the service worker cache.
→ See also: API Testing with Playwright: Beyond the UI | Building a Scalable Playwright Test Framework from Scratch | API Testing with Playwright's APIRequestContext (No Postman Required)