mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
1015 lines
31 KiB
Markdown
1015 lines
31 KiB
Markdown
|
|
# Fixtures and Hooks
|
||
|
|
|
||
|
|
> **When to use**: Whenever tests need shared setup, teardown, reusable resources, or configurable context. Fixtures are Playwright's killer feature — prefer them over hooks in every situation where both could work.
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
| Mechanism | Scope | Cleanup guaranteed? | Parallelism-safe? | Use for |
|
||
|
|
|---|---|---|---|---|
|
||
|
|
| `test.extend()` fixture | per-test | Yes (via `use()` callback) | Yes | Most setup/teardown needs |
|
||
|
|
| Worker-scoped fixture | per-worker | Yes | Yes (isolated per worker) | Expensive resources: DB connections, auth state |
|
||
|
|
| Auto fixture | per-test or per-worker | Yes | Yes | Side effects that must always run: blocking analytics, logging |
|
||
|
|
| `beforeEach` / `afterEach` | per-test | No (`afterEach` skipped on crash) | Yes | Simple, one-off setup that doesn't need cleanup |
|
||
|
|
| `beforeAll` / `afterAll` | per-worker | No (`afterAll` skipped on crash) | Dangerous if mutating shared state | One-time worker setup with no cleanup needs |
|
||
|
|
|
||
|
|
**The rule**: If it needs cleanup, use a fixture. If it doesn't need cleanup and is simple, a hook is acceptable. When in doubt, use a fixture.
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### 1. Custom Test Fixture
|
||
|
|
|
||
|
|
**Use when**: Tests need a resource with guaranteed setup and teardown.
|
||
|
|
**Avoid when**: The setup is a single line with no teardown — a `beforeEach` is fine.
|
||
|
|
|
||
|
|
Fixtures use a `use()` callback. Everything before `use()` is setup; everything after is teardown. Teardown runs even if the test crashes.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
type TodoFixtures = {
|
||
|
|
todoPage: TodoPage;
|
||
|
|
};
|
||
|
|
|
||
|
|
class TodoPage {
|
||
|
|
constructor(private page: import('@playwright/test').Page) {}
|
||
|
|
|
||
|
|
async addTodo(text: string) {
|
||
|
|
await this.page.getByPlaceholder('What needs to be done?').fill(text);
|
||
|
|
await this.page.getByPlaceholder('What needs to be done?').press('Enter');
|
||
|
|
}
|
||
|
|
|
||
|
|
async todos() {
|
||
|
|
return this.page.getByTestId('todo-item');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const test = base.extend<TodoFixtures>({
|
||
|
|
todoPage: async ({ page }, use) => {
|
||
|
|
// Setup
|
||
|
|
await page.goto('/todos');
|
||
|
|
const todoPage = new TodoPage(page);
|
||
|
|
|
||
|
|
// Hand the fixture to the test
|
||
|
|
await use(todoPage);
|
||
|
|
|
||
|
|
// Teardown — runs even if test fails or crashes
|
||
|
|
await page.evaluate(() => localStorage.clear());
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// todos.spec.ts
|
||
|
|
import { test, expect } from './fixtures';
|
||
|
|
|
||
|
|
test('add a todo item', async ({ todoPage, page }) => {
|
||
|
|
await todoPage.addTodo('Buy milk');
|
||
|
|
await expect(await todoPage.todos()).toHaveCount(1);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
class TodoPage {
|
||
|
|
constructor(page) {
|
||
|
|
this.page = page;
|
||
|
|
}
|
||
|
|
|
||
|
|
async addTodo(text) {
|
||
|
|
await this.page.getByPlaceholder('What needs to be done?').fill(text);
|
||
|
|
await this.page.getByPlaceholder('What needs to be done?').press('Enter');
|
||
|
|
}
|
||
|
|
|
||
|
|
async todos() {
|
||
|
|
return this.page.getByTestId('todo-item');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
todoPage: async ({ page }, use) => {
|
||
|
|
await page.goto('/todos');
|
||
|
|
const todoPage = new TodoPage(page);
|
||
|
|
await use(todoPage);
|
||
|
|
await page.evaluate(() => localStorage.clear());
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Worker-Scoped Fixtures
|
||
|
|
|
||
|
|
**Use when**: A resource is expensive to create and safe to share across tests in the same worker (database connections, auth tokens, compiled assets).
|
||
|
|
**Avoid when**: Tests mutate the resource — each test must get its own copy.
|
||
|
|
|
||
|
|
Worker-scoped fixtures are created once per worker process, not once per test. They cannot depend on test-scoped fixtures (`page`, `context`, `request`).
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base } from '@playwright/test';
|
||
|
|
|
||
|
|
type WorkerFixtures = {
|
||
|
|
dbConnection: DatabaseClient;
|
||
|
|
authToken: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<{}, WorkerFixtures>({
|
||
|
|
dbConnection: [async ({}, use) => {
|
||
|
|
const db = await DatabaseClient.connect(process.env.DB_URL!);
|
||
|
|
await use(db);
|
||
|
|
await db.disconnect();
|
||
|
|
}, { scope: 'worker' }],
|
||
|
|
|
||
|
|
authToken: [async ({}, use) => {
|
||
|
|
const response = await fetch(`${process.env.API_URL}/auth/token`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
username: 'test-user',
|
||
|
|
password: process.env.TEST_PASSWORD,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const { token } = await response.json();
|
||
|
|
await use(token);
|
||
|
|
// No teardown needed — token expires on its own
|
||
|
|
}, { scope: 'worker' }],
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect } from '@playwright/test';
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
dbConnection: [async ({}, use) => {
|
||
|
|
const db = await DatabaseClient.connect(process.env.DB_URL);
|
||
|
|
await use(db);
|
||
|
|
await db.disconnect();
|
||
|
|
}, { scope: 'worker' }],
|
||
|
|
|
||
|
|
authToken: [async ({}, use) => {
|
||
|
|
const response = await fetch(`${process.env.API_URL}/auth/token`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
username: 'test-user',
|
||
|
|
password: process.env.TEST_PASSWORD,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const { token } = await response.json();
|
||
|
|
await use(token);
|
||
|
|
}, { scope: 'worker' }],
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
**Important**: The second type parameter in `base.extend<TestFixtures, WorkerFixtures>` is for worker-scoped fixtures. Always put worker fixtures in the second generic argument.
|
||
|
|
|
||
|
|
### 3. Auto Fixtures
|
||
|
|
|
||
|
|
**Use when**: Something must run for every test without being explicitly requested — blocking analytics, capturing console errors, injecting feature flags.
|
||
|
|
**Avoid when**: Tests need to opt out. Auto fixtures always run; there is no per-test escape hatch.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
type AutoFixtures = {
|
||
|
|
blockAnalytics: void;
|
||
|
|
consoleErrors: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<AutoFixtures>({
|
||
|
|
// Blocks all analytics/tracking requests in every test
|
||
|
|
blockAnalytics: [async ({ page }, use) => {
|
||
|
|
await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) =>
|
||
|
|
route.abort()
|
||
|
|
);
|
||
|
|
await use();
|
||
|
|
}, { auto: true }],
|
||
|
|
|
||
|
|
// Captures console errors — fail test if unexpected errors appear
|
||
|
|
consoleErrors: [async ({ page }, use) => {
|
||
|
|
const errors: string[] = [];
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
if (msg.type() === 'error') {
|
||
|
|
errors.push(msg.text());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await use(errors);
|
||
|
|
// Teardown: assert no unexpected console errors
|
||
|
|
const unexpected = errors.filter(
|
||
|
|
(e) => !e.includes('Expected warning')
|
||
|
|
);
|
||
|
|
if (unexpected.length > 0) {
|
||
|
|
throw new Error(
|
||
|
|
`Unexpected console errors:\n${unexpected.join('\n')}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}, { auto: true }],
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
blockAnalytics: [async ({ page }, use) => {
|
||
|
|
await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) =>
|
||
|
|
route.abort()
|
||
|
|
);
|
||
|
|
await use();
|
||
|
|
}, { auto: true }],
|
||
|
|
|
||
|
|
consoleErrors: [async ({ page }, use) => {
|
||
|
|
const errors = [];
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
if (msg.type() === 'error') {
|
||
|
|
errors.push(msg.text());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await use(errors);
|
||
|
|
const unexpected = errors.filter(
|
||
|
|
(e) => !e.includes('Expected warning')
|
||
|
|
);
|
||
|
|
if (unexpected.length > 0) {
|
||
|
|
throw new Error(
|
||
|
|
`Unexpected console errors:\n${unexpected.join('\n')}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}, { auto: true }],
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
Auto fixtures can also be worker-scoped with `{ auto: true, scope: 'worker' }` for things like starting a dev server once per worker.
|
||
|
|
|
||
|
|
### 4. Fixture Composition with `mergeTests()`
|
||
|
|
|
||
|
|
**Use when**: You have multiple fixture files (auth fixtures, API fixtures, UI fixtures) and tests need several of them.
|
||
|
|
**Avoid when**: You only have one fixture file. Don't over-engineer.
|
||
|
|
|
||
|
|
`mergeTests()` combines multiple extended `test` objects into one. Each fixture file defines its own concerns.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures/auth.ts
|
||
|
|
import { test as base } from '@playwright/test';
|
||
|
|
|
||
|
|
type AuthFixtures = {
|
||
|
|
authenticatedPage: import('@playwright/test').Page;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<AuthFixtures>({
|
||
|
|
authenticatedPage: async ({ page }, use) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('user@example.com');
|
||
|
|
await page.getByLabel('Password').fill('password123');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// fixtures/api.ts
|
||
|
|
import { test as base, APIRequestContext } from '@playwright/test';
|
||
|
|
|
||
|
|
type ApiFixtures = {
|
||
|
|
apiClient: APIRequestContext;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<ApiFixtures>({
|
||
|
|
apiClient: async ({ playwright }, use) => {
|
||
|
|
const api = await playwright.request.newContext({
|
||
|
|
baseURL: 'https://api.example.com',
|
||
|
|
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
|
||
|
|
});
|
||
|
|
await use(api);
|
||
|
|
await api.dispose();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// fixtures/index.ts
|
||
|
|
import { mergeTests } from '@playwright/test';
|
||
|
|
import { test as authTest } from './auth';
|
||
|
|
import { test as apiTest } from './api';
|
||
|
|
|
||
|
|
export const test = mergeTests(authTest, apiTest);
|
||
|
|
export { expect } from '@playwright/test';
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// dashboard.spec.ts
|
||
|
|
import { test, expect } from './fixtures';
|
||
|
|
|
||
|
|
test('dashboard loads user data', async ({ authenticatedPage, apiClient }) => {
|
||
|
|
// Both fixtures are available
|
||
|
|
const data = await apiClient.get('/users/me');
|
||
|
|
await expect(authenticatedPage.getByRole('heading')).toContainText('Dashboard');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures/auth.js
|
||
|
|
const { test: base } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
authenticatedPage: async ({ page }, use) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('user@example.com');
|
||
|
|
await page.getByLabel('Password').fill('password123');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test };
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// fixtures/api.js
|
||
|
|
const { test: base } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
apiClient: async ({ playwright }, use) => {
|
||
|
|
const api = await playwright.request.newContext({
|
||
|
|
baseURL: 'https://api.example.com',
|
||
|
|
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
|
||
|
|
});
|
||
|
|
await use(api);
|
||
|
|
await api.dispose();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test };
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// fixtures/index.js
|
||
|
|
const { mergeTests } = require('@playwright/test');
|
||
|
|
const { test: authTest } = require('./auth');
|
||
|
|
const { test: apiTest } = require('./api');
|
||
|
|
|
||
|
|
const test = mergeTests(authTest, apiTest);
|
||
|
|
module.exports = { test, expect: require('@playwright/test').expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Parameterized Fixtures (Option Fixtures)
|
||
|
|
|
||
|
|
**Use when**: A fixture's behavior should be configurable per-project or per-describe block (locale, viewport, user role, feature flags).
|
||
|
|
**Avoid when**: The value never changes — just hardcode it in the fixture.
|
||
|
|
|
||
|
|
Option fixtures are declared with `{ option: true }` and can be overridden in `playwright.config` under `use` or with `test.use()` in test files.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
type OptionFixtures = {
|
||
|
|
userRole: 'admin' | 'editor' | 'viewer';
|
||
|
|
locale: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
type DerivedFixtures = {
|
||
|
|
authenticatedPage: import('@playwright/test').Page;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<OptionFixtures & DerivedFixtures>({
|
||
|
|
// Option fixtures — configurable per project or describe block
|
||
|
|
userRole: ['viewer', { option: true }],
|
||
|
|
locale: ['en-US', { option: true }],
|
||
|
|
|
||
|
|
// Derived fixture — uses the option values
|
||
|
|
authenticatedPage: async ({ page, userRole, locale }, use) => {
|
||
|
|
await page.goto(`/login?locale=${locale}`);
|
||
|
|
const credentials = {
|
||
|
|
admin: { email: 'admin@test.com', password: 'admin-pass' },
|
||
|
|
editor: { email: 'editor@test.com', password: 'editor-pass' },
|
||
|
|
viewer: { email: 'viewer@test.com', password: 'viewer-pass' },
|
||
|
|
};
|
||
|
|
const { email, password } = credentials[userRole];
|
||
|
|
await page.getByLabel('Email').fill(email);
|
||
|
|
await page.getByLabel('Password').fill(password);
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// playwright.config.ts (override per project)
|
||
|
|
import { defineConfig } from '@playwright/test';
|
||
|
|
|
||
|
|
export default defineConfig({
|
||
|
|
projects: [
|
||
|
|
{
|
||
|
|
name: 'admin-tests',
|
||
|
|
testDir: './tests/admin',
|
||
|
|
use: { userRole: 'admin', locale: 'en-US' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'viewer-tests',
|
||
|
|
testDir: './tests/viewer',
|
||
|
|
use: { userRole: 'viewer', locale: 'fr-FR' },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// admin-settings.spec.ts (override per describe block)
|
||
|
|
import { test, expect } from './fixtures';
|
||
|
|
|
||
|
|
test.describe('admin settings', () => {
|
||
|
|
test.use({ userRole: 'admin' });
|
||
|
|
|
||
|
|
test('can access settings page', async ({ authenticatedPage }) => {
|
||
|
|
await authenticatedPage.goto('/settings');
|
||
|
|
await expect(authenticatedPage.getByRole('heading')).toHaveText('Admin Settings');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
userRole: ['viewer', { option: true }],
|
||
|
|
locale: ['en-US', { option: true }],
|
||
|
|
|
||
|
|
authenticatedPage: async ({ page, userRole, locale }, use) => {
|
||
|
|
await page.goto(`/login?locale=${locale}`);
|
||
|
|
const credentials = {
|
||
|
|
admin: { email: 'admin@test.com', password: 'admin-pass' },
|
||
|
|
editor: { email: 'editor@test.com', password: 'editor-pass' },
|
||
|
|
viewer: { email: 'viewer@test.com', password: 'viewer-pass' },
|
||
|
|
};
|
||
|
|
const { email, password } = credentials[userRole];
|
||
|
|
await page.getByLabel('Email').fill(email);
|
||
|
|
await page.getByLabel('Password').fill(password);
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6. Fixture Dependencies
|
||
|
|
|
||
|
|
**Use when**: One fixture needs the output of another fixture. Playwright automatically resolves the dependency graph.
|
||
|
|
**Avoid when**: You are tempted to nest fixtures more than 3 levels deep — flatten the design instead.
|
||
|
|
|
||
|
|
Request fixtures by name in the destructured first argument. Playwright handles ordering and lifecycle.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
type Fixtures = {
|
||
|
|
apiContext: import('@playwright/test').APIRequestContext;
|
||
|
|
testUser: { id: string; email: string };
|
||
|
|
userPage: import('@playwright/test').Page;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const test = base.extend<Fixtures>({
|
||
|
|
apiContext: async ({ playwright }, use) => {
|
||
|
|
const ctx = await playwright.request.newContext({
|
||
|
|
baseURL: process.env.API_URL,
|
||
|
|
});
|
||
|
|
await use(ctx);
|
||
|
|
await ctx.dispose();
|
||
|
|
},
|
||
|
|
|
||
|
|
// Depends on apiContext — Playwright creates apiContext first
|
||
|
|
testUser: async ({ apiContext }, use) => {
|
||
|
|
const response = await apiContext.post('/test/users', {
|
||
|
|
data: { email: `user-${Date.now()}@test.com`, role: 'editor' },
|
||
|
|
});
|
||
|
|
const user = await response.json();
|
||
|
|
await use(user);
|
||
|
|
// Teardown: delete the test user
|
||
|
|
await apiContext.delete(`/test/users/${user.id}`);
|
||
|
|
},
|
||
|
|
|
||
|
|
// Depends on page AND testUser — both are ready before this runs
|
||
|
|
userPage: async ({ page, testUser }, use) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill(testUser.email);
|
||
|
|
await page.getByLabel('Password').fill('default-test-password');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
apiContext: async ({ playwright }, use) => {
|
||
|
|
const ctx = await playwright.request.newContext({
|
||
|
|
baseURL: process.env.API_URL,
|
||
|
|
});
|
||
|
|
await use(ctx);
|
||
|
|
await ctx.dispose();
|
||
|
|
},
|
||
|
|
|
||
|
|
testUser: async ({ apiContext }, use) => {
|
||
|
|
const response = await apiContext.post('/test/users', {
|
||
|
|
data: { email: `user-${Date.now()}@test.com`, role: 'editor' },
|
||
|
|
});
|
||
|
|
const user = await response.json();
|
||
|
|
await use(user);
|
||
|
|
await apiContext.delete(`/test/users/${user.id}`);
|
||
|
|
},
|
||
|
|
|
||
|
|
userPage: async ({ page, testUser }, use) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill(testUser.email);
|
||
|
|
await page.getByLabel('Password').fill('default-test-password');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
Teardown runs in reverse dependency order: `userPage` tears down first, then `testUser` (which deletes the user), then `apiContext`.
|
||
|
|
|
||
|
|
### 7. Overriding Built-in Fixtures
|
||
|
|
|
||
|
|
**Use when**: Every test needs the same modification to `page`, `context`, or `browser` — custom headers, viewport, locale, route blocking.
|
||
|
|
**Avoid when**: Only some tests need the override. Use a new named fixture instead to keep the default `page` available.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
export const test = base.extend({
|
||
|
|
// Override the built-in context fixture to add custom headers
|
||
|
|
context: async ({ browser }, use) => {
|
||
|
|
const context = await browser.newContext({
|
||
|
|
extraHTTPHeaders: {
|
||
|
|
'X-Test-ID': `test-${Date.now()}`,
|
||
|
|
'Accept-Language': 'en-US',
|
||
|
|
},
|
||
|
|
permissions: ['geolocation'],
|
||
|
|
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
||
|
|
});
|
||
|
|
await use(context);
|
||
|
|
await context.close();
|
||
|
|
},
|
||
|
|
|
||
|
|
// Override the built-in page fixture to block third-party scripts
|
||
|
|
page: async ({ context }, use) => {
|
||
|
|
const page = await context.newPage();
|
||
|
|
await page.route('**/*.{png,jpg,jpeg,gif,svg}', (route) => route.abort());
|
||
|
|
await use(page);
|
||
|
|
// No need to close page — context.close() handles it
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// fixtures.js
|
||
|
|
const { test: base, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const test = base.extend({
|
||
|
|
context: async ({ browser }, use) => {
|
||
|
|
const context = await browser.newContext({
|
||
|
|
extraHTTPHeaders: {
|
||
|
|
'X-Test-ID': `test-${Date.now()}`,
|
||
|
|
'Accept-Language': 'en-US',
|
||
|
|
},
|
||
|
|
permissions: ['geolocation'],
|
||
|
|
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
||
|
|
});
|
||
|
|
await use(context);
|
||
|
|
await context.close();
|
||
|
|
},
|
||
|
|
|
||
|
|
page: async ({ context }, use) => {
|
||
|
|
const page = await context.newPage();
|
||
|
|
await page.route('**/*.{png,jpg,jpeg,gif,svg}', (route) => route.abort());
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = { test, expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
### 8. beforeEach / afterEach — When Hooks Are Acceptable
|
||
|
|
|
||
|
|
**Use when**: Simple, stateless setup with no teardown needed. Navigation to a starting URL is the canonical example.
|
||
|
|
**Avoid when**: The setup creates something that must be cleaned up. Use a fixture instead.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// acceptable-hooks.spec.ts
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
// Acceptable: simple navigation, no teardown needed
|
||
|
|
test.beforeEach(async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shows welcome message', async ({ page }) => {
|
||
|
|
await expect(page.getByRole('heading')).toHaveText('Welcome');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shows navigation sidebar', async ({ page }) => {
|
||
|
|
await expect(page.getByRole('navigation')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// acceptable-hooks.spec.js
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.beforeEach(async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shows welcome message', async ({ page }) => {
|
||
|
|
await expect(page.getByRole('heading')).toHaveText('Welcome');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shows navigation sidebar', async ({ page }) => {
|
||
|
|
await expect(page.getByRole('navigation')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Hooks can use fixtures in their argument list — `beforeEach(async ({ page, context })` works. But hooks cannot *define* fixtures.
|
||
|
|
|
||
|
|
### 9. beforeAll / afterAll — Worker-Level Hooks
|
||
|
|
|
||
|
|
**Use when**: One-time setup that all tests in a file share and that has no cleanup needs, such as logging a diagnostic or checking a precondition.
|
||
|
|
**Avoid when**: You are creating resources that need cleanup (use worker-scoped fixtures) or storing mutable state (this breaks parallelism).
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// health-check.spec.ts
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
// Good: read-only check, no mutable state
|
||
|
|
test.beforeAll(async ({ request }) => {
|
||
|
|
const response = await request.get('/api/health');
|
||
|
|
expect(response.ok()).toBeTruthy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('homepage loads', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
await expect(page).toHaveTitle(/My App/);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// health-check.spec.js
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.beforeAll(async ({ request }) => {
|
||
|
|
const response = await request.get('/api/health');
|
||
|
|
expect(response.ok()).toBeTruthy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('homepage loads', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
await expect(page).toHaveTitle(/My App/);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
`beforeAll` / `afterAll` receive only worker-scoped fixtures: `request`, `browser`, and any custom worker fixtures. They do **not** receive `page` or `context` (those are per-test).
|
||
|
|
|
||
|
|
### 10. Typed Fixtures in TypeScript
|
||
|
|
|
||
|
|
Always define an interface for your fixtures. This gives you autocomplete, catches typos at compile time, and documents the fixture contract.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// fixtures.ts
|
||
|
|
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||
|
|
|
||
|
|
// Define fixture types explicitly
|
||
|
|
interface TestFixtures {
|
||
|
|
adminPage: Page;
|
||
|
|
editorPage: Page;
|
||
|
|
apiClient: APIRequestContext;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface WorkerFixtures {
|
||
|
|
sharedToken: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||
|
|
sharedToken: [async ({}, use) => {
|
||
|
|
const res = await fetch(`${process.env.API_URL}/auth/service-token`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ secret: process.env.SERVICE_SECRET }),
|
||
|
|
});
|
||
|
|
const { token } = await res.json();
|
||
|
|
await use(token);
|
||
|
|
}, { scope: 'worker' }],
|
||
|
|
|
||
|
|
apiClient: async ({ playwright, sharedToken }, use) => {
|
||
|
|
const ctx = await playwright.request.newContext({
|
||
|
|
baseURL: process.env.API_URL,
|
||
|
|
extraHTTPHeaders: { Authorization: `Bearer ${sharedToken}` },
|
||
|
|
});
|
||
|
|
await use(ctx);
|
||
|
|
await ctx.dispose();
|
||
|
|
},
|
||
|
|
|
||
|
|
adminPage: async ({ browser }, use) => {
|
||
|
|
const ctx = await browser.newContext({ storageState: 'auth/admin.json' });
|
||
|
|
const page = await ctx.newPage();
|
||
|
|
await use(page);
|
||
|
|
await ctx.close();
|
||
|
|
},
|
||
|
|
|
||
|
|
editorPage: async ({ browser }, use) => {
|
||
|
|
const ctx = await browser.newContext({ storageState: 'auth/editor.json' });
|
||
|
|
const page = await ctx.newPage();
|
||
|
|
await use(page);
|
||
|
|
await ctx.close();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export { expect };
|
||
|
|
```
|
||
|
|
|
||
|
|
## Decision Guide
|
||
|
|
|
||
|
|
```
|
||
|
|
I need to set up something before tests run.
|
||
|
|
│
|
||
|
|
├── Does it create a resource that MUST be cleaned up?
|
||
|
|
│ │
|
||
|
|
│ ├── YES → Use a fixture (setup before `use()`, teardown after)
|
||
|
|
│ │ │
|
||
|
|
│ │ ├── Is the resource expensive and safe to share across tests?
|
||
|
|
│ │ │ ├── YES → Worker-scoped fixture: { scope: 'worker' }
|
||
|
|
│ │ │ └── NO → Test-scoped fixture (default)
|
||
|
|
│ │ │
|
||
|
|
│ │ ├── Should every test get this automatically?
|
||
|
|
│ │ │ ├── YES → Auto fixture: { auto: true }
|
||
|
|
│ │ │ └── NO → Regular fixture (test declares it in args)
|
||
|
|
│ │ │
|
||
|
|
│ │ └── Should the value be configurable per project?
|
||
|
|
│ │ ├── YES → Option fixture: { option: true }
|
||
|
|
│ │ └── NO → Regular fixture with hardcoded setup
|
||
|
|
│ │
|
||
|
|
│ └── NO → Hook is acceptable
|
||
|
|
│ │
|
||
|
|
│ ├── Per-test setup?
|
||
|
|
│ │ └── beforeEach
|
||
|
|
│ │
|
||
|
|
│ └── One-time per worker?
|
||
|
|
│ └── beforeAll (but only for read-only / diagnostic checks)
|
||
|
|
│
|
||
|
|
└── Am I combining fixtures from multiple domains?
|
||
|
|
└── YES → mergeTests() from separate fixture files
|
||
|
|
```
|
||
|
|
|
||
|
|
## Anti-Patterns
|
||
|
|
|
||
|
|
### 1. Global Mutable State in `beforeAll`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD: Mutable state shared across parallel tests
|
||
|
|
let testUser: { id: string; email: string };
|
||
|
|
|
||
|
|
test.beforeAll(async ({ request }) => {
|
||
|
|
const res = await request.post('/api/users', { data: { email: 'shared@test.com' } });
|
||
|
|
testUser = await res.json(); // Shared mutable state!
|
||
|
|
});
|
||
|
|
|
||
|
|
test.afterAll(async ({ request }) => {
|
||
|
|
await request.delete(`/api/users/${testUser.id}`); // Cleanup may not run
|
||
|
|
});
|
||
|
|
|
||
|
|
test('test 1', async ({ page }) => {
|
||
|
|
// Uses testUser — but what if another worker also has a testUser?
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GOOD: Worker-scoped fixture — isolated per worker, guaranteed cleanup
|
||
|
|
import { test as base } from '@playwright/test';
|
||
|
|
|
||
|
|
export const test = base.extend<{ testUser: { id: string; email: string } }>({
|
||
|
|
testUser: async ({ request }, use) => {
|
||
|
|
const res = await request.post('/api/users', {
|
||
|
|
data: { email: `user-${Date.now()}@test.com` },
|
||
|
|
});
|
||
|
|
const user = await res.json();
|
||
|
|
await use(user);
|
||
|
|
await request.delete(`/api/users/${user.id}`);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Cleanup in `afterEach` Instead of Fixture Teardown
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD: afterEach is not guaranteed to run on crash
|
||
|
|
test.beforeEach(async ({ page }) => {
|
||
|
|
await page.evaluate(() => localStorage.setItem('debug', 'true'));
|
||
|
|
});
|
||
|
|
|
||
|
|
test.afterEach(async ({ page }) => {
|
||
|
|
// This might NOT run if the test crashes or times out
|
||
|
|
await page.evaluate(() => localStorage.clear());
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GOOD: Fixture teardown always runs
|
||
|
|
export const test = base.extend<{ debugMode: void }>({
|
||
|
|
debugMode: async ({ page }, use) => {
|
||
|
|
await page.evaluate(() => localStorage.setItem('debug', 'true'));
|
||
|
|
await use();
|
||
|
|
await page.evaluate(() => localStorage.clear()); // Guaranteed
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Fixtures That Do Too Many Things
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD: One fixture doing setup for unrelated concerns
|
||
|
|
export const test = base.extend({
|
||
|
|
everything: async ({ page }, use) => {
|
||
|
|
// Logs in
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page.getByLabel('Password').fill('password');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
// Creates test data
|
||
|
|
await page.request.post('/api/products', { data: { name: 'Widget' } });
|
||
|
|
// Blocks analytics
|
||
|
|
await page.route(/analytics/, (r) => r.abort());
|
||
|
|
// Sets locale
|
||
|
|
await page.evaluate(() => localStorage.setItem('locale', 'en'));
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GOOD: Separate fixtures, each with one responsibility
|
||
|
|
export const test = base.extend({
|
||
|
|
authenticatedPage: async ({ page }, use) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('user@test.com');
|
||
|
|
await page.getByLabel('Password').fill('password');
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
|
||
|
|
testProduct: async ({ request }, use) => {
|
||
|
|
const res = await request.post('/api/products', { data: { name: 'Widget' } });
|
||
|
|
const product = await res.json();
|
||
|
|
await use(product);
|
||
|
|
await request.delete(`/api/products/${product.id}`);
|
||
|
|
},
|
||
|
|
|
||
|
|
blockAnalytics: [async ({ page }, use) => {
|
||
|
|
await page.route(/analytics/, (r) => r.abort());
|
||
|
|
await use();
|
||
|
|
}, { auto: true }],
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Over-Abstracting Fixtures
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD: Fixture factory with layers of indirection nobody can follow
|
||
|
|
const createFixture = (role, permissions, options) =>
|
||
|
|
base.extend({
|
||
|
|
[`${role}Page`]: async ({ page }, use) => {
|
||
|
|
await setupRole(page, role, permissions, options);
|
||
|
|
await use(page);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const adminTest = createFixture('admin', ['read', 'write', 'delete'], { mfa: true });
|
||
|
|
const editorTest = createFixture('editor', ['read', 'write'], { mfa: false });
|
||
|
|
// Good luck debugging which fixture ran
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GOOD: Explicit fixtures — boring but readable
|
||
|
|
export const test = base.extend({
|
||
|
|
adminPage: async ({ browser }, use) => {
|
||
|
|
const ctx = await browser.newContext({ storageState: 'auth/admin.json' });
|
||
|
|
const page = await ctx.newPage();
|
||
|
|
await use(page);
|
||
|
|
await ctx.close();
|
||
|
|
},
|
||
|
|
|
||
|
|
editorPage: async ({ browser }, use) => {
|
||
|
|
const ctx = await browser.newContext({ storageState: 'auth/editor.json' });
|
||
|
|
const page = await ctx.newPage();
|
||
|
|
await use(page);
|
||
|
|
await ctx.close();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Not Typing Fixtures in TypeScript
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// BAD: No type parameter — no autocomplete, no compile-time errors
|
||
|
|
export const test = base.extend({
|
||
|
|
myFixture: async ({ page }, use) => {
|
||
|
|
await use({ count: 42 });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
// test('example', async ({ myFixture }) => {
|
||
|
|
// myFixture.cont // No autocomplete, no type safety
|
||
|
|
// });
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// GOOD: Explicit type interface
|
||
|
|
interface MyFixtures {
|
||
|
|
myFixture: { count: number };
|
||
|
|
}
|
||
|
|
|
||
|
|
export const test = base.extend<MyFixtures>({
|
||
|
|
myFixture: async ({ page }, use) => {
|
||
|
|
await use({ count: 42 });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
// test('example', async ({ myFixture }) => {
|
||
|
|
// myFixture.count // Autocomplete works, typos caught at compile time
|
||
|
|
// });
|
||
|
|
```
|
||
|
|
|
||
|
|
## Troubleshooting
|
||
|
|
|
||
|
|
| Symptom | Cause | Fix |
|
||
|
|
|---|---|---|
|
||
|
|
| `Cannot use a test-scoped fixture in a worker-scoped fixture` | Worker fixture depends on `page`, `context`, or test-scoped custom fixture | Worker fixtures can only depend on other worker fixtures or built-in worker fixtures (`browser`, `playwright`) |
|
||
|
|
| `Fixture "X" has already been registered` | Two fixture files define the same fixture name and are both extended | Use `mergeTests()` instead of chaining `.extend()` calls, or rename one fixture |
|
||
|
|
| Fixture teardown not running | You used `afterEach` instead of the `use()` callback pattern | Move cleanup code after the `await use()` call inside the fixture |
|
||
|
|
| `beforeAll` can't access `page` | `page` is test-scoped; `beforeAll` only gets worker-scoped fixtures | Use a worker-scoped fixture, or move logic to `beforeEach` |
|
||
|
|
| Test hangs inside fixture | `await use()` was never called — Playwright waits indefinitely for the fixture to yield | Ensure every fixture code path calls `await use(value)` exactly once |
|
||
|
|
| Fixture runs but test doesn't see the value | Fixture not declared in the test's destructured arguments | Add the fixture name to the test function signature: `test('name', async ({ myFixture }) => { ... })` |
|
||
|
|
| Option fixture value ignored | `test.use()` called inside `test()` instead of `test.describe()` | `test.use()` must be at the top level of a `describe` block or file, not inside a test |
|
||
|
|
| Auto fixture not running | Fixture file not imported — the custom `test` is not used | Import and use the `test` from your fixture file, not from `@playwright/test` |
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [pom/page-object-model.md](../pom/page-object-model.md) — Page objects are typically consumed via fixtures
|
||
|
|
- [core/test-organization.md](test-organization.md) — Where to put fixture files in the project tree
|
||
|
|
- [core/configuration.md](configuration.md) — Option fixtures are configured in `playwright.config`
|
||
|
|
- [core/authentication.md](authentication.md) — Auth state is the most common worker-scoped fixture
|
||
|
|
- [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) — For truly global (all-workers) setup, not per-worker
|
||
|
|
- [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md) — When to use each abstraction
|