43 KiB
Executable file
Network Mocking
When to use: Isolating your frontend from external services, simulating error states, testing loading/empty/error UI, speeding up tests by avoiding real network calls, and testing against APIs that don't exist yet. Prerequisites: core/locators.md, core/assertions-and-waiting.md
Quick Reference
// Intercept and return fake data
await page.route('**/api/users', (route) =>
route.fulfill({ json: [{ id: 1, name: 'Jane' }] })
);
// Modify a real response before it reaches the browser
await page.route('**/api/users', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 999, name: 'Injected' });
await route.fulfill({ response, json });
});
// Block third-party scripts
await page.route('**/*.{png,jpg,svg}', (route) => route.abort());
// Wait for a specific request/response
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Load' }).click();
await responsePromise;
// HAR replay — serve recorded responses
await page.routeFromHAR('tests/data/api.har', { url: '**/api/**' });
Patterns
Route Interception Basics
Use when: You need to intercept any HTTP request made by the page to fulfill, modify, or block it. Avoid when: You want to test the real integration between frontend and backend (use real API calls instead).
page.route() registers a handler that runs for every request matching a URL pattern. Each handler must call exactly one of route.fulfill(), route.continue(), or route.abort().
TypeScript
import { test, expect } from '@playwright/test';
test('route interception basics', async ({ page }) => {
// Intercept before navigating — routes must be set up first
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Alice' }]),
});
});
await page.goto('/dashboard');
await expect(page.getByText('Alice')).toBeVisible();
// Remove the route when done (important for cleanup)
await page.unroute('**/api/users');
});
test('context-level routes apply to all pages', async ({ context, page }) => {
// Routes on the context apply to every page in that context
await context.route('**/api/config', (route) =>
route.fulfill({ json: { theme: 'dark', locale: 'en' } })
);
await page.goto('/settings');
await expect(page.getByText('Dark')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('route interception basics', async ({ page }) => {
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Alice' }]),
});
});
await page.goto('/dashboard');
await expect(page.getByText('Alice')).toBeVisible();
await page.unroute('**/api/users');
});
test('context-level routes apply to all pages', async ({ context, page }) => {
await context.route('**/api/config', (route) =>
route.fulfill({ json: { theme: 'dark', locale: 'en' } })
);
await page.goto('/settings');
await expect(page.getByText('Dark')).toBeVisible();
});
Mocking REST Responses
Use when: Your frontend depends on a REST API and you want deterministic, instant responses with controlled data.
Avoid when: You need to verify that your frontend sends the correct request body or headers to the real API (use route.continue() with waitForRequest instead).
TypeScript
import { test, expect } from '@playwright/test';
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
];
test('mock a GET endpoint with JSON', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({ json: mockUsers })
);
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(3); // header + 2 data rows
});
test('mock a POST endpoint and verify the request', async ({ page }) => {
await page.route('**/api/users', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 201,
json: { id: 3, name: 'Charlie', email: 'charlie@example.com' },
});
}
// Let other methods through
return route.continue();
});
await page.goto('/users/new');
await page.getByLabel('Name').fill('Charlie');
await page.getByLabel('Email').fill('charlie@example.com');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Charlie')).toBeVisible();
});
test('mock with custom headers and status', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 200,
headers: {
'content-type': 'application/json',
'x-total-count': '42',
'x-request-id': 'test-abc-123',
},
body: JSON.stringify(mockUsers),
})
);
await page.goto('/users');
await expect(page.getByText('42 total')).toBeVisible();
});
test('mock empty state', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({ json: [] })
);
await page.goto('/users');
await expect(page.getByText('No users found')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
];
test('mock a GET endpoint with JSON', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({ json: mockUsers })
);
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(3);
});
test('mock a POST endpoint and verify the request', async ({ page }) => {
await page.route('**/api/users', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 201,
json: { id: 3, name: 'Charlie', email: 'charlie@example.com' },
});
}
return route.continue();
});
await page.goto('/users/new');
await page.getByLabel('Name').fill('Charlie');
await page.getByLabel('Email').fill('charlie@example.com');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Charlie')).toBeVisible();
});
test('mock empty state', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({ json: [] })
);
await page.goto('/users');
await expect(page.getByText('No users found')).toBeVisible();
});
Mocking GraphQL
Use when: Your frontend uses GraphQL and you want to mock specific queries or mutations by operation name. Avoid when: The GraphQL endpoint is part of your own backend and you want full integration coverage.
TypeScript
import { test, expect } from '@playwright/test';
test('mock a GraphQL query by operation name', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const request = route.request();
const postData = request.postDataJSON();
if (postData.operationName === 'GetUsers') {
return route.fulfill({
json: {
data: {
users: [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
],
},
},
});
}
if (postData.operationName === 'GetUser') {
return route.fulfill({
json: {
data: {
user: { id: '1', name: 'Alice', email: 'alice@example.com' },
},
},
});
}
// Let unmocked operations through to the real server
return route.continue();
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
test('mock a GraphQL mutation', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const { operationName, variables } = route.request().postDataJSON();
if (operationName === 'CreateUser') {
return route.fulfill({
json: {
data: {
createUser: {
id: '99',
name: variables.input.name,
email: variables.input.email,
},
},
},
});
}
return route.continue();
});
await page.goto('/users/new');
await page.getByLabel('Name').fill('Charlie');
await page.getByLabel('Email').fill('charlie@example.com');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Charlie')).toBeVisible();
});
test('mock GraphQL errors', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const { operationName } = route.request().postDataJSON();
if (operationName === 'GetUsers') {
return route.fulfill({
json: {
data: null,
errors: [
{
message: 'Not authorized',
extensions: { code: 'UNAUTHORIZED' },
},
],
},
});
}
return route.continue();
});
await page.goto('/users');
await expect(page.getByText('Not authorized')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('mock a GraphQL query by operation name', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const postData = route.request().postDataJSON();
if (postData.operationName === 'GetUsers') {
return route.fulfill({
json: {
data: {
users: [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
],
},
},
});
}
return route.continue();
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
test('mock a GraphQL mutation', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const { operationName, variables } = route.request().postDataJSON();
if (operationName === 'CreateUser') {
return route.fulfill({
json: {
data: {
createUser: {
id: '99',
name: variables.input.name,
email: variables.input.email,
},
},
},
});
}
return route.continue();
});
await page.goto('/users/new');
await page.getByLabel('Name').fill('Charlie');
await page.getByLabel('Email').fill('charlie@example.com');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Charlie')).toBeVisible();
});
Modifying Responses
Use when: You need the real API response but want to tweak specific fields -- inject test data, override feature flags, simulate edge cases in real data.
Avoid when: You can fully mock the response. route.fetch() adds a real network round-trip, so it is slower.
TypeScript
import { test, expect } from '@playwright/test';
test('modify a real API response', async ({ page }) => {
await page.route('**/api/users', async (route) => {
// Fetch the real response from the server
const response = await route.fetch();
const users = await response.json();
// Inject a test user into the real data
users.push({ id: 999, name: 'Test User', email: 'test@example.com' });
await route.fulfill({ response, json: users });
});
await page.goto('/users');
await expect(page.getByText('Test User')).toBeVisible();
});
test('override feature flags from real config', async ({ page }) => {
await page.route('**/api/config', async (route) => {
const response = await route.fetch();
const config = await response.json();
// Enable a feature flag for testing
config.featureFlags = {
...config.featureFlags,
newCheckout: true,
darkMode: true,
};
await route.fulfill({ response, json: config });
});
await page.goto('/settings');
await expect(page.getByRole('switch', { name: 'Dark mode' })).toBeVisible();
});
test('modify response headers', async ({ page }) => {
await page.route('**/api/data', async (route) => {
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
'cache-control': 'no-cache',
'x-test-header': 'injected',
},
});
});
await page.goto('/data');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('modify a real API response', async ({ page }) => {
await page.route('**/api/users', async (route) => {
const response = await route.fetch();
const users = await response.json();
users.push({ id: 999, name: 'Test User', email: 'test@example.com' });
await route.fulfill({ response, json: users });
});
await page.goto('/users');
await expect(page.getByText('Test User')).toBeVisible();
});
test('override feature flags from real config', async ({ page }) => {
await page.route('**/api/config', async (route) => {
const response = await route.fetch();
const config = await response.json();
config.featureFlags = {
...config.featureFlags,
newCheckout: true,
darkMode: true,
};
await route.fulfill({ response, json: config });
});
await page.goto('/settings');
await expect(page.getByRole('switch', { name: 'Dark mode' })).toBeVisible();
});
Request Blocking
Use when: Blocking analytics, ads, third-party scripts, images, or fonts to speed up tests and eliminate flakiness from external dependencies. Avoid when: The blocked resource is required for the feature under test.
TypeScript
import { test, expect } from '@playwright/test';
test('block analytics and tracking scripts', async ({ page }) => {
await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) =>
route.abort()
);
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('block images to speed up tests', async ({ page }) => {
await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) =>
route.abort()
);
await page.goto('/gallery');
// Test interactions without waiting for image downloads
await page.getByRole('button', { name: 'Next' }).click();
});
test('block specific third-party domains', async ({ page }) => {
const blockedDomains = [
'ads.example.com',
'tracker.example.com',
'cdn.slow-service.com',
];
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (blockedDomains.includes(url.hostname)) {
return route.abort();
}
return route.continue();
});
await page.goto('/home');
});
test('block by resource type', async ({ context }) => {
// Context-level blocking affects all pages
await context.route('**/*', (route) => {
const resourceType = route.request().resourceType();
if (['image', 'font', 'media'].includes(resourceType)) {
return route.abort();
}
return route.continue();
});
});
JavaScript
const { test, expect } = require('@playwright/test');
test('block analytics and tracking scripts', async ({ page }) => {
await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) =>
route.abort()
);
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('block images to speed up tests', async ({ page }) => {
await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) =>
route.abort()
);
await page.goto('/gallery');
await page.getByRole('button', { name: 'Next' }).click();
});
test('block by resource type', async ({ context }) => {
await context.route('**/*', (route) => {
const resourceType = route.request().resourceType();
if (['image', 'font', 'media'].includes(resourceType)) {
return route.abort();
}
return route.continue();
});
});
HAR Recording and Replay
Use when: You want to capture real network traffic once and replay it in tests for speed and determinism. Great for complex APIs with many endpoints or when API access is limited. Avoid when: API responses change frequently and stale recordings would cause false passes. Keep HAR files in version control and update them regularly.
TypeScript
import { test, expect } from '@playwright/test';
// Record a HAR file — run once to capture traffic, then replay
test('record HAR for later replay', async ({ page }) => {
// This records all matching network traffic to the HAR file.
// If the file already exists and responses match, it serves from HAR.
// If a request has no match in the HAR, it falls through to the network
// and the new response is appended to the HAR file.
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
update: true, // set to true to record, false (or omit) to replay
});
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(6);
});
// Replay from a previously recorded HAR file
test('replay from HAR', async ({ page }) => {
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
// update: false is the default — serves from the HAR file
});
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(6);
});
// HAR with notFound option — control what happens for unmatched requests
test('HAR replay with fallback behavior', async ({ page }) => {
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
notFound: 'abort', // 'abort' fails unmatched requests; 'fallback' lets them through
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
});
// Context-level HAR replay
test('HAR replay at context level', async ({ context, page }) => {
await context.routeFromHAR('tests/data/full-app.har', {
url: '**/api/**',
});
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('record HAR for later replay', async ({ page }) => {
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
update: true,
});
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(6);
});
test('replay from HAR', async ({ page }) => {
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
});
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(6);
});
test('HAR replay with fallback behavior', async ({ page }) => {
await page.routeFromHAR('tests/data/users-api.har', {
url: '**/api/**',
notFound: 'abort',
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
});
Conditional Mocking
Use when: You need different responses based on the request method, body, headers, or query parameters. Common for paginated APIs, search endpoints, and role-based access. Avoid when: Simple static mocking suffices. Don't over-engineer route handlers.
TypeScript
import { test, expect } from '@playwright/test';
test('respond based on request method', async ({ page }) => {
await page.route('**/api/users', (route) => {
const method = route.request().method();
switch (method) {
case 'GET':
return route.fulfill({
json: [{ id: 1, name: 'Alice' }],
});
case 'POST':
return route.fulfill({
status: 201,
json: { id: 2, name: 'Bob' },
});
case 'DELETE':
return route.fulfill({ status: 204, body: '' });
default:
return route.continue();
}
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
});
test('respond based on query parameters', async ({ page }) => {
await page.route('**/api/users*', (route) => {
const url = new URL(route.request().url());
const page_num = parseInt(url.searchParams.get('page') || '1');
const role = url.searchParams.get('role');
const allUsers = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'user' },
{ id: 4, name: 'Diana', role: 'admin' },
];
let filtered = allUsers;
if (role) {
filtered = allUsers.filter((u) => u.role === role);
}
const perPage = 2;
const start = (page_num - 1) * perPage;
const paginated = filtered.slice(start, start + perPage);
return route.fulfill({
json: paginated,
headers: {
'content-type': 'application/json',
'x-total-count': String(filtered.length),
},
});
});
await page.goto('/users');
await expect(page.getByRole('row')).toHaveCount(3); // header + 2 rows
});
test('respond based on request body', async ({ page }) => {
await page.route('**/api/search', (route) => {
const body = route.request().postDataJSON();
const query = body?.query?.toLowerCase() || '';
const results = {
playwright: [{ title: 'Playwright Docs' }, { title: 'Playwright GitHub' }],
cypress: [{ title: 'Cypress Docs' }],
};
return route.fulfill({
json: results[query] || [],
});
});
await page.goto('/search');
await page.getByLabel('Search').fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
});
test('respond based on request headers', async ({ page }) => {
await page.route('**/api/users', (route) => {
const authHeader = route.request().headers()['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return route.fulfill({
status: 401,
json: { error: 'Unauthorized' },
});
}
return route.fulfill({
json: [{ id: 1, name: 'Alice' }],
});
});
await page.goto('/login');
await expect(page.getByText('Unauthorized')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('respond based on request method', async ({ page }) => {
await page.route('**/api/users', (route) => {
const method = route.request().method();
switch (method) {
case 'GET':
return route.fulfill({ json: [{ id: 1, name: 'Alice' }] });
case 'POST':
return route.fulfill({ status: 201, json: { id: 2, name: 'Bob' } });
case 'DELETE':
return route.fulfill({ status: 204, body: '' });
default:
return route.continue();
}
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
});
test('respond based on query parameters', async ({ page }) => {
await page.route('**/api/users*', (route) => {
const url = new URL(route.request().url());
const role = url.searchParams.get('role');
const allUsers = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'user' },
];
const filtered = role ? allUsers.filter((u) => u.role === role) : allUsers;
return route.fulfill({ json: filtered });
});
await page.goto('/users?role=admin');
await expect(page.getByText('Alice')).toBeVisible();
});
test('respond based on request body', async ({ page }) => {
await page.route('**/api/search', (route) => {
const body = route.request().postDataJSON();
const query = body?.query?.toLowerCase() || '';
const results = {
playwright: [{ title: 'Playwright Docs' }, { title: 'Playwright GitHub' }],
cypress: [{ title: 'Cypress Docs' }],
};
return route.fulfill({ json: results[query] || [] });
});
await page.goto('/search');
await page.getByLabel('Search').fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
});
Network Error Simulation
Use when: Testing how your UI handles server errors, timeouts, and connection failures. Essential for verifying error boundaries, retry logic, and degraded-mode UX. Avoid when: The test is about the happy path. Only introduce errors when testing error handling.
TypeScript
import { test, expect } from '@playwright/test';
test('simulate a 500 server error', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 500,
json: { error: 'Internal Server Error' },
})
);
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('simulate a 403 forbidden', async ({ page }) => {
await page.route('**/api/admin/**', (route) =>
route.fulfill({
status: 403,
json: { error: 'Forbidden', message: 'Admin access required' },
})
);
await page.goto('/admin/settings');
await expect(page.getByText('Admin access required')).toBeVisible();
});
test('simulate a 404 not found', async ({ page }) => {
await page.route('**/api/users/999', (route) =>
route.fulfill({
status: 404,
json: { error: 'User not found' },
})
);
await page.goto('/users/999');
await expect(page.getByText('User not found')).toBeVisible();
});
test('simulate a network error (connection refused)', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.abort('connectionrefused')
);
await page.goto('/users');
await expect(page.getByText('Network error')).toBeVisible();
});
test('simulate a timeout', async ({ page }) => {
await page.route('**/api/users', async (route) => {
// Delay longer than the app's fetch timeout to trigger a timeout error
await new Promise((resolve) => setTimeout(resolve, 30_000));
await route.fulfill({ json: [] });
});
await page.goto('/users');
// The app should show a timeout message before the route resolves
await expect(page.getByText('Request timed out')).toBeVisible({
timeout: 15_000,
});
});
test('simulate intermittent failures then recovery', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/users', (route) => {
requestCount++;
if (requestCount <= 2) {
return route.fulfill({
status: 503,
json: { error: 'Service Unavailable' },
});
}
return route.fulfill({
json: [{ id: 1, name: 'Alice' }],
});
});
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
// Simulate user clicking retry (which makes the 3rd request)
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Something went wrong')).toBeVisible();
// Third attempt succeeds
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Alice')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('simulate a 500 server error', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({
status: 500,
json: { error: 'Internal Server Error' },
})
);
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('simulate a network error (connection refused)', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.abort('connectionrefused')
);
await page.goto('/users');
await expect(page.getByText('Network error')).toBeVisible();
});
test('simulate intermittent failures then recovery', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/users', (route) => {
requestCount++;
if (requestCount <= 2) {
return route.fulfill({
status: 503,
json: { error: 'Service Unavailable' },
});
}
return route.fulfill({
json: [{ id: 1, name: 'Alice' }],
});
});
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Something went wrong')).toBeVisible();
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Alice')).toBeVisible();
});
Abort reasons: 'aborted', 'accessdenied', 'addressunreachable', 'blockedbyclient', 'blockedbyresponse', 'connectionaborted', 'connectionclosed', 'connectionfailed', 'connectionrefused', 'connectionreset', 'internetdisconnected', 'namenotresolved', 'timedout', 'failed'.
Request Waiting
Use when: Synchronizing your test with network activity -- waiting for a request to be sent or a response to arrive before asserting on the UI. Avoid when: A web-first assertion on a locator is sufficient. Only use request waiting when you need to inspect the request/response itself.
TypeScript
import { test, expect } from '@playwright/test';
test('wait for response and verify UI updates', async ({ page }) => {
await page.goto('/users');
// CRITICAL: set up the wait BEFORE triggering the action
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
const users = await response.json();
expect(users).toHaveLength(5);
});
test('wait for request and verify payload', async ({ page }) => {
await page.goto('/users/new');
const requestPromise = page.waitForRequest('**/api/users');
await page.getByLabel('Name').fill('Alice');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByRole('button', { name: 'Create' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');
expect(request.postDataJSON()).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
});
test('wait for response with predicate function', async ({ page }) => {
await page.goto('/dashboard');
// Wait for a specific response matching custom criteria
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/api/users') &&
response.status() === 200 &&
response.request().method() === 'GET'
);
await page.getByRole('button', { name: 'Load users' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.length).toBeGreaterThan(0);
});
test('wait for multiple requests in sequence', async ({ page }) => {
await page.goto('/checkout');
// Wait for multiple API calls that happen during a flow
const [validateResponse, submitResponse] = await Promise.all([
page.waitForResponse('**/api/cart/validate'),
page.waitForResponse('**/api/orders'),
page.getByRole('button', { name: 'Place order' }).click(),
]);
expect(validateResponse.status()).toBe(200);
expect(submitResponse.status()).toBe(201);
});
JavaScript
const { test, expect } = require('@playwright/test');
test('wait for response and verify UI updates', async ({ page }) => {
await page.goto('/users');
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
const users = await response.json();
expect(users).toHaveLength(5);
});
test('wait for request and verify payload', async ({ page }) => {
await page.goto('/users/new');
const requestPromise = page.waitForRequest('**/api/users');
await page.getByLabel('Name').fill('Alice');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByRole('button', { name: 'Create' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');
expect(request.postDataJSON()).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
});
test('wait for multiple requests in sequence', async ({ page }) => {
await page.goto('/checkout');
const [validateResponse, submitResponse] = await Promise.all([
page.waitForResponse('**/api/cart/validate'),
page.waitForResponse('**/api/orders'),
page.getByRole('button', { name: 'Place order' }).click(),
]);
expect(validateResponse.status()).toBe(200);
expect(submitResponse.status()).toBe(201);
});
Glob Patterns and URL Matching
Use when: You need to match URLs with wildcards, partial paths, or regex. Every page.route(), waitForRequest(), and waitForResponse() accepts glob patterns, strings, or regex.
Avoid when: The URL is known and static. Use the exact string.
TypeScript
import { test, expect } from '@playwright/test';
test('glob pattern examples', async ({ page }) => {
// ** matches any path segments (including nested)
await page.route('**/api/users', (route) =>
route.fulfill({ json: [] })
);
// Matches: https://example.com/api/users
// Matches: https://example.com/v2/api/users
// * matches any single path segment
await page.route('**/api/users/*/orders', (route) =>
route.fulfill({ json: [] })
);
// Matches: /api/users/123/orders
// Matches: /api/users/abc/orders
// Does NOT match: /api/users/123/456/orders
// Match file extensions
await page.route('**/*.{png,jpg,svg}', (route) => route.abort());
// Matches: /images/logo.png, /assets/photo.jpg
// Match query strings with *
await page.route('**/api/search?q=*', (route) =>
route.fulfill({ json: [] })
);
// Matches: /api/search?q=anything
// Use regex for complex patterns
await page.route(/\/api\/users\/\d+$/, (route) =>
route.fulfill({ json: { id: 1, name: 'Alice' } })
);
// Matches: /api/users/123, /api/users/456
// Does NOT match: /api/users/abc, /api/users/123/orders
// Regex with captured groups for dynamic responses
await page.route(/\/api\/users\/(\d+)/, (route) => {
const match = route.request().url().match(/\/api\/users\/(\d+)/);
const userId = match ? match[1] : '0';
return route.fulfill({
json: { id: parseInt(userId), name: `User ${userId}` },
});
});
await page.goto('/users');
});
test('match all requests on a domain', async ({ page }) => {
// Block everything from a specific domain
await page.route('https://analytics.example.com/**', (route) =>
route.abort()
);
await page.goto('/dashboard');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('glob pattern examples', async ({ page }) => {
// ** matches any path segments
await page.route('**/api/users', (route) =>
route.fulfill({ json: [] })
);
// * matches a single path segment
await page.route('**/api/users/*/orders', (route) =>
route.fulfill({ json: [] })
);
// Match file extensions
await page.route('**/*.{png,jpg,svg}', (route) => route.abort());
// Regex for complex patterns
await page.route(/\/api\/users\/\d+$/, (route) =>
route.fulfill({ json: { id: 1, name: 'Alice' } })
);
// Regex with dynamic responses
await page.route(/\/api\/users\/(\d+)/, (route) => {
const match = route.request().url().match(/\/api\/users\/(\d+)/);
const userId = match ? match[1] : '0';
return route.fulfill({
json: { id: parseInt(userId), name: `User ${userId}` },
});
});
await page.goto('/users');
});
test('match all requests on a domain', async ({ page }) => {
await page.route('https://analytics.example.com/**', (route) =>
route.abort()
);
await page.goto('/dashboard');
});
Pattern reference:
| Pattern | Matches | Does Not Match |
|---|---|---|
**/api/users |
/api/users, /v2/api/users |
/api/users/1 |
**/api/users* |
/api/users, /api/users?page=1 |
/api/users/1 |
**/api/users/** |
/api/users/1, /api/users/1/orders |
/api/users |
**/api/users/*/orders |
/api/users/1/orders |
/api/users/1/2/orders |
**/*.{png,jpg} |
/logo.png, /deep/path/img.jpg |
/file.svg |
/\/api\/users\/\d+$/ (regex) |
/api/users/123 |
/api/users/abc |
Decision Guide
| Scenario | Use | Why |
|---|---|---|
| Frontend depends on an external API | route.fulfill() |
Deterministic data, no external dependency, fast |
| Need to test real API but tweak one field | route.fetch() + modify + route.fulfill() |
Uses real data as baseline, only overrides what you need |
| Testing happy path with real backend | route.continue() (or no route) |
Full integration coverage |
| Block analytics/ads/third-party noise | route.abort() |
Faster tests, no flakiness from external services |
| Complex API with many endpoints | page.routeFromHAR() with update: true |
Record once, replay forever; minimal test code |
| Testing error handling (500, timeout) | route.fulfill({ status: 500 }) or route.abort('timedout') |
Simulate errors deterministically |
| Verify request payload sent by frontend | page.waitForRequest() + assertions |
Confirms frontend sends correct data |
| Verify response data before UI check | page.waitForResponse() + assertions |
Confirms data arrives before asserting on DOM |
| Multiple tests need the same mock | context.route() or fixture |
Share routes across tests without repetition |
| Testing loading spinners / skeleton UI | route.fulfill() with a delay (via setTimeout) |
Control exact timing of response |
| Paginated or search-based API | Conditional mock (check query params / body) | Dynamic responses based on request content |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Mocking your own app's pages and static assets | You end up testing a fake app, not your real one | Only mock API/data endpoints, never HTML/JS/CSS served by your app |
| Hardcoding mock data inline in every test | Duplicated data, hard to update when API changes | Extract mock data into shared fixtures (tests/data/users.json) |
| Never updating mocks when the API changes | Tests pass against stale data; real app breaks | Use HAR recording with periodic update: true runs, or validate mock shapes against OpenAPI schemas |
| Mocking in production-like E2E tests | You lose integration confidence | Keep a separate test suite with real backends for smoke/integration tests; mock only in component and isolated UI tests |
Forgetting to call route.fulfill(), route.continue(), or route.abort() |
Request hangs, test times out with a confusing error | Every route handler must call exactly one of the three |
Setting up routes after page.goto() |
Requests fire during navigation before the route is registered | Always call page.route() before page.goto() |
Using waitForResponse after the triggering action |
Race condition: response may arrive before the wait is registered | Always set up the promise before the action: const p = page.waitForResponse(...); await click(); await p; |
Mocking with route.continue() and thinking it mocks |
route.continue() passes the request to the real server |
Use route.fulfill() to return fake data |
Over-broad glob patterns (**/*) without filtering |
Catches all requests including HTML, JS, CSS; breaks the app | Be specific: **/api/** or filter by resourceType() |
Forgetting await on route setup or fulfillment |
Route may not be active when navigation starts | Always await page.route(...) and await route.fulfill(...) |
Not calling page.unroute() when swapping mocks mid-test |
Old route handler still fires, new one is ignored or both fire | Call page.unroute() before registering a new handler for the same pattern |
Using page.on('request') for mocking |
Event listeners are read-only; they cannot modify or fulfill requests | Use page.route() for interception; page.on('request') only for logging |
Troubleshooting
Route handler never fires
Cause: The URL pattern does not match the actual request URL. Common when the base URL includes a port number, path prefix, or the request uses a different protocol.
Fix:
- Log all requests to find the exact URL:
page.on('request', (req) => console.log(req.method(), req.url()));
- Ensure glob pattern matches.
**/api/usersdoes not matchhttp://localhost:3000/api/users?page=1-- use**/api/users*to include query strings. - Check that
page.route()is called beforepage.goto().
Route handler fires but test still times out
Cause: The handler throws an error or never calls fulfill/continue/abort.
Fix:
- Add error handling inside the route handler.
- Ensure every code path in the handler ends with one of the three resolution methods.
await page.route('**/api/users', async (route) => {
try {
const data = getTestData(); // this might throw
await route.fulfill({ json: data });
} catch (error) {
console.error('Route handler error:', error);
await route.abort();
}
});
Mocked response is ignored -- app shows real data
Cause: The route is registered at the page level but the request is made by a service worker or a different browser context.
Fix:
- Use
context.route()instead ofpage.route()to cover all pages and service workers. - Disable service workers in the config if they interfere:
// playwright.config.ts
export default defineConfig({
use: {
serviceWorkers: 'block',
},
});
route.fetch() causes infinite loop
Cause: route.fetch() re-issues the request, which can re-trigger the same route handler if the URL pattern matches.
Fix: Playwright handles this correctly for the same route handler -- route.fetch() will not re-enter the handler that called it. But if you have multiple overlapping route handlers, they can interfere. Simplify to a single handler per URL pattern, or use route.fetch({ url: 'different-url' }) to redirect.
HAR replay returns wrong responses
Cause: HAR files match requests by URL and sometimes by POST body. If the request body changes (e.g., timestamps, CSRF tokens), the match fails.
Fix:
- Re-record the HAR file with
update: true. - Use
notFound: 'fallback'to let unmatched requests hit the real server. - For POST requests with dynamic bodies, consider using
page.route()with manual matching instead of HAR.
Related
- core/when-to-mock.md -- decision framework for when to mock vs use real services
- core/api-testing.md -- testing REST and GraphQL APIs directly (without a browser)
- core/assertions-and-waiting.md -- web-first assertions and
waitForResponsepatterns - core/authentication.md -- mocking auth tokens and session state
- core/error-and-edge-cases.md -- error state testing patterns beyond network errors
- core/service-workers-and-pwa.md -- handling service worker caching that interferes with mocks