# 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** ```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({ 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 } ``` ```typescript // 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** ```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** ```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** ```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` 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** ```typescript // fixtures.ts import { test as base, expect } from "@playwright/test" type AutoFixtures = { blockAnalytics: void consoleErrors: string[] } export const test = base.extend({ // 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** ```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** ```typescript // fixtures/auth.ts import { test as base } from "@playwright/test" type AuthFixtures = { authenticatedPage: import("@playwright/test").Page } export 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) }, }) ``` ```typescript // fixtures/api.ts import { test as base, APIRequestContext } from "@playwright/test" type ApiFixtures = { apiClient: APIRequestContext } export 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() }, }) ``` ```typescript // 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" ``` ```typescript // 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** ```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 } ``` ```javascript // 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 } ``` ```javascript // 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** ```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({ // 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 } ``` ```typescript // 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" }, }, ], }) ``` ```typescript // 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** ```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** ```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({ 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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({ 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 files ``` ## Anti-Patterns ### 1. Global Mutable State in `beforeAll` ```typescript // 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? }) ``` ```typescript // 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 ```typescript // 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()) }) ``` ```typescript // 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 ```typescript // 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) }, }) ``` ```typescript // 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 ```typescript // 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 ``` ```typescript // 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 ```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 // }); ``` ```typescript // GOOD: Explicit type interface interface MyFixtures { myFixture: { count: number } } export const test = base.extend({ 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](../pom/page-object-model.md) — Page objects are typically consumed via fixtures - [core/test-organization.md](test-organization.md) — Where to put fixture files in the project tree - [core/configuration.md](configuration.md) — Option fixtures are configured in `playwright.config` - [core/authentication.md](authentication.md) — Auth state is the most common worker-scoped fixture - [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) — For truly global (all-workers) setup, not per-worker - [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md) — When to use each abstraction