Five lines of login setup copied across 40 tests means updating 40 files when the login flow changes. Custom fixtures remove that with test.extend(): code before await use(value) runs as setup, code after it runs as teardown regardless of whether the test passed or failed. This article covers building an authenticatedPage fixture, composing fixtures that resolve dependencies automatically, worker scope for sharing expensive setup across tests, and the cases where a helper function is cleaner than a fixture.
Why built-in fixtures aren't enough
Playwright ships with fixtures like page, browser, context, and request. They cover the basics. But they don't know anything about your application: your login flow, your authenticated state, your domain objects.
The moment you have more than a handful of tests, you start running into the same problem over and over:
// tests/items.spec.ts
import { test, expect } from '@playwright/test';
test('user can add a travel item', async ({ page }) => {
// Setup — repeated in every test
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor();
// Actual test
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Item name').fill('Passport');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('cell', { name: 'Passport' })).toBeVisible();
});Five lines of login before you get to the test. Multiply that by 40 tests and you have 200 lines of code that add zero value to your test suite. Change the login flow and you're updating all 40 files.
Custom fixtures move the setup out of the test and into a shared definition. The test gets the result (an authenticated page, a ready-to-use page object) without caring how it was prepared.
The test.extend() pattern
The API is test.extend. You pass it an object where each key is a fixture name and each value is an async function that receives existing fixtures and a use callback.
Here's the minimal example, a fixture that logs in before the test runs:
// fixtures/auth.fixture.ts
import { test as base, expect } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: typeof base extends { page: infer P } ? P : never;
};
// Simpler with explicit Page type
import { Page } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor();
// Hand the authenticated page to the test
await use(page);
// Teardown runs after use() returns
// (nothing to clean up here — page closes automatically)
},
});
export { expect } from '@playwright/test';Now the test file imports test from the fixture file instead of from Playwright directly:
// tests/items.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
test('user can add a travel item', async ({ authenticatedPage }) => {
const page = authenticatedPage;
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Item name').fill('Passport');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('cell', { name: 'Passport' })).toBeVisible();
});The test starts at the dashboard. The login is gone. The intent is immediately obvious.
expect from your fixture file: export { expect } from '@playwright/test'. This way test files only need one import line, and you don't risk accidentally using Playwright's expect instead of a custom-wrapped version.Page object fixtures
Fixtures and page objects solve different problems but work extremely well together. A page object wraps the how of interacting with a page. A fixture handles the when: setting up the object and injecting it into the test.
Start with simple page objects:
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly addItemButton: Locator;
readonly itemsTable: Locator;
constructor(page: Page) {
this.page = page;
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.itemsTable = page.getByRole('table');
}
async isLoaded() {
await this.page.getByText('My Travel Items').waitFor({ state: 'visible' });
}
async getRowCount() {
const rows = this.page.getByRole('row');
return (await rows.count()) - 1; // subtract header row
}
}// pages/AddItemModal.ts
import { Page, Locator } from '@playwright/test';
export class AddItemModal {
readonly itemNameInput: Locator;
readonly categorySelect: Locator;
readonly saveButton: Locator;
constructor(page: Page) {
this.itemNameInput = page.getByLabel('Item name');
this.categorySelect = page.getByLabel('Category');
this.saveButton = page.getByRole('button', { name: 'Save' });
}
async fillAndSave(name: string, category: string) {
await this.itemNameInput.fill(name);
await this.categorySelect.selectOption(category);
await this.saveButton.click();
}
}Now wire them up as fixtures:
// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';
type PageFixtures = {
dashboardPage: DashboardPage;
addItemModal: AddItemModal;
};
export const test = base.extend<PageFixtures>({
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
addItemModal: async ({ page }, use) => {
await use(new AddItemModal(page));
},
});
export { expect } from '@playwright/test';Tests now declare exactly what objects they need:
// tests/items.spec.ts
import { test, expect } from '../fixtures/pages.fixture';
test('dashboard shows existing items', async ({ page, dashboardPage }) => {
await page.goto('https://lab.becomeqa.com');
// ... login steps
await dashboardPage.isLoaded();
const count = await dashboardPage.getRowCount();
expect(count).toBeGreaterThan(0);
});Setup and teardown in fixtures
The use callback is the dividing line between setup and teardown. Code before await use(value) runs before the test. Code after it runs after the test finishes, whether the test passed or failed.
This is where fixtures start to shine for anything that needs cleanup:
// fixtures/data.fixture.ts
import { test as base, request } from '@playwright/test';
type DataFixtures = {
testItemId: string;
};
export const test = base.extend<DataFixtures>({
testItemId: async ({}, use) => {
// Setup: create a travel item via API before the test
const apiContext = await request.newContext({
baseURL: 'https://lab.becomeqa.com/api',
extraHTTPHeaders: {
Authorization: 'Bearer test-token-123',
},
});
const response = await apiContext.post('/items', {
data: { name: 'Fixture Item', category: 'Documents' },
});
const { id } = await response.json();
// Hand the ID to the test
await use(id);
// Teardown: delete the item after the test
await apiContext.delete(`/items/${id}`);
await apiContext.dispose();
},
});The test doesn't manage the item lifecycle at all:
test('user can delete a travel item', async ({ page, testItemId }) => {
// The item already exists. Just navigate and delete it.
await page.goto(`https://lab.becomeqa.com/items`);
await page.getByTestId(`item-row-${testItemId}`).getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId(`item-row-${testItemId}`)).not.toBeVisible();
});If the test fails halfway through, the teardown still runs. The API call still deletes the item. Your database doesn't accumulate leftover test data.
Fixture scope: test vs worker
By default, every fixture is created fresh for each test. That's scope: 'test'. It's the safe default. Tests are isolated and there's no state bleed between them.
But authentication is expensive. Navigating, clicking, filling, waiting: that's 1-3 seconds per test. If you have 100 tests and they all log in from scratch, that's potentially 3 minutes of test time spent on login alone.
Worker scope runs the fixture once per worker process and shares the result across all tests in that worker. The right approach for authentication is to save browser storage state once and reuse it:
// fixtures/worker-auth.fixture.ts
import { test as base, chromium, BrowserContext } from '@playwright/test';
import path from 'path';
type WorkerFixtures = {
workerContext: BrowserContext;
};
export const test = base.extend<{}, WorkerFixtures>({
workerContext: [
async ({}, use) => {
// This runs once per worker, not once per test
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// Log in and save state
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor();
await use(context);
await context.close();
await browser.close();
},
{ scope: 'worker' },
],
});Notice the second argument { scope: 'worker' }. That's how you opt in. Worker-scoped fixtures are declared in the second generic parameter of extend rather than the first.
The practical implication: worker-scoped fixtures share state between tests. That's fine for a read-only authenticated context. It's a problem if tests modify the state they share (one test logs out, the next test fails to find the session). Use worker scope for things that are expensive to create and safe to share; use test scope for everything else.
Composing fixtures
Fixtures can use other fixtures. This is where the pattern scales to real projects.
You have an auth fixture that handles login. You have page object fixtures. You want a fixture that delivers an already-authenticated dashboard, combining both:
// fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';
type AppFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
addItemModal: AddItemModal;
loggedInDashboard: DashboardPage;
};
export const test = base.extend<AppFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
addItemModal: async ({ page }, use) => {
await use(new AddItemModal(page));
},
// This fixture uses loginPage and dashboardPage
loggedInDashboard: async ({ loginPage, dashboardPage }, use) => {
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await dashboardPage.isLoaded();
await use(dashboardPage);
},
});
export { expect } from '@playwright/test';Tests that need an authenticated dashboard get it in one word:
// tests/items.spec.ts
import { test, expect } from '../fixtures';
test('dashboard shows at least one item', async ({ loggedInDashboard }) => {
const count = await loggedInDashboard.getRowCount();
expect(count).toBeGreaterThan(0);
});
test('user can open add item modal', async ({ loggedInDashboard, addItemModal }) => {
await loggedInDashboard.addItemButton.click();
await expect(addItemModal.itemNameInput).toBeVisible();
});Playwright resolves the fixture dependency graph automatically. When a test requests loggedInDashboard, Playwright sees that it depends on loginPage and dashboardPage, creates those first, then runs the loggedInDashboard setup. You never manage this resolution manually.
// A more complete LoginPage to support the composing example
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly loginButton: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.loginButton = page.getByRole('button', { name: 'Login' });
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
}
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}The folder structure that emerges from this approach:
03_enterprise_pom/
pages/
LoginPage.ts
DashboardPage.ts
AddItemModal.ts
fixtures/
index.ts ← all fixtures exported from one place
tests/
items/
items-list.spec.ts
items-crud.spec.ts
payments/
payment-flow.spec.ts
playwright.config.tsEvery test file imports from ../fixtures and gets everything it needs with no boilerplate.
When NOT to use fixtures
Fixtures are powerful enough that teams sometimes over-apply them. A fixture that's used by one test isn't a fixture. It's just inline setup with extra ceremony. Before creating a fixture, ask: will at least three tests use this, or will this setup genuinely complicate a test if it's inline?
A few specific cases where fixtures add friction rather than removing it:
One-off test scenarios. If you have a test that verifies behavior after a very specific and unusual state (an item with a corrupted field, a session that's about to expire), inline setup is clearer. The unusual nature of the setup is itself documentation. Tests that need to verify the setup. If your test is actually about the login flow, you want the login steps visible in the test. Hiding them behind aloggedInDashboard fixture defeats the purpose. Tests about authentication should use the raw page fixture and set up the state explicitly.
Setup that varies significantly between tests. If every test needs slightly different initial data, a fixture that tries to accommodate all variations will grow a parameter list that's harder to understand than just writing the setup inline. A factory function (a regular TypeScript function your test calls) is often cleaner than a parametrized fixture.
// Instead of a complex parametrized fixture, use a factory function
// helpers/createItem.ts
import { APIRequestContext } from '@playwright/test';
export async function createItem(
request: APIRequestContext,
overrides: Partial<{ name: string; category: string }> = {}
) {
const response = await request.post('https://lab.becomeqa.com/api/items', {
data: {
name: 'Default Item',
category: 'Documents',
...overrides,
},
});
return response.json();
}// tests/items-edge-cases.spec.ts
import { test, expect } from '@playwright/test';
import { createItem } from '../helpers/createItem';
test('item with very long name is truncated in table', async ({ page, request }) => {
const item = await createItem(request, { name: 'A'.repeat(256) });
await page.goto('https://lab.becomeqa.com/items');
// ... rest of test
});The distinction is worth stating clearly: fixtures are for infrastructure such as authenticated state, shared context, page objects. Business logic and test-specific data setup often belongs in helper functions instead.
storageState feature is a practical alternative to a login fixture for some projects. You run an auth.setup.ts script once that saves the logged-in browser state to a JSON file, then all tests load that state via playwright.config.ts. This approach is faster than a per-test fixture login but requires a setup project in your config. Both approaches are valid. Pick the one that fits your CI pipeline better.FAQ
Can I override a fixture for a specific test?Yes. Use test.extend() again to create a more specific version, or use test.use() inside a describe block to override fixture options for that group. test.use() accepts an object with fixture values and applies them to all tests in the current scope.
Split by domain once the file gets long. A project might have auth.fixture.ts, data.fixture.ts, and pages.fixture.ts, then re-export everything from an index.ts. Test files import from the index and never need to know which file a fixture lives in.
test.describe?
Yes. Fixtures are available inside any describe block. You can also use test.describe.configure({ mode: 'parallel' }) inside a describe block. Fixtures respect the parallelism setting automatically.
Playwright marks the test as failed and still attempts to run any teardown code in fixtures that completed their setup phase. Fixtures that never reached await use() do not have their teardown code executed.
beforeAll or beforeEach?
Not directly. beforeAll and beforeEach don't receive fixtures as arguments. If you need shared setup that uses fixtures, convert the beforeEach into a fixture with its own scope. This is one of the cleaner motivations for adopting fixtures: they make beforeAll/beforeEach largely unnecessary.