# 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({ 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` 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({ // 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({ 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({ 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({ // 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({ 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({ 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({ 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