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.
| 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.
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
// 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 };// 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
// 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 };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
// 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
// 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.
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
// 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
// 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.
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
// 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);
},
});// 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();
},
});// 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';// 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
// 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 };// 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 };// 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 };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
// 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 };// 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' },
},
],
});// 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
// 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 };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
// 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
// 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.
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
// 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
// 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 };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
// 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
// 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.
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
// 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
// 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).
Always define an interface for your fixtures. This gives you autocomplete, catches typos at compile time, and documents the fixture contract.
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 };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
// 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?
});// 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}`);
},
});// 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());
});// 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
},
});// 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);
},
});// 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 }],
});// 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// 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();
},
});// 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
// });// 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
// });| 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 |
- pom/page-object-model.md — Page objects are typically consumed via fixtures
- core/test-organization.md — Where to put fixture files in the project tree
- core/configuration.md — Option fixtures are configured in
playwright.config - core/authentication.md — Auth state is the most common worker-scoped fixture
- ci/global-setup-teardown.md — For truly global (all-workers) setup, not per-worker
- pom/pom-vs-fixtures-vs-helpers.md — When to use each abstraction