mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
1330 lines
43 KiB
Markdown
1330 lines
43 KiB
Markdown
|
|
# 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](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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**
|
||
|
|
```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:
|
||
|
|
```typescript
|
||
|
|
page.on('request', (req) => console.log(req.method(), req.url()));
|
||
|
|
```
|
||
|
|
- Ensure glob pattern matches. `**/api/users` does not match `http://localhost:3000/api/users?page=1` -- use `**/api/users*` to include query strings.
|
||
|
|
- Check that `page.route()` is called before `page.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.
|
||
|
|
```typescript
|
||
|
|
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 of `page.route()` to cover all pages and service workers.
|
||
|
|
- Disable service workers in the config if they interfere:
|
||
|
|
```typescript
|
||
|
|
// 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](when-to-mock.md) -- decision framework for when to mock vs use real services
|
||
|
|
- [core/api-testing.md](api-testing.md) -- testing REST and GraphQL APIs directly (without a browser)
|
||
|
|
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- web-first assertions and `waitForResponse` patterns
|
||
|
|
- [core/authentication.md](authentication.md) -- mocking auth tokens and session state
|
||
|
|
- [core/error-and-edge-cases.md](error-and-edge-cases.md) -- error state testing patterns beyond network errors
|
||
|
|
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- handling service worker caching that interferes with mocks
|