.agents/skills/playwright-best-practices/core/fixtures-hooks.md
test('example', async ({
page, // Isolated page instance
context, // Browser context (cookies, localStorage)
browser, // Browser instance
browserName, // 'chromium', 'firefox', or 'webkit'
request, // API request context
}) => {
// Each test gets fresh instances
})
test('API call', async ({request}) => {
const response = await request.get('/api/users')
await expect(response).toBeOK()
const users = await response.json()
expect(users).toHaveLength(5)
})
// fixtures.ts
import {test as base} from '@playwright/test'
// Declare fixture types
type MyFixtures = {
todoPage: TodoPage
apiClient: ApiClient
}
export const test = base.extend<MyFixtures>({
// Fixture with setup and teardown
todoPage: async ({page}, use) => {
const todoPage = new TodoPage(page)
await todoPage.goto()
await use(todoPage) // Test runs here
// Teardown (optional)
await todoPage.clearTodos()
},
// Simple fixture
apiClient: async ({request}, use) => {
await use(new ApiClient(request))
},
})
export {expect} from '@playwright/test'
type Options = {
defaultUser: {email: string; password: string}
}
type Fixtures = {
authenticatedPage: Page
}
export const test = base.extend<Options & Fixtures>({
// Define option with default
defaultUser: [{email: '[email protected]', password: 'pass123'}, {option: true}],
// Use option in fixture
authenticatedPage: async ({page, defaultUser}, use) => {
await page.goto('/login')
await page.getByLabel('Email').fill(defaultUser.email)
await page.getByLabel('Password').fill(defaultUser.password)
await page.getByRole('button', {name: 'Sign in'}).click()
await use(page)
},
})
// Override in config
export default defineConfig({
use: {
defaultUser: {email: '[email protected]', password: 'admin123'},
},
})
export const test = base.extend<{}, {setupDb: void}>({
// Auto-fixture runs for every test without explicit usage
setupDb: [
async ({}, use) => {
await seedDatabase()
await use()
await cleanDatabase()
},
{auto: true},
],
})
Created fresh for each test:
test.extend({
page: async ({browser}, use) => {
const page = await browser.newPage()
await use(page)
await page.close()
},
})
Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):
type WorkerFixtures = {
sharedAccount: Account
}
export const test = base.extend<{}, WorkerFixtures>({
sharedAccount: [
async ({browser}, use) => {
// Expensive setup - runs once per worker
const account = await createTestAccount()
await use(account)
await deleteTestAccount(account)
},
{scope: 'worker'},
],
})
When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use testInfo.workerIndex (or process.env.TEST_WORKER_INDEX) in a worker-scoped fixture to create unique data per worker:
import {test as baseTest} from '@playwright/test'
type WorkerFixtures = {
dbUserName: string
}
export const test = baseTest.extend<{}, WorkerFixtures>({
dbUserName: [
async ({}, use, testInfo) => {
const userName = `user-${testInfo.workerIndex}`
await createUserInTestDatabase(userName)
await use(userName)
await deleteUserFromTestDatabase(userName)
},
{scope: 'worker'},
],
})
Then each worker uses a distinct user (e.g. user-1, user-2), so parallel workers do not overwrite each other’s data.
test.beforeEach(async ({page}) => {
// Runs before each test in file
await page.goto('/')
})
test.afterEach(async ({page}, testInfo) => {
// Runs after each test
if (testInfo.status !== 'passed') {
await page.screenshot({path: `failed-${testInfo.title}.png`})
}
})
test.beforeAll(async ({browser}) => {
// Runs once before all tests in file
// Note: Cannot use page fixture here
})
test.afterAll(async () => {
// Runs once after all tests in file
})
test.describe('User Management', () => {
test.beforeEach(async ({page}) => {
await page.goto('/users')
})
test('can list users', async ({page}) => {
// Starts at /users
})
test('can add user', async ({page}) => {
// Starts at /users
})
})
// auth.setup.ts
import {test as setup, expect} from '@playwright/test'
const authFile = '.auth/user.json'
setup('authenticate', async ({page}) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
await page.getByRole('button', {name: 'Sign in'}).click()
await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()
await page.context().storageState({path: authFile})
})
// playwright.config.ts
export default defineConfig({
projects: [
{name: 'setup', testMatch: /.*\.setup\.ts/},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
})
// auth.setup.ts
setup('admin auth', async ({page}) => {
await login(page, '[email protected]', 'adminpass')
await page.context().storageState({path: '.auth/admin.json'})
})
setup('user auth', async ({page}) => {
await login(page, '[email protected]', 'userpass')
await page.context().storageState({path: '.auth/user.json'})
})
// playwright.config.ts
projects: [
{
name: 'admin tests',
testMatch: /.*admin.*\.spec\.ts/,
use: {storageState: '.auth/admin.json'},
dependencies: ['setup'],
},
{
name: 'user tests',
testMatch: /.*user.*\.spec\.ts/,
use: {storageState: '.auth/user.json'},
dependencies: ['setup'],
},
]
// fixtures/auth.fixture.ts
export const test = base.extend<{adminPage: Page; userPage: Page}>({
adminPage: async ({browser}, use) => {
const context = await browser.newContext({
storageState: '.auth/admin.json',
})
const page = await context.newPage()
await use(page)
await context.close()
},
userPage: async ({browser}, use) => {
const context = await browser.newContext({
storageState: '.auth/user.json',
})
const page = await context.newPage()
await use(page)
await context.close()
},
})
This section covers per-test database fixtures (isolation, transaction rollback). For related topics:
import {test as base} from '@playwright/test'
import {db} from '../db'
export const test = base.extend<{dbTransaction: Transaction}>({
dbTransaction: async ({}, use) => {
const transaction = await db.beginTransaction()
await use(transaction)
await transaction.rollback() // Clean slate for next test
},
})
type TestData = {
testUser: User
testProducts: Product[]
}
export const test = base.extend<TestData>({
testUser: async ({}, use) => {
const user = await db.users.create({
email: `test-${Date.now()}@example.com`,
name: 'Test User',
})
await use(user)
await db.users.delete(user.id)
},
testProducts: async ({testUser}, use) => {
const products = await db.products.createMany([
{name: 'Product A', ownerId: testUser.id},
{name: 'Product B', ownerId: testUser.id},
])
await use(products)
await db.products.deleteMany(products.map((p) => p.id))
},
})
| Tip | Explanation |
|---|---|
| Fixtures are lazy | Only created when used |
| Compose fixtures | Use other fixtures as dependencies |
| Keep setup minimal | Do heavy lifting in worker-scoped fixtures |
| Clean up resources | Use teardown in fixtures, not afterEach |
| Avoid shared state | Each fixture instance should be independent |
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation |
| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup |
| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions |
Shared page or context in beforeAll | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or beforeEach + fresh page; if serial is required, prefer test.describe.configure({ mode: 'serial' }) and document that isolation is sacrificed |
| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with testInfo.workerIndex to create unique data per worker |