PRACTICAL GUIDE / playwright fixtures explained
Playwright Fixtures Explained: Test Setup That Scales
Playwright fixtures explained with test scope, worker scope, custom setup, cleanup, auth state, examples, reporting, and mistakes to avoid in CI.
In this guide8 sections
- Read a Fixture as a Lifecycle
- Choose Scope by Isolation Risk
- Extend the Base Test With Domain Capabilities
- Model Authentication at the Right Boundary
- Use Options for Configuration, Fixtures for Resources
- Apply Automatic Fixtures Sparingly
- Avoid Fixture Designs That Create Flake
- Migrate Setup With an Ownership Checklist
What you will learn
- Read a Fixture as a Lifecycle
- Choose Scope by Isolation Risk
- Extend the Base Test With Domain Capabilities
- Model Authentication at the Right Boundary
Test setup becomes dangerous when nobody can tell who owns it. A beforeEach signs in, a helper creates an order, a project dependency seeds an account, and a retry inherits whatever cleanup missed. Playwright fixtures provide a dependency model for that work, but moving every setup line into a fixture can make the ownership problem worse.
Use a fixture when a test needs a named capability with a clear lifecycle. The test asks for that capability in its parameters, Playwright prepares its dependencies, and teardown runs after the consumer finishes. That explicit request is the feature that makes fixtures scale.
Read a Fixture as a Lifecycle
Built-in fixtures such as page, context, browser, request, and testInfo already follow this model. A custom fixture has three phases:
- Prepare the resource.
- Pass it to the test by calling
use(resource). - Clean up after
usereturns.
import { test as base, expect } from '@playwright/test'
type Fixtures = {
account: { id: string; email: string; password: string }
}
export const test = base.extend<Fixtures>({
account: async ({ request }, use) => {
const response = await request.post('/api/test/accounts', {
data: { role: 'buyer' },
})
expect(response.ok()).toBeTruthy()
const account = await response.json()
await use(account)
await request.delete(`/api/test/accounts/${account.id}`)
},
})
export { expect }If the test throws, the code after use still gets its opportunity to run. Cleanup should tolerate partial setup and resources that the test legitimately deleted. A teardown failure must not erase the original test evidence, so log enough context to distinguish them.
Choose Scope by Isolation Risk
The default fixture scope is test: Playwright creates one instance for each test that requests it. A worker-scoped fixture is created once for a worker process and reused by tests scheduled in that worker.
| Resource | Likely scope | Decision test |
|---|---|---|
| New customer account | Test | Cases mutate identity or data |
Page object wrapping page | Test | It depends on a test-scoped page |
| Unique order | Test | Each case needs independent state |
| Read-only service client | Worker | Safe to share and costly to initialize |
| Database schema for one worker | Worker | Namespaced so workers cannot collide |
| Browser page or context | Test | Cookies, storage, and navigation mutate |
Worker scope is an optimization with a cost. Shared mutable state can create failures that depend on which tests happen to share a worker. Retries may run in a new worker, so a worker fixture can be recreated even if it was intended to exist “once.”
Use workerInfo.workerIndex or parallelIndex to namespace worker-owned accounts, schemas, or ports. Do not assume one worker equals one spec file or one permanent CI machine.
Extend the Base Test With Domain Capabilities
A fixture file becomes the suite's import boundary. Specs should import the extended test and expect, not mix them accidentally with the base package.
import { test as base, expect, type Page } from '@playwright/test'
class OrdersPage {
constructor(private readonly page: Page) {}
async open() {
await this.page.goto('/orders')
}
rowFor(orderId: string) {
return this.page.getByRole('row', { name: new RegExp(orderId) })
}
}
type AppFixtures = {
ordersPage: OrdersPage
}
export const test = base.extend<AppFixtures>({
ordersPage: async ({ page }, use) => {
await use(new OrdersPage(page))
},
})
export { expect }The fixture constructs a capability. It does not automatically navigate, create orders, or assert that the page loaded. A test that requests ordersPage should still show the scenario:
test('new order appears in history', async ({ ordersPage }) => {
await ordersPage.open()
await expect(ordersPage.rowFor('ORD-1042')).toBeVisible()
})When fixtures depend on one another, Playwright builds only the dependency graph needed by the requested test. That is more focused than a large beforeEach that initializes every object for every case.
Model Authentication at the Right Boundary
Authentication is a common fixture, but there are two different needs. A login test should exercise the login UI. Most other tests need an already authenticated context.
For a single shared role, generate storageState in a setup project and configure dependent projects to use it. For unique or dynamic users, create a test-scoped account and authenticate through an API or controlled setup path, then create a context with that state.
type AuthFixtures = {
authenticatedPage: Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ browser, request, account }, use) => {
const login = await request.post('/api/auth/login', {
data: { email: account.email, password: account.password },
})
expect(login.ok()).toBeTruthy()
const state = await request.storageState()
const context = await browser.newContext({ storageState: state })
const page = await context.newPage()
await use(page)
await context.close()
},
})Confirm that the API request context and browser context share the authentication mechanism your application uses. Cookies, origins, and tokens must line up. Do not copy a token into local storage without understanding when the app reads and refreshes it.
Shared accounts are unsuitable when tests modify profile, permissions, cart, or subscription state. A cached admin identity may be fine for read-only reporting checks, while buyer scenarios need unique accounts.
Use Options for Configuration, Fixtures for Resources
Fixture options let projects or individual tests provide typed configuration without reading environment variables throughout the suite.
type Options = {
defaultRole: 'buyer' | 'seller'
}
export const test = base.extend<Options>({
defaultRole: ['buyer', { option: true }],
})The project can set defaultRole through use, and a describe block can override it with test.use({ defaultRole: 'seller' }). A resource fixture can depend on that option when it creates an account.
This separation keeps concerns clear. An option describes what the test needs. A fixture performs work to provide it. Avoid reading process.env.ROLE inside five different fixtures, because the effective configuration becomes difficult to report and override.
Options should remain serializable, predictable inputs. Secrets still belong in protected environment configuration and should never be attached to test output.
Apply Automatic Fixtures Sparingly
An automatic fixture runs even when the test does not list it. This can be useful for universal diagnostics, such as attaching application logs after a failure.
type AutoFixtures = {
captureAppLogs: void
}
export const test = base.extend<AutoFixtures>({
captureAppLogs: [async ({ page }, use, testInfo) => {
const messages: string[] = []
page.on('console', message => messages.push(message.text()))
await use()
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('browser-console', {
body: Buffer.from(messages.join('\n')),
contentType: 'text/plain',
})
}
}, { auto: true }],
})Automatic fixtures should be cheap and genuinely universal. Auto-login, auto-navigation, and auto-data creation make test parameters lie about dependencies. They also impose work on tests that do not need it.
Review fixture names in reports. If setup dominates traces, consider whether the fixture does too much or whether its scope is wrong. Setup should improve diagnosis, not become an opaque prelude to every test.
Avoid Fixture Designs That Create Flake
The most costly mistakes are lifecycle mistakes:
- A worker fixture returns a mutable user that tests edit concurrently.
- Cleanup uses the page after the page or context fixture has already closed.
- A fixture catches setup errors and calls
usewith an incomplete object. - An automatic fixture changes application state for every test.
- A fixture contains the primary product assertion, so the report says setup failed.
- One giant fixture exposes unrelated pages, clients, users, and test data.
- Tests import
testfrom different fixture modules and silently lose extensions. - Teardown assumes setup completed fully and masks the initial exception.
Dependency order follows fixture use, so design resources to clean up while their dependencies are still available. If an order fixture needs an API client to delete its record, express that dependency in its parameter list rather than importing a global client.
Retries are a useful audit. If a retry passes only because it receives a fresh worker or account, the original case probably leaked or depended on state.
Migrate Setup With an Ownership Checklist
Do not convert every hook in one pass. Pick a repeated setup capability and write down its inputs, output, scope, dependencies, and cleanup. Implement it as test-scoped first. Run cases in parallel and with retries before considering worker scope.
For every new fixture, verify:
- A test requests it by a name that explains the capability.
- Its scope matches how the resource can be mutated.
- Setup fails immediately when the resource cannot be prepared.
- Teardown is safe after partial setup and test-side deletion.
- Parallel workers receive unique data or isolated namespaces.
- The main product assertion remains in the spec.
- Imports consistently use the extended test module.
The next practical step is to replace one repeated authentication or data-seeding hook with a test-scoped fixture, then inspect a passing trace, a setup failure, and a teardown failure. If all three tell a clear story, the fixture is ready to become part of the suite's shared contract.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
FAQ / QUICK ANSWERS
Questions testers ask
What are fixtures in Playwright?
Fixtures are reusable setup and teardown building blocks that Playwright injects into tests. Built in fixtures include page, browser, context, request, and testInfo. Custom fixtures let you prepare logged in users, seeded data, page objects, and service clients consistently.
What is the difference between test and worker fixtures?
A test scoped fixture is created separately for each test. A worker scoped fixture is created once for a worker process and shared by tests running in that worker. Use worker scope for expensive setup that is safe to share.
Should page objects be Playwright fixtures?
Page objects can be fixtures when many tests need the same pages and setup pattern. Keep them thin and readable. Do not hide important assertions or business decisions inside fixtures because that makes tests harder to review.
How do fixtures help with flaky tests?
Fixtures reduce flakiness by centralizing setup, cleanup, authentication, data seeding, and environment checks. When each test starts from a known state, failures are more likely to reflect product behavior instead of polluted data.
Can Playwright fixtures replace beforeEach?
Fixtures can replace many beforeEach blocks, especially when setup is reusable and dependency based. beforeEach is still fine for simple local setup, but fixtures scale better when several tests need the same prepared object.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 02
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.
GUIDE 03
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.
GUIDE 04
How to Build a Test Automation Framework from Scratch
Learn how to build a test automation framework from scratch with layers, design patterns, reporting, CI/CD hooks, and a practical starter architecture.