A 500 error from the products API, a slow gateway that triggers a loading spinner, a network failure that tests whether the error message is helpful: none of these are easy to reproduce against a real backend on demand. page.route() intercepts any request your application makes and lets you return route.fulfill() with any status and body, delay and pass through with route.continue(), or simulate network failure with route.abort(). This guide covers the core patterns: JSON fixtures, error state testing, modifying real responses via route.fetch(), blocking third-party scripts, and waitForRequest/waitForResponse for asserting what the app actually sends.
Why Intercept Network Requests?
Testing states that are hard to reproduce:- Server returns 500 — what does the UI show?
- API takes 10 seconds — is there a loading spinner?
- Payment gateway is down — does checkout show a useful error?
- Mock the backend so tests don't depend on real data
- Block analytics, ads, and tracking scripts that slow page load
- Avoid rate limits on external APIs
- Develop UI tests before the API is built
- Test edge cases that are hard to trigger in a real system
Basic Route Mocking
page.route() intercepts requests matching a URL pattern:
test('shows loading state', async ({ page }) => {
// Intercept and delay the products API
await page.route('/api/products', async (route) => {
await new Promise(resolve => setTimeout(resolve, 3000)); // 3 second delay
await route.continue(); // Then let the real request through
});
await page.goto('/products');
// Loading spinner should be visible during the delay
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Wait for products to load
await expect(page.getByTestId('product-card').first()).toBeVisible();
});Returning Mock Responses
Instead of forwarding to the real server, return fake data:
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@test.com', role: 'member' },
];
test('users table shows all users', async ({ page }) => {
await page.route('/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockUsers),
});
});
await page.goto('/admin/users');
const rows = page.getByTestId('user-row');
await expect(rows).toHaveCount(2);
await expect(rows.first()).toContainText('Alice');
await expect(rows.last()).toContainText('Bob');
});Testing Error States
test('shows error when API fails', async ({ page }) => {
await page.route('/api/products', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/products');
await expect(page.getByTestId('error-message')).toBeVisible();
await expect(page.getByTestId('error-message')).toContainText('Something went wrong');
await expect(page.getByTestId('retry-button')).toBeVisible();
});
test('shows empty state when no products', async ({ page }) => {
await page.route('/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.goto('/products');
await expect(page.getByTestId('empty-state')).toBeVisible();
await expect(page.getByTestId('empty-state')).toContainText('No products found');
});
test('shows network error message', async ({ page }) => {
await page.route('/api/products', async (route) => {
await route.abort('failed'); // Simulate network failure
});
await page.goto('/products');
await expect(page.getByTestId('network-error')).toBeVisible();
});URL Patterns
page.route() supports glob patterns and regex:
// Exact URL
await page.route('/api/users', handler);
// Wildcard
await page.route('/api/users/*', handler); // /api/users/1, /api/users/abc
await page.route('/api/**', handler); // All API routes
// Regex
await page.route(/\/api\/users\/\d+/, handler); // /api/users/123
// Glob with query string
await page.route('/api/products?*', handler); // /api/products?page=1&limit=10Intercepting and Modifying Requests
Read the actual request before deciding what to do:
test('uses correct auth header', async ({ page }) => {
let capturedAuthHeader = '';
await page.route('/api/users', async (route) => {
// Capture the actual request header
capturedAuthHeader = route.request().headers()['authorization'] || '';
await route.fulfill({
status: 200,
body: JSON.stringify([]),
});
});
// Do something that triggers the request
await page.goto('/admin/users');
expect(capturedAuthHeader).toMatch(/Bearer .+/);
});
test('sends correct request body', async ({ page }) => {
let capturedBody = '';
await page.route('/api/auth/login', async (route) => {
capturedBody = route.request().postData() || '';
await route.fulfill({
status: 200,
body: JSON.stringify({ token: 'fake-token', user: { id: 1 } }),
});
});
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
const body = JSON.parse(capturedBody);
expect(body.email).toBe('user@test.com');
expect(body.password).toBe('ValidPass1');
});Modifying Real Responses
Intercept a real request and modify its response:
test('handles extra user roles gracefully', async ({ page }) => {
await page.route('/api/users/1', async (route) => {
// Let the real request go through
const response = await route.fetch();
const body = await response.json();
// Modify the response
body.role = 'super-admin'; // This role might not exist in test DB
await route.fulfill({
status: 200,
body: JSON.stringify(body),
});
});
await page.goto('/users/1');
// Test how the UI handles unexpected role values
await expect(page.getByTestId('role-badge')).toBeVisible();
});Blocking Third-Party Requests
Speed up tests by blocking tracking, analytics, and ads:
test.beforeEach(async ({ page }) => {
// Block common third-party scripts
await page.route('**/*.{png,jpg,jpeg,gif,svg,ico,woff,woff2}', route => route.abort());
await page.route('**/google-analytics.com/**', route => route.abort());
await page.route('**/googletagmanager.com/**', route => route.abort());
await page.route('**/hotjar.com/**', route => route.abort());
await page.route('**/intercom.io/**', route => route.abort());
await page.route('**/sentry.io/**', route => route.abort());
});Caution: blocking images might affect visual tests or layout-sensitive assertions.
Using page.waitForRequest and page.waitForResponse
Wait for specific network activity to happen:
test('form submission sends correct data', async ({ page }) => {
await page.goto('/login');
// Start waiting for the request BEFORE the action that triggers it
const requestPromise = page.waitForRequest(req =>
req.url().includes('/api/auth/login') && req.method() === 'POST'
);
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
const request = await requestPromise;
const body = JSON.parse(request.postData() || '{}');
expect(body.email).toBe('user@test.com');
});
test('page reloads after save', async ({ page }) => {
await page.goto('/profile');
// Wait for the save API call
const responsePromise = page.waitForResponse(resp =>
resp.url().includes('/api/users') && resp.status() === 200
);
await page.fill('[data-testid="name"]', 'New Name');
await page.click('[data-testid="save"]');
const response = await responsePromise;
const body = await response.json();
expect(body.name).toBe('New Name');
});Realistic Mock Data Patterns
// data/mocks/users.ts
export const mockUser = (overrides = {}) => ({
id: Math.floor(Math.random() * 10000),
email: `user_${Date.now()}@test.com`,
name: 'Test User',
role: 'member',
createdAt: new Date().toISOString(),
...overrides,
});
export const mockPaginatedResponse = <T>(items: T[], page = 1, limit = 10) => ({
data: items,
page,
limit,
total: items.length,
totalPages: Math.ceil(items.length / limit),
});test('admin table handles 100 users', async ({ page }) => {
const users = Array.from({ length: 100 }, (_, i) =>
mockUser({ id: i + 1, name: `User ${i + 1}` })
);
await page.route('/api/users', async (route) => {
const url = new URL(route.request().url());
const pageNum = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '10');
const start = (pageNum - 1) * limit;
const pageUsers = users.slice(start, start + limit);
await route.fulfill({
status: 200,
body: JSON.stringify(mockPaginatedResponse(pageUsers, pageNum, limit)),
});
});
await page.goto('/admin/users');
// Verify pagination shows correct count
await expect(page.getByTestId('total-count')).toContainText('100');
await expect(page.getByTestId('user-row')).toHaveCount(10); // First page
});Summary
| Method | What it does |
|--------|-------------|
| page.route(url, handler) | Intercept requests matching URL |
| route.fulfill({...}) | Return a mock response |
| route.continue() | Let the real request through |
| route.abort('failed') | Simulate network failure |
| route.fetch() | Make the real request, get response |
| page.waitForRequest(filter) | Wait for a specific request to happen |
| page.waitForResponse(filter) | Wait for a specific response |
Network interception is one of the most powerful Playwright features for testing edge cases. Use it to test loading states, errors, empty states, and network failures — scenarios that are difficult or impossible to reproduce consistently with a real backend.
→ See also: Network Interception, Mocking, and Stubbing in Playwright | API Testing with Playwright: Beyond the UI | Waiting Strategies in Playwright: No More sleep()