Fixtures Advanced
Why Advanced Fixtures Matter
Production test suites require sophisticated setup and teardown patterns to manage complex dependencies like databases, external APIs, authentication state, and test data. Basic test setup with beforeEach/afterEach hooks leads to duplicated setup code, slow tests (repeating expensive operations), and brittle tests that leak state between runs.
Playwright’s advanced fixture system provides dependency injection with fine-grained control over fixture scope (test-level vs worker-level), automatic cleanup, and composable setup. Worker-scoped fixtures run once per worker process (shared across tests), dramatically reducing expensive setup operations like database migrations or server initialization. Custom fixtures enable you to inject specialized dependencies (authenticated pages, seeded databases, mock API servers) directly into tests, making test code cleaner and more maintainable.
Production systems need advanced fixtures because complex test suites without proper fixture management suffer from:
- Slow test execution: Repeating expensive setup operations (DB migrations, server startup) for every test
- State leakage: Tests affecting each other through shared resources (database state, file system)
- Setup duplication: Copy-pasting setup code across test files
- Poor maintainability: Changes to setup logic require updating dozens of test files
Standard Library Approach: Basic Test Hooks
Playwright provides built-in test hooks (beforeEach, afterEach, beforeAll, afterAll) for basic setup and teardown without requiring custom fixtures.
Basic test with beforeEach setup:
import { test, expect } from "@playwright/test";
// => Import Playwright test runner
// => @playwright/test provides test() and hooks
// => No custom fixtures required
let apiToken: string;
// => Module-level variable for test state
// => Shared across tests in file
// => Potential state leakage risk
test.beforeEach(async ({ page }) => {
// => beforeEach runs before every test
// => Receives page fixture
// => Duplicated across test files
const response = await page.request.post("https://api.example.com/auth/login", {
// => Send login request
// => page.request provides HTTP client
// => Returns Promise<APIResponse>
data: {
username: "testuser",
password: "testpass123",
// => Hardcoded credentials in setup
// => Repeated for every test
// => Expensive API call per test
},
});
// => API login for authentication
// => Executes before EVERY test
// => No caching across tests
const json = await response.json();
// => Parse JSON response
// => Extract authentication token
// => await waits for JSON parsing
apiToken = json.token;
// => Store token in module variable
// => Available to test functions
// => Potential race condition with parallel tests
});
test("user can view dashboard", async ({ page }) => {
// => Test receives page fixture
// => apiToken available from beforeEach
// => Setup code runs before this test
await page.goto("https://example.com/dashboard", {
// => Navigate to dashboard
// => Options configure request headers
// => Returns Promise<Response>
headers: {
Authorization: `Bearer ${apiToken}`,
// => Use token from beforeEach
// => Manual header injection
// => Token from module variable
},
});
// => Load authenticated page
// => Token passed in headers
// => Requires beforeEach to run first
await expect(page.locator("h1")).toHaveText("Dashboard");
// => Assert dashboard loaded
// => Verify authentication successful
// => Test logic separate from setup
});
test("user can view profile", async ({ page }) => {
// => Second test, new beforeEach run
// => apiToken refreshed
// => Duplicate API call
await page.goto("https://example.com/profile", {
headers: {
Authorization: `Bearer ${apiToken}`,
// => Reuse token pattern
// => Manual header setup again
// => Duplicated code
},
});
// => Navigate to profile page
// => Same authentication pattern
// => No abstraction
await expect(page.locator("h1")).toHaveText("Profile");
// => Verify profile page loaded
});Database setup with beforeAll:
import { test, expect } from "@playwright/test";
import { Pool } from "pg";
// => Import PostgreSQL client
// => pg provides connection pool
// => Used for database setup
let dbPool: Pool;
// => Module-level database pool
// => Shared across all tests
// => No automatic cleanup
test.beforeAll(async () => {
// => beforeAll runs once before all tests
// => No fixtures available
// => Manual setup required
dbPool = new Pool({
// => Create connection pool
// => Pool manages database connections
// => Configured with credentials
host: "localhost",
port: 5432,
database: "test_db",
user: "testuser",
password: "testpass",
// => Hardcoded database credentials
// => No environment variable support
// => Security risk
});
// => Initialize database pool
// => Runs once per worker
// => Must manually clean up
await dbPool.query("DELETE FROM users");
// => Clear users table
// => Manual database cleanup
// => Executes before tests run
await dbPool.query("INSERT INTO users (id, name) VALUES (1, 'Test User')");
// => Seed test data
// => Hardcoded test data
// => No data isolation between workers
});
test.afterAll(async () => {
// => afterAll runs once after all tests
// => Manual cleanup required
// => No automatic teardown
await dbPool.end();
// => Close database connections
// => Manual cleanup
// => Easy to forget
});
test("user exists in database", async ({ page }) => {
// => Test depends on beforeAll setup
// => Database seeded before this runs
// => No explicit dependency declaration
const result = await dbPool.query("SELECT * FROM users WHERE id = 1");
// => Query database directly
// => Uses module-level pool
// => No fixture injection
expect(result.rows[0].name).toBe("Test User");
// => Assert seeded data exists
// => Test depends on beforeAll
// => Implicit dependency
});Limitations for production test suites:
- No dependency injection: Tests access module variables, not injected dependencies
- Scope inflexibility: beforeEach always runs per-test, beforeAll per-worker (no fine-grained control)
- Manual cleanup: Easy to forget afterEach/afterAll, causing state leakage
- No composition: Cannot combine multiple setup pieces (auth + database + mock server)
- Poor reusability: Setup code duplicated across test files
- Parallel test risks: Module variables cause race conditions with parallel execution
Production Framework: Custom Test Fixtures
Playwright’s fixture system provides dependency injection with automatic cleanup, composable setup, and flexible scoping (test-level or worker-level).
Installation (already included with Playwright):
npm install --save-dev @playwright/test
# => Playwright includes fixture system
# => No additional dependencies
# => fixtures built into test runnerCustom fixture for authenticated API context:
// fixtures/authFixture.ts
import { test as base, expect } from "@playwright/test";
// => Import base test from Playwright
// => Extend with custom fixtures
// => base.extend() returns new test function
import { APIRequestContext } from "@playwright/test";
// => Import API context type
// => APIRequestContext provides HTTP client
// => Used for authenticated requests
type AuthFixtures = {
apiToken: string;
// => Custom fixture providing auth token
// => Tests receive token directly
// => No module variables needed
authenticatedContext: APIRequestContext;
// => Authenticated API client fixture
// => Pre-configured with auth headers
// => Injected into tests
};
export const test = base.extend<AuthFixtures>({
// => Extend base test with AuthFixtures
// => Type-safe fixture definitions
// => Exported for test files
apiToken: async ({ request }, use) => {
// => Define apiToken fixture
// => Receives request fixture (HTTP client)
// => use() callback provides fixture value
const response = await request.post("https://api.example.com/auth/login", {
// => Login to get token
// => request fixture provides HTTP client
// => Returns Promise<APIResponse>
data: {
username: process.env.TEST_USERNAME || "testuser",
password: process.env.TEST_PASSWORD || "testpass123",
// => Credentials from environment
// => Fallback to defaults for local dev
// => Secure configuration
},
});
// => Authentication request
// => Runs once per test
// => Automatic cleanup after test
const json = await response.json();
// => Parse response JSON
// => Extract token
// => Type-safe JSON parsing
const token = json.token as string;
// => Extract token from response
// => Type assertion for TypeScript
// => Token ready for use
await use(token);
// => Provide token to test
// => Test receives apiToken parameter
// => await use() pauses until test completes
// Cleanup runs here after test
// => Automatic teardown
// => Invalidate token if needed
// => No manual cleanup required
},
authenticatedContext: async ({ request, apiToken }, use) => {
// => Define authenticated context fixture
// => Depends on request and apiToken fixtures
// => Dependency injection pattern
const context = await request.newContext({
// => Create new API context
// => Inherits from request fixture
// => Returns APIRequestContext
extraHTTPHeaders: {
Authorization: `Bearer ${apiToken}`,
// => Inject auth token automatically
// => All requests include token
// => No manual header management
},
});
// => Pre-configured authenticated client
// => Uses apiToken fixture
// => Automatic token injection
await use(context);
// => Provide context to test
// => Test receives authenticated client
// => await use() pauses until test completes
await context.dispose();
// => Cleanup: dispose context
// => Automatic cleanup after test
// => Prevents resource leaks
},
});
export { expect };
// => Re-export expect for convenience
// => Tests import from authFixture
// => Single import source
Tests using authenticated fixtures:
// tests/dashboard.spec.ts
import { test, expect } from "../fixtures/authFixture";
// => Import custom test with fixtures
// => Tests automatically receive auth fixtures
// => No manual setup required
test("user can view dashboard via API", async ({ authenticatedContext }) => {
// => Test receives authenticated context
// => Context pre-configured with token
// => No manual authentication needed
const response = await authenticatedContext.get("https://api.example.com/dashboard");
// => API request with automatic auth
// => authenticatedContext injects token
// => Returns Promise<APIResponse>
expect(response.ok()).toBeTruthy();
// => Assert request successful
// => ok() checks 200-299 status
// => No manual status code checks
const data = await response.json();
// => Parse response JSON
// => Extract dashboard data
// => Type-safe response handling
expect(data).toHaveProperty("widgets");
// => Assert response structure
// => Verify dashboard data present
// => Business logic assertion
});
test("user can view profile via API", async ({ authenticatedContext }) => {
// => Second test with same fixture
// => New auth token generated
// => Test isolation maintained
const response = await authenticatedContext.get("https://api.example.com/profile");
// => Authenticated API request
// => Token handled by fixture
// => Clean test code
expect(response.ok()).toBeTruthy();
const profile = await response.json();
expect(profile).toHaveProperty("email");
// => Assert profile structure
// => Test focuses on business logic
// => Authentication abstracted away
});Worker-scoped fixture for database setup:
// fixtures/dbFixture.ts
import { test as base } from "@playwright/test";
import { Pool } from "pg";
// => Import PostgreSQL client
// => Pool manages connections
// => Used for database operations
type DatabaseFixtures = {
dbPool: Pool;
// => Database pool fixture
// => Shared across tests in worker
// => Worker-scoped for efficiency
};
export const test = base.extend<{}, DatabaseFixtures>({
// => Extend with worker-scoped fixtures
// => First type param: test-scoped (empty)
// => Second type param: worker-scoped
dbPool: [
async ({}, use, workerInfo) => {
// => Worker-scoped fixture definition
// => No test-level dependencies
// => workerInfo provides worker index
const pool = new Pool({
// => Create connection pool
// => One pool per worker
// => Shared across worker's tests
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
database: `test_db_worker_${workerInfo.workerIndex}`,
// => Separate database per worker
// => workerInfo.workerIndex for isolation
// => Prevents worker interference
user: process.env.DB_USER || "testuser",
password: process.env.DB_PASSWORD || "testpass",
// => Credentials from environment
// => Secure configuration
// => No hardcoded secrets
});
// => Initialize pool once per worker
// => Expensive operation amortized
// => Shared by all worker's tests
await pool.query("DELETE FROM users");
await pool.query("INSERT INTO users (id, name) VALUES (1, 'Test User')");
// => Seed database for worker
// => Runs once per worker
// => All worker's tests use same data
await use(pool);
// => Provide pool to tests
// => All tests in worker share pool
// => await use() pauses until worker completes
await pool.query("DELETE FROM users");
// => Cleanup: clear test data
// => Runs once after all worker's tests
// => Automatic cleanup
await pool.end();
// => Close pool connections
// => Automatic cleanup
// => Prevents connection leaks
},
{ scope: "worker" },
// => Mark fixture as worker-scoped
// => Runs once per worker process
// => Shared across worker's tests
],
});Tests using worker-scoped database fixture:
// tests/users.spec.ts
import { test, expect } from "../fixtures/dbFixture";
// => Import test with database fixture
// => dbPool fixture available
// => Worker-scoped, shared across tests
test("user exists in database", async ({ dbPool }) => {
// => Test receives shared database pool
// => Pool initialized once per worker
// => No per-test setup overhead
const result = await dbPool.query("SELECT * FROM users WHERE id = 1");
// => Query database using fixture
// => Pool injected by fixture system
// => Type-safe dependency
expect(result.rows[0].name).toBe("Test User");
// => Assert seeded data exists
// => Data seeded by worker-scoped setup
// => Shared across worker's tests
});
test("can query user count", async ({ dbPool }) => {
// => Second test, same dbPool fixture
// => No additional setup overhead
// => Reuses worker's pool
const result = await dbPool.query("SELECT COUNT(*) FROM users");
// => Count users in database
// => Uses shared pool
// => Fast execution (no setup)
expect(parseInt(result.rows[0].count)).toBe(1);
// => Assert seeded data count
// => Test isolation from other workers
// => Worker-specific database
});Composing multiple fixtures:
// fixtures/composedFixture.ts
import { test as base } from "@playwright/test";
import { test as authTest } from "./authFixture";
import { test as dbTest } from "./dbFixture";
// => Import specialized fixtures
// => Compose into combined fixture
// => Merges type definitions
export const test = base.extend({
// => Extend base test
// => Merge fixture definitions
// => Combine dependencies
...authTest,
...dbTest,
// => Spread auth and db fixtures
// => Tests receive all fixtures
// => Type-safe composition
// Additional composed fixture example
seededAuthenticatedPage: async ({ page, apiToken, dbPool }, use) => {
// => Composed fixture using dependencies
// => Depends on page, apiToken, dbPool
// => Combines multiple fixtures
await dbPool.query("INSERT INTO sessions (user_id, token) VALUES (1, $1)", [apiToken]);
// => Store session in database
// => Uses dbPool fixture
// => Links auth token to user
await page.goto("https://example.com", {
headers: { Authorization: `Bearer ${apiToken}` },
});
// => Load page with authentication
// => Uses apiToken fixture
// => Uses page fixture
await use(page);
// => Provide configured page
// => Test receives ready-to-use page
// => Combined setup complete
await dbPool.query("DELETE FROM sessions WHERE token = $1", [apiToken]);
// => Cleanup: remove session
// => Automatic teardown
// => Combined cleanup
},
});Fixture Scope and Lifecycle Diagram
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#0173B2','primaryTextColor':'#fff','primaryBorderColor':'#0173B2','lineColor':'#029E73','secondaryColor':'#DE8F05','tertiaryColor':'#CC78BC','background':'#fff','mainBkg':'#fff','secondaryBkg':'#f4f4f4','tertiaryBkg':'#f0f0f0'}}}%%
sequenceDiagram
participant Worker as Worker Process
participant TestScope as Test-Scoped Fixture
participant WorkerScope as Worker-Scoped Fixture
participant Test1 as Test 1
participant Test2 as Test 2
Worker->>WorkerScope: Setup (once per worker)
Note over WorkerScope: Database pool init<br/>Expensive operations
WorkerScope->>Test1: Provide shared resource
Test1->>TestScope: Setup (per test)
Note over TestScope: Auth token<br/>API context
TestScope->>Test1: Run test
Test1->>TestScope: Teardown (per test)
Note over TestScope: Cleanup token<br/>Dispose context
WorkerScope->>Test2: Provide shared resource
Test2->>TestScope: Setup (per test)
TestScope->>Test2: Run test
Test2->>TestScope: Teardown (per test)
Worker->>WorkerScope: Teardown (once per worker)
Note over WorkerScope: Close pool<br/>Cleanup database
Production Patterns and Best Practices
Pattern 1: Fixture Composition for Complex Setup
Compose multiple fixtures to build complex test dependencies:
// fixtures/e2eFixture.ts
import { test as base } from "@playwright/test";
import { test as authTest } from "./authFixture";
import { test as dbTest } from "./dbFixture";
import { test as mockApiTest } from "./mockApiFixture";
// => Import specialized fixtures
// => Each fixture provides specific capability
// => Compose for end-to-end tests
export const test = base.extend({
...authTest,
...dbTest,
...mockApiTest,
// => Merge all fixtures
// => Tests receive all dependencies
// => Type-safe composition
fullyConfiguredPage: async ({ page, apiToken, dbPool, mockApiServer }, use) => {
// => Composed fixture for E2E
// => Depends on auth, DB, mock API
// => Single fixture for complete setup
await dbPool.query("INSERT INTO users (id, name, email) VALUES (1, 'Test', 'test@example.com')");
// => Seed database with user
// => Uses dbPool fixture
// => Test data setup
await mockApiServer.setupRoute("/external-api/data", { status: 200, body: { data: "mocked" } });
// => Configure mock API endpoint
// => Uses mockApiServer fixture
// => External dependency mocked
await page.goto("https://example.com", {
headers: { Authorization: `Bearer ${apiToken}` },
});
// => Navigate to app
// => Uses apiToken fixture
// => Authentication configured
await use(page);
// => Provide fully configured page
// => All dependencies ready
// => Test receives complete environment
await dbPool.query("DELETE FROM users WHERE id = 1");
await mockApiServer.reset();
// => Cleanup all fixtures
// => Automatic teardown
// => Complete cleanup
},
});Using composed fixture:
import { test, expect } from "../fixtures/e2eFixture";
// => Import composed fixture
// => All dependencies available
// => Single import
test("user completes checkout flow", async ({ fullyConfiguredPage }) => {
// => Test receives complete environment
// => Auth, DB, mocks all configured
// => No manual setup
await fullyConfiguredPage.click("#checkout-button");
// => Test focuses on business logic
// => Setup abstracted away
// => Clean test code
await expect(fullyConfiguredPage.locator(".success-message")).toBeVisible();
// => Assert checkout successful
// => Environment pre-configured
// => Reliable test execution
});Pattern 2: Worker-Scoped Fixtures for Expensive Operations
Use worker scope for expensive setup operations that can be shared across tests:
// fixtures/serverFixture.ts
import { test as base } from "@playwright/test";
import { spawn, ChildProcess } from "child_process";
// => Import Node.js child_process
// => spawn() starts server process
// => Used for test server
type ServerFixtures = {
testServerUrl: string;
// => Test server URL fixture
// => Worker-scoped, shared across tests
// => Provides server endpoint
};
export const test = base.extend<{}, ServerFixtures>({
testServerUrl: [
async ({}, use, workerInfo) => {
// => Worker-scoped server fixture
// => Starts server once per worker
// => workerInfo for port assignment
const port = 3000 + workerInfo.workerIndex;
// => Unique port per worker
// => Prevents port conflicts
// => Parallel worker isolation
const serverProcess: ChildProcess = spawn("node", ["server.js"], {
// => Start Node.js server
// => spawn() returns ChildProcess
// => Runs in background
env: {
...process.env,
PORT: port.toString(),
NODE_ENV: "test",
// => Configure server environment
// => Worker-specific port
// => Test mode
},
});
// => Server starts once per worker
// => Expensive operation amortized
// => Shared by worker's tests
await new Promise((resolve) => setTimeout(resolve, 2000));
// => Wait for server startup
// => Simple delay (2 seconds)
// => Production: wait for health check
const url = `http://localhost:${port}`;
// => Construct server URL
// => Worker-specific port
// => Available to all worker's tests
await use(url);
// => Provide URL to tests
// => All worker's tests share server
// => await use() pauses until worker completes
serverProcess.kill();
// => Cleanup: stop server
// => Automatic teardown
// => Runs once after all worker's tests
},
{ scope: "worker" },
// => Mark as worker-scoped
// => Server started once per worker
// => Expensive operation optimized
],
});Tests using worker-scoped server:
import { test, expect } from "../fixtures/serverFixture";
// => Import test with server fixture
// => testServerUrl available
// => Worker-scoped server
test("server responds to health check", async ({ page, testServerUrl }) => {
// => Test receives server URL
// => Server already running
// => No startup overhead
await page.goto(`${testServerUrl}/health`);
// => Navigate to server endpoint
// => Uses worker's server
// => Fast execution (no startup)
await expect(page.locator("body")).toContainText("OK");
// => Assert server healthy
// => Shared server across tests
// => Efficient resource usage
});
test("server serves API endpoint", async ({ page, testServerUrl }) => {
// => Second test, same server
// => No additional startup
// => Reuses worker's server
const response = await page.request.get(`${testServerUrl}/api/data`);
// => API request to server
// => Server shared by worker
// => No per-test overhead
expect(response.ok()).toBeTruthy();
// => Assert response successful
});Pattern 3: Automatic Cleanup with use() Callback
Always implement cleanup in fixtures to prevent state leakage:
// fixtures/fileFixture.ts
import { test as base } from "@playwright/test";
import { writeFile, unlink, mkdir } from "fs/promises";
import { join } from "path";
// => Import Node.js file system
// => Promises API for async operations
// => Used for test file management
type FileFixtures = {
tempFile: string;
// => Temporary file fixture
// => Provides file path to test
// => Automatic cleanup after test
};
export const test = base.extend<FileFixtures>({
tempFile: async ({}, use) => {
// => Define tempFile fixture
// => No dependencies
// => use() callback for cleanup
const tempDir = join(__dirname, "../temp");
// => Define temp directory
// => Relative to fixtures directory
// => Isolated test files
await mkdir(tempDir, { recursive: true });
// => Create temp directory
// => recursive: true creates parent dirs
// => Idempotent operation
const filePath = join(tempDir, `test-${Date.now()}.txt`);
// => Generate unique file path
// => Date.now() for uniqueness
// => Prevents file conflicts
await writeFile(filePath, "initial content");
// => Create file with content
// => Setup before test runs
// => File ready for test
await use(filePath);
// => Provide file path to test
// => Test receives ready-to-use file
// => await use() pauses until test completes
try {
await unlink(filePath);
// => Cleanup: delete file
// => Runs after test completes
// => Automatic cleanup
} catch (error) {
// => Handle cleanup errors
// => File might not exist
// => Prevent test failure on cleanup
console.warn(`Failed to cleanup ${filePath}:`, error);
// => Log cleanup warning
// => Don't throw (test already completed)
}
},
});Tests using automatic cleanup fixture:
import { test, expect } from "../fixtures/fileFixture";
import { readFile } from "fs/promises";
// => Import test with file fixture
// => tempFile fixture provides path
// => Automatic cleanup
test("can read temp file", async ({ tempFile }) => {
// => Test receives temp file path
// => File already created
// => Automatic cleanup after test
const content = await readFile(tempFile, "utf-8");
// => Read file content
// => Uses fixture-provided path
// => File guaranteed to exist
expect(content).toBe("initial content");
// => Assert initial content
// => File cleanup automatic
// => No manual teardown needed
});
test("can modify temp file", async ({ tempFile }) => {
// => Second test, new temp file
// => Fresh file per test
// => Test isolation
await writeFile(tempFile, "modified content");
// => Modify file content
// => Uses fixture-provided path
// => Cleanup automatic
const content = await readFile(tempFile, "utf-8");
expect(content).toBe("modified content");
// => Assert modification successful
// => File deleted after test
// => No manual cleanup
});Trade-offs and When to Use
Standard Library (beforeEach/afterAll hooks):
- Use when: Simple setup (<5 lines), single test file, quick prototypes
- Benefits: Simple API, no abstractions, immediate understanding
- Costs: Manual cleanup, no dependency injection, setup duplication
Test-Scoped Custom Fixtures:
- Use when: Complex per-test setup, dependency injection needed, multiple test files
- Benefits: Automatic cleanup, composable, reusable across files, type-safe
- Costs: Learning fixture lifecycle, debugging fixture issues, upfront abstraction
Worker-Scoped Fixtures:
- Use when: Expensive setup (>1 second), shared resources (DB, server), parallel execution
- Benefits: Dramatic speed improvement (1x setup vs Nx setup), resource efficiency
- Costs: Shared state risks, worker isolation complexity, debugging harder
Production recommendation: Use custom fixtures for all production test suites. Worker-scoped fixtures for expensive operations (databases, servers, migrations). The maintenance and speed improvements justify initial learning investment.
Security Considerations
- Credentials in fixtures: Always use environment variables (
process.env.DB_PASSWORD), never hardcode - Worker isolation: Worker-scoped fixtures must use unique resources (per-worker databases, ports)
- Cleanup enforcement: Always implement cleanup in
use()callback to prevent credential leakage - Token expiration: Consider token TTL for long-running test suites
- Database isolation: Use separate databases per worker to prevent cross-worker data leakage
Common Pitfalls
- Forgetting cleanup: Always implement cleanup after
use()callback - Shared state in worker fixtures: Worker-scoped fixtures must use isolated resources (unique DB names, ports)
- Expensive test-scoped operations: Move slow operations (DB migrations, server startup) to worker scope
- Hardcoded credentials: Use environment variables for all secrets
- Circular fixture dependencies: Fixtures cannot depend on each other in a cycle (A→B→A)
- Missing type definitions: Always define fixture types for TypeScript safety