You already met fixtures as the things in the brackets: async ({ page }) => {}. That page is a fixture Playwright provides. Now you write your own, and this is where Playwright stops being a test runner and starts being a framework.
Here is the problem. Half your tests need a logged-in user. A quarter need a contact that already exists. You could put that in beforeEach, but then every test in the file pays for setup it may not need, and the test cannot say what it requires.
A custom fixture is setup that a test asks for by name.
import { test as base } from '@playwright/test';
type Fixtures = {
loggedInPage: Page;
};
export const test = base.extend<Fixtures>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('demo@nimbus.app');
await page.getByLabel('Password').fill('secure123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('welcome')).toBeVisible();
await use(page); // the test runs here
},
});
Read the shape carefully, because use is the part that is unfamiliar.
Everything before await use(page) is setup. Then use hands the value to the test and waits. Everything after use returns is teardown, and it runs even if the test failed.
That last point is what makes fixtures better than beforeEach plus afterEach. Setup and its cleanup live together in one function, so they cannot drift apart.
Now a test asks for it:
test('shows the contact list', async ({ loggedInPage }) => {
await loggedInPage.goto('/contacts');
await expect(loggedInPage.getByTestId('contact-row')).not.toHaveCount(0);
});
The test declares what it needs. A test that does not ask for loggedInPage never pays for the login.
A fixture that cleans up after itself.
contact: async ({ request }, use) => {
const response = await request.post('/api/contacts', {
data: { name: `Dana ${Date.now()}`, company: 'FoxLabs' },
});
const created = await response.json();
await use(created);
await request.delete(`/api/contacts/${created.id}`);
},
Created before, deleted after, and the deletion happens even when the test fails. This is the pattern that keeps a shared test database from filling up with junk over a year.
Worker-scoped fixtures, for expensive setup.
Some setup is too slow to repeat per test but must not be shared carelessly. Playwright lets a fixture live for the lifetime of a worker, which is one parallel process running many tests:
export const test = base.extend<{}, { adminToken: string }>({
adminToken: [async ({}, use) => {
const token = await fetchAdminToken();
await use(token);
}, { scope: 'worker' }],
});
Fetched once per worker instead of once per test. With four workers and two hundred tests, that is four calls instead of two hundred.
⚠️ Worker scope is where fixtures get dangerous.
Anything worker-scoped is shared by every test in that worker. A read-only token is fine. A record that tests modify is a shared-state bug waiting to happen, and it will fail differently depending on how many workers you run.
Rule of thumb: worker scope for things you read, test scope for things you change.
Remember this: setup before use, cleanup after it, and share only what nobody writes to.
Reference: Fixtures.