Hooks, Fixtures, and Test Isolation
Group tests with describe, share setup with beforeEach, then replace both with custom fixtures that build a page object and clean up after themselves.
Each Playwright test gets its own browser context, which means its own cookies and its own localStorage. Tests cannot leak into each other, and you never write a cleanup step for browser state. What you do write is the setup that gets a test to its starting point.
Grouping and beforeEach
import { test, expect } from '@playwright/test';
test.describe('todo list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
});
test('starts empty', async ({ page }) => {
await expect(page.getByTestId('todo-title')).toHaveCount(0);
});
test('accepts a new item', async ({ page }) => {
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
await expect(page.getByTestId('todo-title')).toHaveText(['buy milk']);
});
});
Running 2 tests using 2 workers
✓ 1 [chromium] › tests/todo.spec.ts:8:3 › todo list › starts empty (952ms)
✓ 2 [chromium] › tests/todo.spec.ts:12:3 › todo list › accepts a new item (1.2s)
2 passed (2.1s)
The describe title is prefixed to each test name, which is what makes a report readable at 200 tests. Note that “starts empty” passed after the other test added an item — different contexts, different localStorage.
The four hooks:
test.beforeAll(async () => { /* once per worker, before the first test in the file */ });
test.beforeEach(async ({ page }) => { /* before every test */ });
test.afterEach(async ({ page }, testInfo) => { /* after every test, pass or fail */ });
test.afterAll(async () => { /* after the last test in the file */ });
afterEach receives testInfo, so it can react to the result:
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
console.log(`failed at ${page.url()}`);
}
});
✘ 1 [chromium] › tests/todo.spec.ts:12:3 › todo list › accepts a new item (5.4s)
failed at https://demo.playwright.dev/todomvc/#/
1 failed
The built-in fixtures
Destructure what you need from the test’s first argument; the runner builds only those.
| Fixture | Scope | What it is |
|---|---|---|
page | test | a fresh page in a fresh context |
context | test | the BrowserContext, for a second tab or cookie work |
browser | worker | the shared browser instance |
request | test | an API request context that shares cookies with the browser |
browserName | worker | 'chromium', 'firefox' or 'webkit' |
test('two tabs share a session, two contexts do not', async ({ context }) => {
const first = await context.newPage();
await first.goto('https://demo.playwright.dev/todomvc');
await first.evaluate(() => localStorage.setItem('seen-tour', 'yes'));
const second = await context.newPage();
await second.goto('https://demo.playwright.dev/todomvc');
expect(await second.evaluate(() => localStorage.getItem('seen-tour'))).toBe('yes');
});
✓ 1 [chromium] › tests/context.spec.ts:3:1 › two tabs share a session, two contexts do not (1.4s)
1 passed (2.1s)
Changing options for one block
test.use overrides config options for a file or a describe block:
test.describe('mobile layout', () => {
test.use({ viewport: { width: 390, height: 844 }, locale: 'de-DE' });
test('nav collapses on a narrow screen', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
expect(page.viewportSize()).toEqual({ width: 390, height: 844 });
});
});
✓ 1 [chromium] › tests/mobile.spec.ts:5:3 › mobile layout › nav collapses on a narrow screen (1.0s)
1 passed (1.7s)
Anything under use in the config can be overridden this way: baseURL, colorScheme,
timezoneId, permissions, storageState.
Writing your own fixture
A fixture is a function that builds a value, yields it to the test, and cleans up after. Here is one that returns a ready-to-use page object:
// tests/fixtures.ts
import { test as base, expect, type Page } from '@playwright/test';
class TodoPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc');
}
async add(...items: string[]) {
const input = this.page.getByPlaceholder('What needs to be done?');
for (const item of items) {
await input.fill(item);
await input.press('Enter');
}
}
row(text: string) {
return this.page.getByRole('listitem').filter({ hasText: text });
}
get titles() {
return this.page.getByTestId('todo-title');
}
}
export const test = base.extend<{ todos: TodoPage }>({
todos: async ({ page }, use) => {
const todos = new TodoPage(page);
await todos.goto();
await use(todos); // the test runs here
await page.evaluate(() => localStorage.clear()); // teardown
},
});
export { expect };
// tests/todo-object.spec.ts
import { test, expect } from './fixtures';
test('completing an item moves it out of Active', async ({ todos, page }) => {
await todos.add('buy milk', 'walk the dog');
await todos.row('buy milk').getByRole('checkbox').check();
await page.getByRole('link', { name: 'Active' }).click();
await expect(todos.titles).toHaveText(['walk the dog']);
});
Running 1 test using 1 worker
✓ 1 [chromium] › tests/todo-object.spec.ts:3:1 › completing an item moves it out of Active (1.3s)
1 passed (2.0s)
Three things a beforeEach cannot do as cleanly. The fixture returns a value, so the
test gets todos rather than reaching for module-level state. It runs teardown after
use(), even when the test fails. And it is lazy — a test that does not name todos in
its arguments never navigates, so it costs nothing.
Worker-scoped fixtures
Expensive, read-only setup belongs once per worker rather than once per test:
export const test = base.extend<{}, { seedData: { userId: string } }>({
seedData: [
async ({}, use) => {
const userId = await createTestUser(); // slow: one API call
await use({ userId });
await deleteTestUser(userId); // after the worker's last test
},
{ scope: 'worker' },
],
});
Running 12 tests using 4 workers
✓ 1 [chromium] › tests/orders.spec.ts:5:1 › lists orders (1.1s)
✓ 2 [chromium] › tests/orders.spec.ts:9:1 › filters by status (1.0s)
…
12 passed (6.8s)
Four workers means createTestUser ran four times, not twelve. The trade is real: anything
worker-scoped is shared, so if one test mutates it the next test in that worker inherits the
mutation. Keep worker fixtures read-only, or give each worker its own namespace with
test.info().parallelIndex.
Automatic fixtures
An auto fixture runs for every test without being named — useful for cross-cutting setup:
export const test = base.extend<{ failOnConsoleError: void }>({
failOnConsoleError: [
async ({ page }, use) => {
const errors: string[] = [];
page.on('console', msg => msg.type() === 'error' && errors.push(msg.text()));
await use();
expect(errors, 'console errors during the test').toEqual([]);
},
{ auto: true },
],
});
✘ 1 [chromium] › tests/todo.spec.ts:8:3 › todo list › starts empty (1.1s)
Error: console errors during the test
expect(received).toEqual(expected)
- Expected - 1
+ Received + 0
Array [
+ "Failed to load resource: 404 (/api/prefs)",
]
The test’s own assertions passed; the fixture failed it for a console error nobody would have noticed. This is one of the highest-value fixtures you can add to an existing suite.
Steps, for a readable trace
test('checkout', async ({ page, todos }) => {
await test.step('add two items', async () => {
await todos.add('buy milk', 'walk the dog');
});
await test.step('complete the first', async () => {
await todos.row('buy milk').getByRole('checkbox').check();
});
await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
});
✓ 1 [chromium] › tests/steps.spec.ts:3:1 › checkout (1.4s)
1 passed (2.1s)
Steps collapse in the HTML report and the trace viewer, so a twenty-action test reads as four lines until you expand the one that failed.
Practice
1. Convert the beforeEach navigation into a fixture that also seeds two todos.
export const test = base.extend<{ seeded: TodoPage }>({
seeded: async ({ page }, use) => {
const todos = new TodoPage(page);
await todos.goto();
await todos.add('buy milk', 'walk the dog');
await use(todos);
},
});
✓ 1 [chromium] › tests/seeded.spec.ts:3:1 › starts with two items (1.3s)
1 passed (2.0s)
Tests that need an empty list keep asking for page; only those that name seeded pay for
the seeding. That selectivity is the practical advantage over beforeEach.
2. Add an afterEach that attaches the page URL to the report on failure.
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('final-url', { body: page.url(), contentType: 'text/plain' });
}
});
attachment #1: final-url (text/plain) ─────────────────────────────────────────
test-results/todo-accepts-a-new-item-chromium/final-url.txt
───────────────────────────────────────────────────────────────────────────────
testInfo.attach puts arbitrary data in the HTML report — a URL, an API response body, a
generated CSV. Far more useful than a console.log that scrolls past in CI.
3. Make a fixture worker-scoped and prove it runs once for several tests.
export const test = base.extend<{}, { worker: number }>({
worker: [
async ({}, use) => {
console.log('building worker fixture');
await use(test.info().parallelIndex);
},
{ scope: 'worker' },
],
});
npx playwright test --workers=1 --project=chromium
building worker fixture
✓ 1 [chromium] › tests/worker.spec.ts:3:1 › first (12ms)
✓ 2 [chromium] › tests/worker.spec.ts:7:1 › second (9ms)
2 passed (658ms)
One log line for two tests. Run it again with --workers=2 and it prints twice — the
fixture is per worker, not per run.
4. Use test.use to run one describe block in dark mode.
test.describe('dark mode', () => {
test.use({ colorScheme: 'dark' });
test('respects the preference', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const matches = await page.evaluate(
() => matchMedia('(prefers-color-scheme: dark)').matches
);
expect(matches).toBe(true);
});
});
✓ 1 [chromium] › tests/dark.spec.ts:5:3 › dark mode › respects the preference (1.0s)
1 passed (1.7s)
test.use must sit at file or describe level, not inside a test — the options are read when
the fixture set is built, before the test body runs.
Next: authentication — logging in once and reusing the session across the whole suite.