Fixtures and Hooks
When to use: Whenever tests need shared setup, teardown, reusable resources, or configurable context. Fixtures are Playwright's killer feature — prefer them over hooks in every situation where both could work.
Quick Reference
| Mechanism | Scope | Cleanup guaranteed? | Parallelism-safe? | Use for |
|---|---|---|---|---|
test.extend() fixture | per-test | Yes (via use() callback) | Yes | Most setup/teardown needs |
| Worker-scoped fixture | per-worker | Yes | Yes (isolated per worker) | Expensive resources: DB connections, auth state |
| Auto fixture | per-test or per-worker | Yes | Yes | Side effects that must always run: blocking analytics, logging |
beforeEach / afterEach | per-test | No (afterEach skipped on crash) | Yes | Simple, one-off setup that doesn't need cleanup |
beforeAll / afterAll | per-worker | No (afterAll skipped on crash) | Dangerous if mutating shared state | One-time worker setup with no cleanup needs |
The rule: If it needs cleanup, use a fixture. If it doesn't need cleanup and is simple, a hook is acceptable. When in doubt, use a fixture.
Patterns
1. Custom Test Fixture
Use when: Tests need a resource with guaranteed setup and teardown. Avoid when: The setup is a single line with no teardown — a beforeEach is fine.
Fixtures use a use() callback. Everything before use() is setup; everything after is teardown. Teardown runs even if the test crashes.
TypeScript
// fixtures.ts
import { test as base, expect } from "@playwright/test"
type TodoFixtures = {
todoPage: TodoPage
}
class TodoPage {
constructor(private page: import("@playwright/test").Page) {}
async addTodo(text: string) {
await this.page.getByPlaceholder("What needs to be done?").fill(text)
await this.page.getByPlaceholder("What needs to be done?").press("Enter")
}
async todos() {
return this.page.getByTestId("todo-item")
}
}
export const test = base.extend<TodoFixtures>({
todoPage: async ({ page }, use) => {
// Setup
await page.goto("/todos")
const todoPage = new TodoPage(page)
// Hand the fixture to the test
await use(todoPage)
// Teardown — runs even if test fails or crashes
await page.evaluate(() => localStorage.clear())
},
})
export { expect }// todos.spec.ts
import { test, expect } from "./fixtures"
test("add a todo item", async ({ todoPage, page }) => {
await todoPage.addTodo("Buy milk")
await expect(await todoPage.todos()).toHaveCount(1)
})JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
class TodoPage {
constructor(page) {
this.page = page
}
async addTodo(text) {
await this.page.getByPlaceholder("What needs to be done?").fill(text)
await this.page.getByPlaceholder("What needs to be done?").press("Enter")
}
async todos() {
return this.page.getByTestId("todo-item")
}
}
const test = base.extend({
todoPage: async ({ page }, use) => {
await page.goto("/todos")
const todoPage = new TodoPage(page)
await use(todoPage)
await page.evaluate(() => localStorage.clear())
},
})
module.exports = { test, expect }2. Worker-Scoped Fixtures
Use when: A resource is expensive to create and safe to share across tests in the same worker (database connections, auth tokens, compiled assets). Avoid when: Tests mutate the resource — each test must get its own copy.
Worker-scoped fixtures are created once per worker process, not once per test. They cannot depend on test-scoped fixtures (page, context, request).
TypeScript
// fixtures.ts
import { test as base } from "@playwright/test"
type WorkerFixtures = {
dbConnection: DatabaseClient
authToken: string
}
export const test = base.extend<{}, WorkerFixtures>({
dbConnection: [
async ({}, use) => {
const db = await DatabaseClient.connect(process.env.DB_URL!)
await use(db)
await db.disconnect()
},
{ scope: "worker" },
],
authToken: [
async ({}, use) => {
const response = await fetch(`${process.env.API_URL}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "test-user",
password: process.env.TEST_PASSWORD,
}),
})
const { token } = await response.json()
await use(token)
// No teardown needed — token expires on its own
},
{ scope: "worker" },
],
})
export { expect } from "@playwright/test"JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
dbConnection: [
async ({}, use) => {
const db = await DatabaseClient.connect(process.env.DB_URL)
await use(db)
await db.disconnect()
},
{ scope: "worker" },
],
authToken: [
async ({}, use) => {
const response = await fetch(`${process.env.API_URL}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "test-user",
password: process.env.TEST_PASSWORD,
}),
})
const { token } = await response.json()
await use(token)
},
{ scope: "worker" },
],
})
module.exports = { test, expect }Important: The second type parameter in base.extend<TestFixtures, WorkerFixtures> is for worker-scoped fixtures. Always put worker fixtures in the second generic argument.
3. Auto Fixtures
Use when: Something must run for every test without being explicitly requested — blocking analytics, capturing console errors, injecting feature flags. Avoid when: Tests need to opt out. Auto fixtures always run; there is no per-test escape hatch.
TypeScript
// fixtures.ts
import { test as base, expect } from "@playwright/test"
type AutoFixtures = {
blockAnalytics: void
consoleErrors: string[]
}
export const test = base.extend<AutoFixtures>({
// Blocks all analytics/tracking requests in every test
blockAnalytics: [
async ({ page }, use) => {
await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) => route.abort())
await use()
},
{ auto: true },
],
// Captures console errors — fail test if unexpected errors appear
consoleErrors: [
async ({ page }, use) => {
const errors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text())
}
})
await use(errors)
// Teardown: assert no unexpected console errors
const unexpected = errors.filter((e) => !e.includes("Expected warning"))
if (unexpected.length > 0) {
throw new Error(`Unexpected console errors:\n${unexpected.join("\n")}`)
}
},
{ auto: true },
],
})
export { expect }JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
blockAnalytics: [
async ({ page }, use) => {
await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) => route.abort())
await use()
},
{ auto: true },
],
consoleErrors: [
async ({ page }, use) => {
const errors = []
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text())
}
})
await use(errors)
const unexpected = errors.filter((e) => !e.includes("Expected warning"))
if (unexpected.length > 0) {
throw new Error(`Unexpected console errors:\n${unexpected.join("\n")}`)
}
},
{ auto: true },
],
})
module.exports = { test, expect }Auto fixtures can also be worker-scoped with \{ auto: true, scope: 'worker' \} for things like starting a dev server once per worker.
4. Fixture Composition with mergeTests()
Use when: You have multiple fixture files (auth fixtures, API fixtures, UI fixtures) and tests need several of them. Avoid when: You only have one fixture file. Don't over-engineer.
mergeTests() combines multiple extended test objects into one. Each fixture file defines its own concerns.
TypeScript
// fixtures/auth.ts
import { test as base } from "@playwright/test"
type AuthFixtures = {
authenticatedPage: import("@playwright/test").Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(page)
},
})// fixtures/api.ts
import { test as base, APIRequestContext } from "@playwright/test"
type ApiFixtures = {
apiClient: APIRequestContext
}
export const test = base.extend<ApiFixtures>({
apiClient: async ({ playwright }, use) => {
const api = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
})
await use(api)
await api.dispose()
},
})// fixtures/index.ts
import { mergeTests } from "@playwright/test"
import { test as authTest } from "./auth"
import { test as apiTest } from "./api"
export const test = mergeTests(authTest, apiTest)
export { expect } from "@playwright/test"// dashboard.spec.ts
import { test, expect } from "./fixtures"
test("dashboard loads user data", async ({ authenticatedPage, apiClient }) => {
// Both fixtures are available
const data = await apiClient.get("/users/me")
await expect(authenticatedPage.getByRole("heading")).toContainText("Dashboard")
})JavaScript
// fixtures/auth.js
const { test: base } = require("@playwright/test")
const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(page)
},
})
module.exports = { test }// fixtures/api.js
const { test: base } = require("@playwright/test")
const test = base.extend({
apiClient: async ({ playwright }, use) => {
const api = await playwright.request.newContext({
baseURL: "https://api.example.com",
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
})
await use(api)
await api.dispose()
},
})
module.exports = { test }// fixtures/index.js
const { mergeTests } = require("@playwright/test")
const { test: authTest } = require("./auth")
const { test: apiTest } = require("./api")
const test = mergeTests(authTest, apiTest)
module.exports = { test, expect: require("@playwright/test").expect }5. Parameterized Fixtures (Option Fixtures)
Use when: A fixture's behavior should be configurable per-project or per-describe block (locale, viewport, user role, feature flags). Avoid when: The value never changes — just hardcode it in the fixture.
Option fixtures are declared with \{ option: true \} and can be overridden in playwright.config under use or with test.use() in test files.
TypeScript
// fixtures.ts
import { test as base, expect } from "@playwright/test"
type OptionFixtures = {
userRole: "admin" | "editor" | "viewer"
locale: string
}
type DerivedFixtures = {
authenticatedPage: import("@playwright/test").Page
}
export const test = base.extend<OptionFixtures & DerivedFixtures>({
// Option fixtures — configurable per project or describe block
userRole: ["viewer", { option: true }],
locale: ["en-US", { option: true }],
// Derived fixture — uses the option values
authenticatedPage: async ({ page, userRole, locale }, use) => {
await page.goto(`/login?locale=${locale}`)
const credentials = {
admin: { email: "admin@test.com", password: "admin-pass" },
editor: { email: "editor@test.com", password: "editor-pass" },
viewer: { email: "viewer@test.com", password: "viewer-pass" },
}
const { email, password } = credentials[userRole]
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign in" }).click()
await use(page)
},
})
export { expect }// playwright.config.ts (override per project)
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "admin-tests",
testDir: "./tests/admin",
use: { userRole: "admin", locale: "en-US" },
},
{
name: "viewer-tests",
testDir: "./tests/viewer",
use: { userRole: "viewer", locale: "fr-FR" },
},
],
})// admin-settings.spec.ts (override per describe block)
import { test, expect } from "./fixtures"
test.describe("admin settings", () => {
test.use({ userRole: "admin" })
test("can access settings page", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/settings")
await expect(authenticatedPage.getByRole("heading")).toHaveText("Admin Settings")
})
})JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
userRole: ["viewer", { option: true }],
locale: ["en-US", { option: true }],
authenticatedPage: async ({ page, userRole, locale }, use) => {
await page.goto(`/login?locale=${locale}`)
const credentials = {
admin: { email: "admin@test.com", password: "admin-pass" },
editor: { email: "editor@test.com", password: "editor-pass" },
viewer: { email: "viewer@test.com", password: "viewer-pass" },
}
const { email, password } = credentials[userRole]
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign in" }).click()
await use(page)
},
})
module.exports = { test, expect }6. Fixture Dependencies
Use when: One fixture needs the output of another fixture. Playwright automatically resolves the dependency graph. Avoid when: You are tempted to nest fixtures more than 3 levels deep — flatten the design instead.
Request fixtures by name in the destructured first argument. Playwright handles ordering and lifecycle.
TypeScript
// fixtures.ts
import { test as base, expect } from "@playwright/test"
type Fixtures = {
apiContext: import("@playwright/test").APIRequestContext
testUser: { id: string; email: string }
userPage: import("@playwright/test").Page
}
export const test = base.extend<Fixtures>({
apiContext: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: process.env.API_URL,
})
await use(ctx)
await ctx.dispose()
},
// Depends on apiContext — Playwright creates apiContext first
testUser: async ({ apiContext }, use) => {
const response = await apiContext.post("/test/users", {
data: { email: `user-${Date.now()}@test.com`, role: "editor" },
})
const user = await response.json()
await use(user)
// Teardown: delete the test user
await apiContext.delete(`/test/users/${user.id}`)
},
// Depends on page AND testUser — both are ready before this runs
userPage: async ({ page, testUser }, use) => {
await page.goto("/login")
await page.getByLabel("Email").fill(testUser.email)
await page.getByLabel("Password").fill("default-test-password")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(page)
},
})
export { expect }JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
apiContext: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: process.env.API_URL,
})
await use(ctx)
await ctx.dispose()
},
testUser: async ({ apiContext }, use) => {
const response = await apiContext.post("/test/users", {
data: { email: `user-${Date.now()}@test.com`, role: "editor" },
})
const user = await response.json()
await use(user)
await apiContext.delete(`/test/users/${user.id}`)
},
userPage: async ({ page, testUser }, use) => {
await page.goto("/login")
await page.getByLabel("Email").fill(testUser.email)
await page.getByLabel("Password").fill("default-test-password")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(page)
},
})
module.exports = { test, expect }Teardown runs in reverse dependency order: userPage tears down first, then testUser (which deletes the user), then apiContext.
7. Overriding Built-in Fixtures
Use when: Every test needs the same modification to page, context, or browser — custom headers, viewport, locale, route blocking. Avoid when: Only some tests need the override. Use a new named fixture instead to keep the default page available.
TypeScript
// fixtures.ts
import { test as base, expect } from "@playwright/test"
export const test = base.extend({
// Override the built-in context fixture to add custom headers
context: async ({ browser }, use) => {
const context = await browser.newContext({
extraHTTPHeaders: {
"X-Test-ID": `test-${Date.now()}`,
"Accept-Language": "en-US",
},
permissions: ["geolocation"],
geolocation: { latitude: 37.7749, longitude: -122.4194 },
})
await use(context)
await context.close()
},
// Override the built-in page fixture to block third-party scripts
page: async ({ context }, use) => {
const page = await context.newPage()
await page.route("**/*.{png,jpg,jpeg,gif,svg}", (route) => route.abort())
await use(page)
// No need to close page — context.close() handles it
},
})
export { expect }JavaScript
// fixtures.js
const { test: base, expect } = require("@playwright/test")
const test = base.extend({
context: async ({ browser }, use) => {
const context = await browser.newContext({
extraHTTPHeaders: {
"X-Test-ID": `test-${Date.now()}`,
"Accept-Language": "en-US",
},
permissions: ["geolocation"],
geolocation: { latitude: 37.7749, longitude: -122.4194 },
})
await use(context)
await context.close()
},
page: async ({ context }, use) => {
const page = await context.newPage()
await page.route("**/*.{png,jpg,jpeg,gif,svg}", (route) => route.abort())
await use(page)
},
})
module.exports = { test, expect }8. beforeEach / afterEach — When Hooks Are Acceptable
Use when: Simple, stateless setup with no teardown needed. Navigation to a starting URL is the canonical example. Avoid when: The setup creates something that must be cleaned up. Use a fixture instead.
TypeScript
// acceptable-hooks.spec.ts
import { test, expect } from "@playwright/test"
// Acceptable: simple navigation, no teardown needed
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard")
})
test("shows welcome message", async ({ page }) => {
await expect(page.getByRole("heading")).toHaveText("Welcome")
})
test("shows navigation sidebar", async ({ page }) => {
await expect(page.getByRole("navigation")).toBeVisible()
})JavaScript
// acceptable-hooks.spec.js
const { test, expect } = require("@playwright/test")
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard")
})
test("shows welcome message", async ({ page }) => {
await expect(page.getByRole("heading")).toHaveText("Welcome")
})
test("shows navigation sidebar", async ({ page }) => {
await expect(page.getByRole("navigation")).toBeVisible()
})Hooks can use fixtures in their argument list — beforeEach(async (\{ page, context \}) works. But hooks cannot define fixtures.
9. beforeAll / afterAll — Worker-Level Hooks
Use when: One-time setup that all tests in a file share and that has no cleanup needs, such as logging a diagnostic or checking a precondition. Avoid when: You are creating resources that need cleanup (use worker-scoped fixtures) or storing mutable state (this breaks parallelism).
TypeScript
// health-check.spec.ts
import { test, expect } from "@playwright/test"
// Good: read-only check, no mutable state
test.beforeAll(async ({ request }) => {
const response = await request.get("/api/health")
expect(response.ok()).toBeTruthy()
})
test("homepage loads", async ({ page }) => {
await page.goto("/")
await expect(page).toHaveTitle(/My App/)
})JavaScript
// health-check.spec.js
const { test, expect } = require("@playwright/test")
test.beforeAll(async ({ request }) => {
const response = await request.get("/api/health")
expect(response.ok()).toBeTruthy()
})
test("homepage loads", async ({ page }) => {
await page.goto("/")
await expect(page).toHaveTitle(/My App/)
})beforeAll / afterAll receive only worker-scoped fixtures: request, browser, and any custom worker fixtures. They do not receive page or context (those are per-test).
10. Typed Fixtures in TypeScript
Always define an interface for your fixtures. This gives you autocomplete, catches typos at compile time, and documents the fixture contract.
TypeScript
// fixtures.ts
import { test as base, expect, Page, APIRequestContext } from "@playwright/test"
// Define fixture types explicitly
interface TestFixtures {
adminPage: Page
editorPage: Page
apiClient: APIRequestContext
}
interface WorkerFixtures {
sharedToken: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
sharedToken: [
async ({}, use) => {
const res = await fetch(`${process.env.API_URL}/auth/service-token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.SERVICE_SECRET }),
})
const { token } = await res.json()
await use(token)
},
{ scope: "worker" },
],
apiClient: async ({ playwright, sharedToken }, use) => {
const ctx = await playwright.request.newContext({
baseURL: process.env.API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${sharedToken}` },
})
await use(ctx)
await ctx.dispose()
},
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: "auth/admin.json" })
const page = await ctx.newPage()
await use(page)
await ctx.close()
},
editorPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: "auth/editor.json" })
const page = await ctx.newPage()
await use(page)
await ctx.close()
},
})
export { expect }Decision Guide
I need to set up something before tests run.
│
├── Does it create a resource that MUST be cleaned up?
│ │
│ ├── YES → Use a fixture (setup before `use()`, teardown after)
│ │ │
│ │ ├── Is the resource expensive and safe to share across tests?
│ │ │ ├── YES → Worker-scoped fixture: { scope: 'worker' }
│ │ │ └── NO → Test-scoped fixture (default)
│ │ │
│ │ ├── Should every test get this automatically?
│ │ │ ├── YES → Auto fixture: { auto: true }
│ │ │ └── NO → Regular fixture (test declares it in args)
│ │ │
│ │ └── Should the value be configurable per project?
│ │ ├── YES → Option fixture: { option: true }
│ │ └── NO → Regular fixture with hardcoded setup
│ │
│ └── NO → Hook is acceptable
│ │
│ ├── Per-test setup?
│ │ └── beforeEach
│ │
│ └── One-time per worker?
│ └── beforeAll (but only for read-only / diagnostic checks)
│
└── Am I combining fixtures from multiple domains?
└── YES → mergeTests() from separate fixture filesAnti-Patterns
1. Global Mutable State in beforeAll
// BAD: Mutable state shared across parallel tests
let testUser: { id: string; email: string }
test.beforeAll(async ({ request }) => {
const res = await request.post("/api/users", { data: { email: "shared@test.com" } })
testUser = await res.json() // Shared mutable state!
})
test.afterAll(async ({ request }) => {
await request.delete(`/api/users/${testUser.id}`) // Cleanup may not run
})
test("test 1", async ({ page }) => {
// Uses testUser — but what if another worker also has a testUser?
})// GOOD: Worker-scoped fixture — isolated per worker, guaranteed cleanup
import { test as base } from "@playwright/test"
export const test = base.extend<{ testUser: { id: string; email: string } }>({
testUser: async ({ request }, use) => {
const res = await request.post("/api/users", {
data: { email: `user-${Date.now()}@test.com` },
})
const user = await res.json()
await use(user)
await request.delete(`/api/users/${user.id}`)
},
})2. Cleanup in afterEach Instead of Fixture Teardown
// BAD: afterEach is not guaranteed to run on crash
test.beforeEach(async ({ page }) => {
await page.evaluate(() => localStorage.setItem("debug", "true"))
})
test.afterEach(async ({ page }) => {
// This might NOT run if the test crashes or times out
await page.evaluate(() => localStorage.clear())
})// GOOD: Fixture teardown always runs
export const test = base.extend<{ debugMode: void }>({
debugMode: async ({ page }, use) => {
await page.evaluate(() => localStorage.setItem("debug", "true"))
await use()
await page.evaluate(() => localStorage.clear()) // Guaranteed
},
})3. Fixtures That Do Too Many Things
// BAD: One fixture doing setup for unrelated concerns
export const test = base.extend({
everything: async ({ page }, use) => {
// Logs in
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
// Creates test data
await page.request.post("/api/products", { data: { name: "Widget" } })
// Blocks analytics
await page.route(/analytics/, (r) => r.abort())
// Sets locale
await page.evaluate(() => localStorage.setItem("locale", "en"))
await use(page)
},
})// GOOD: Separate fixtures, each with one responsibility
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await use(page)
},
testProduct: async ({ request }, use) => {
const res = await request.post("/api/products", { data: { name: "Widget" } })
const product = await res.json()
await use(product)
await request.delete(`/api/products/${product.id}`)
},
blockAnalytics: [
async ({ page }, use) => {
await page.route(/analytics/, (r) => r.abort())
await use()
},
{ auto: true },
],
})4. Over-Abstracting Fixtures
// BAD: Fixture factory with layers of indirection nobody can follow
const createFixture = (role, permissions, options) =>
base.extend({
[`${role}Page`]: async ({ page }, use) => {
await setupRole(page, role, permissions, options)
await use(page)
},
})
const adminTest = createFixture("admin", ["read", "write", "delete"], { mfa: true })
const editorTest = createFixture("editor", ["read", "write"], { mfa: false })
// Good luck debugging which fixture ran// GOOD: Explicit fixtures — boring but readable
export const test = base.extend({
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: "auth/admin.json" })
const page = await ctx.newPage()
await use(page)
await ctx.close()
},
editorPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: "auth/editor.json" })
const page = await ctx.newPage()
await use(page)
await ctx.close()
},
})5. Not Typing Fixtures in TypeScript
// BAD: No type parameter — no autocomplete, no compile-time errors
export const test = base.extend({
myFixture: async ({ page }, use) => {
await use({ count: 42 })
},
})
// test('example', async ({ myFixture }) => {
// myFixture.cont // No autocomplete, no type safety
// });// GOOD: Explicit type interface
interface MyFixtures {
myFixture: { count: number }
}
export const test = base.extend<MyFixtures>({
myFixture: async ({ page }, use) => {
await use({ count: 42 })
},
})
// test('example', async ({ myFixture }) => {
// myFixture.count // Autocomplete works, typos caught at compile time
// });Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Cannot use a test-scoped fixture in a worker-scoped fixture | Worker fixture depends on page, context, or test-scoped custom fixture | Worker fixtures can only depend on other worker fixtures or built-in worker fixtures (browser, playwright) |
Fixture "X" has already been registered | Two fixture files define the same fixture name and are both extended | Use mergeTests() instead of chaining .extend() calls, or rename one fixture |
| Fixture teardown not running | You used afterEach instead of the use() callback pattern | Move cleanup code after the await use() call inside the fixture |
beforeAll can't access page | page is test-scoped; beforeAll only gets worker-scoped fixtures | Use a worker-scoped fixture, or move logic to beforeEach |
| Test hangs inside fixture | await use() was never called — Playwright waits indefinitely for the fixture to yield | Ensure every fixture code path calls await use(value) exactly once |
| Fixture runs but test doesn't see the value | Fixture not declared in the test's destructured arguments | Add the fixture name to the test function signature: test('name', async (\{ myFixture \}) => \{ ... \}) |
| Option fixture value ignored | test.use() called inside test() instead of test.describe() | test.use() must be at the top level of a describe block or file, not inside a test |
| Auto fixture not running | Fixture file not imported — the custom test is not used | Import and use the test from your fixture file, not from @playwright/test |
Related
- pom/page-object-model.md — Page objects are typically consumed via fixtures
- core/test-organization.md — Where to put fixture files in the project tree
- core/configuration.md — Option fixtures are configured in
playwright.config - core/authentication.md — Auth state is the most common worker-scoped fixture
- ci/global-setup-teardown.md — For truly global (all-workers) setup, not per-worker
- pom/pom-vs-fixtures-vs-helpers.md — When to use each abstraction