When you write async ({ page }) => {...} in a Playwright test, you're receiving a fixture: Playwright created a fresh browser page before your test ran and will close it automatically after. Custom fixtures work the same way, declared with test.extend() and received by name in the test signature. The difference is that you define the setup and teardown yourself, with await use(value) as the dividing line. This guide covers all five built-in fixtures, the test.extend() pattern for custom fixtures, scope options for sharing expensive setup across tests, and how custom fixtures compose with each other.
What Is a Fixture?
A fixture is a value (or object) that Playwright prepares before your test runs and cleans up after. It's dependency injection for tests.
Instead of writing this:
test('user can log in', async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// test code
await page.close();
await context.close();
await browser.close();
});You write this:
test('user can log in', async ({ page }) => {
// page is ready to use — setup and teardown are handled
});Playwright handles the lifecycle. You get a clean page for each test, and it's closed automatically after.
Built-in Fixtures
Playwright provides these fixtures out of the box:
| Fixture | Type | What it is |
|---------|------|------------|
| page | Page | A new browser page (tab) for each test |
| browser | Browser | The browser instance (shared across tests in a worker) |
| context | BrowserContext | Browser context — like an incognito window |
| browserName | string | The current browser: 'chromium', 'firefox', 'webkit' |
| request | APIRequestContext | HTTP client for API requests |
page
The most commonly used fixture. Each test gets its own isolated page. After the test, it closes automatically.
test('page loads correctly', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await expect(page).toHaveTitle(/BecomeQA/);
});context
A browser context is like an incognito window — it has its own cookies, storage, and session. If you need multiple pages in one test, create them from the same context:
test('two pages share the same session', async ({ context }) => {
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto('/login');
// Log in on page1
// page2 also sees the session (same context = same cookies)
await page2.goto('/dashboard');
await expect(page2.getByTestId('user-name')).toBeVisible();
});browser
Usually you don't need browser directly. Use it when you need to create contexts with specific settings:
test('mobile viewport', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
});
const page = await context.newPage();
await page.goto('/');
// Test on mobile-sized page
await context.close();
});request
Makes HTTP requests without a browser. Used for API testing and for setting up test data via API before UI tests.
test('create user via API', async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'new@test.com', password: 'ValidPass1' },
});
expect(response.status()).toBe(201);
});browserName
Use it to conditionally skip tests for specific browsers:
test('file download', async ({ page, browserName }) => {
test.skip(browserName === 'firefox', 'Download API different in Firefox');
// ...
});Custom Fixtures
The real power of fixtures: you can create your own. Custom fixtures work exactly like built-in ones — they're declared once, used anywhere by destructuring.
Simple custom fixture: a pre-navigated page
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
// Define what custom fixtures exist
type MyFixtures = {
loggedInPage: Page;
};
export const test = base.extend<MyFixtures>({
loggedInPage: async ({ page }, use) => {
// SETUP
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
// Give the test access to the page
await use(page);
// TEARDOWN (runs after the test)
// Nothing needed here — page closes automatically
},
});
export { expect };// tests/dashboard.spec.ts
import { test, expect } from '../fixtures'; // import YOUR test, not @playwright/test
test('dashboard shows welcome message', async ({ loggedInPage }) => {
// Already logged in — loggedInPage IS the page, after login
await expect(loggedInPage.getByTestId('welcome')).toBeVisible();
});Custom fixture: Page Object
The most common pattern — a fixture that provides an initialized page object class:
// fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type PageObjects = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<PageObjects>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('successful login', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('user@test.com', 'ValidPass1');
await expect(dashboardPage.welcomeMessage).toBeVisible();
});No need to instantiate page objects in every test.
Custom fixture with teardown
If your fixture creates something that needs cleanup:
type TestFixtures = {
testUser: { id: number; email: string; token: string };
};
export const test = base.extend<TestFixtures>({
testUser: async ({ request }, use) => {
// SETUP: create a user
const response = await request.post('/api/users', {
data: {
email: `test_${Date.now()}@example.com`,
password: 'ValidPass1',
role: 'member',
},
});
const user = await response.json();
// Login to get token
const loginResp = await request.post('/api/auth/login', {
data: { email: user.email, password: 'ValidPass1' },
});
const { token } = await loginResp.json();
// Give test access
await use({ id: user.id, email: user.email, token });
// TEARDOWN: delete the user
await request.delete(`/api/users/${user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
},
});test('user can update profile', async ({ page, testUser }) => {
// testUser has id, email, token — fresh and unique per test
await page.goto(`/users/${testUser.id}`);
// ...
// After test, the user is automatically deleted
});Fixture Scope
By default, fixtures are 'test' scope — recreated for every test. You can set scope to 'worker' for fixtures that are expensive and safe to share:
export const test = base.extend<{}, { sharedToken: string }>({
sharedToken: [async ({ request }, use) => {
// Runs once per worker, not once per test
const response = await request.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const { token } = await response.json();
await use(token);
}, { scope: 'worker' }],
});Use 'worker' scope for things that are:
- Expensive to recreate (database seeding, file generation)
- Read-only (authentication tokens you only read, not modify)
- Safe to share (no state that one test could corrupt for another)
Combining Fixtures
Custom fixtures can depend on other fixtures (including other custom ones):
export const test = base.extend<{
loginPage: LoginPage;
authenticatedPage: Page;
}>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
// This fixture USES loginPage
authenticatedPage: async ({ page, loginPage }, use) => {
await loginPage.goto();
await loginPage.login('user@test.com', 'ValidPass1');
await page.waitForURL('/dashboard');
await use(page);
},
});A Clean Project Structure
project/
├── fixtures/
│ └── index.ts ← exports your extended test + expect
├── pages/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
└── tests/
├── login.spec.ts ← imports from fixtures/index.ts
└── dashboard.spec.tsAll test files import from fixtures/index.ts, not from @playwright/test directly. This means every test automatically has access to all custom fixtures.
Summary
| | Built-in | Custom |
|-|----------|--------|
| Where defined | Playwright internals | test.extend in your codebase |
| Where used | Any test with { page }, { request } etc. | Any test using your exported test |
| Examples | page, browser, request | loginPage, testUser, authToken |
| Lifecycle | Playwright manages | You define setup + await use() + teardown |
Fixtures are the cleanest way to share setup logic across tests. Once you start using them for page objects and authenticated states, you'll find tests become dramatically shorter and more focused on what they're actually testing.
→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Playwright Test Structure: describe, beforeEach, afterEach, and Hooks | Handling Auth in Playwright with storageState (No Logging In Every Test)