Fixtures & Hooks
Table of Contents
Built-in Fixtures
Core Fixtures
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
});Request Fixture
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);
});Custom Fixtures
Basic Custom Fixture
// 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";Fixture with Options
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" },
},
});Automatic Fixtures
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 },
],
});Fixture Scopes
Test Scope (Default)
Created fresh for each test:
test.extend({
page: async ({ browser }, use) => {
const page = await browser.newPage();
await use(page);
await page.close();
},
});Worker Scope
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" },
],
});Isolate test data between parallel workers
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.
Hooks
beforeEach / afterEach
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` });
}
});beforeAll / afterAll
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
});Describe-Level Hooks
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
});
});Authentication Patterns
Global Setup with Storage State
// 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"],
},
],
});Multiple Auth States
// 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"],
},
];Auth Fixture
// 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();
},
});Database Fixtures
This section covers per-test database fixtures (isolation, transaction rollback). For related topics:
- Test data factories (builders, Faker): See test-data.md
- One-time database setup (migrations, snapshots): See global-setup.md
Transaction Rollback Pattern
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
},
});Seed Data Fixture
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));
},
});Fixture Tips
| 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-Patterns to Avoid
| 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 |
Related References
- Page Objects with fixtures: See page-object-model.md for POM patterns
- Test organization: See test-suite-structure.md for test structure
- Debugging fixture issues: See debugging.md for troubleshooting