Answer
Extending and Composing Playwright Fixtures
Fixtures are the recommended way to share setup code in Playwright. They compose cleanly — a fixture can depend on other fixtures.
Basic Extension Pattern
TypeScript
// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { ProductPage } from '../pages/ProductPage';
import { CartPage } from '../pages/CartPage';
// Declare fixture types
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
productPage: ProductPage;
cartPage: CartPage;
};
// Extend base test
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
productPage: async ({ page }, use) => {
await use(new ProductPage(page));
},
cartPage: async ({ page }, use) => {
await use(new CartPage(page));
},
});
export { expect } from '@playwright/test';
Fixture Depending on Another Fixture
TypeScript
type AuthFixtures = {
loggedInPage: DashboardPage; // Already authenticated
adminPage: DashboardPage; // Authenticated as admin
};
export const test = base.extend<AuthFixtures>({
// This fixture depends on the loginPage fixture
loggedInPage: async ({ page }, use) => {
// Perform login directly
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(new DashboardPage(page));
// Teardown — logout after test
await page.getByRole('button', { name: 'Logout' }).click();
},
});
Layered Fixture Composition
TypeScript
// Layer 1: Core fixtures
const testWithPages = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => await use(new LoginPage(page)),
});
// Layer 2: Auth on top of pages
const testWithAuth = testWithPages.extend<AuthFixtures>({
loggedInPage: async ({ loginPage, page }, use) => {
await loginPage.navigate();
await loginPage.login('user@test.com', 'pass');
await use(new DashboardPage(page));
},
});
// Layer 3: Feature-specific on top of auth
export const test = testWithAuth.extend<FeatureFixtures>({
productPage: async ({ page }, use) => await use(new ProductPage(page)),
});
Worker-Scoped Fixtures (Shared Across Tests)
TypeScript
type WorkerFixtures = {
adminToken: string; // Fetched once per worker
};
export const test = base.extend<{}, WorkerFixtures>({
adminToken: [async ({}, use) => {
// Login via API once per worker — NOT per test
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email: 'admin@test.com', password: 'admin' }),
headers: { 'Content-Type': 'application/json' },
});
const { token } = await res.json();
await use(token); // Token shared with all tests in this worker
// Teardown: invalidate token
await fetch('/api/auth/logout', {
headers: { Authorization: `Bearer ${token}` },
});
}, { scope: 'worker' }],
});
Using Composed Fixtures in Tests
TypeScript
// tests/checkout.spec.ts
import { test, expect } from '../fixtures/pages';
test('checkout flow', async ({ loggedInPage, productPage, cartPage }) => {
// Already logged in via loggedInPage fixture
await productPage.navigateTo('laptop');
await productPage.addToCart();
await cartPage.navigate();
await expect(cartPage.itemCount).toHaveText('1');
await cartPage.checkout();
});
