QA How-To
Playwright test fixtures override: Examples and Best Practices
Learn playwright test fixtures override examples for pages, storage state, options, worker scope, teardown, reporting, and maintainable test design today.
18 min read | 2,931 words
TL;DR
Extend the base Playwright test, redefine the existing fixture name, perform setup, await use(value), and then clean up. Override only when consumers should continue receiving the same conceptual fixture; otherwise export a descriptive new fixture.
Key Takeaways
- Override a fixture with base.extend() and keep await use(value) as the lifecycle boundary.
- Preserve the expected meaning of page, context, or another familiar fixture when overriding it.
- Create a newly named fixture when authentication, role, or starting state is optional or surprising.
- Use option fixtures for typed configuration and worker fixtures only for safely shareable resources.
- Make provisioning and cleanup idempotent because a retry can create a fresh fixture lifecycle.
- Use fixture titles, boxing, and focused attachments to make setup failures diagnosable.
Playwright test fixtures override examples are most useful when the standard page, context, or test options are almost right, but every test needs one controlled change. You override a fixture by extending Playwright's base test, keeping the same fixture name, and calling use() with the value that downstream tests should receive.
The technique is powerful because setup, dependency resolution, and teardown remain inside the Playwright fixture lifecycle. It is also easy to overuse. This guide shows correct TypeScript patterns, explains when a new fixture is safer than an override, and gives review criteria for production test suites.
TL;DR
| Need | Recommended mechanism | Why |
|---|---|---|
| Change a configured browser option | use in config or test.use() |
No fixture code is required |
Add behavior to the built-in page |
Override page with base.extend() |
Tests keep consuming { page } |
| Provide a different capability | Create a new named fixture | Intent stays visible at the call site |
| Share expensive setup per worker | Worker-scoped custom fixture | Setup is reused safely within one worker |
| Compose independent fixture packs | mergeTests() |
Feature modules stay separate |
The core pattern is const test = base.extend({ fixtureName: async (dependencies, use) => { setup; await use(value); teardown; } }). Always await use(), preserve isolation, and make ownership of cleanup explicit.
1. What Playwright Test Fixtures Override Examples Actually Demonstrate
A fixture is a value managed by Playwright Test. The runner analyzes the fixture names requested by a test, builds the dependency graph, runs setup in dependency order, executes the test, and then tears fixtures down in reverse order. Built-in fixtures include browser, context, page, and request. Configurable test options, such as baseURL, locale, and storageState, also participate in this system.
An override defines a fixture under a name that already exists. It can wrap the original value, depend on another fixture, or replace the value completely. The most common form wraps the original page. Because the override requests page as a dependency and also provides page, Playwright supplies the base implementation to the callback, then exposes the overridden result to tests.
That behavior is different from a beforeEach hook. A hook runs because of lexical placement in a file or describe block. A fixture runs on demand because a test or another fixture needs it. A fixture can also have test or worker scope, a dedicated timeout, automatic activation, a report title, and explicit dependencies.
Use an override when consumers should continue asking for the familiar fixture and the new behavior is a suite-wide invariant. If only a subset of tests needs a special page, create a descriptive fixture such as adminPage instead. That choice prevents invisible setup and makes future debugging easier. For a broader foundation, see the Playwright global setup guide, which explains process-wide preparation versus per-test fixtures.
2. The Minimal Playwright Fixture Override Pattern
The following two-file example is runnable against the public Playwright documentation site. The fixture navigates every requested page to the configured baseURL. It uses a fallback so the code remains safe even if a consumer imports the fixture without a configured URL.
// fixtures.ts
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
page: async ({ page, baseURL }, use) => {
await page.goto(baseURL ?? 'https://playwright.dev');
await use(page);
},
});
export { expect };
// home.spec.ts
import { test, expect } from './fixtures';
test.use({ baseURL: 'https://playwright.dev' });
test('opens the configured start page', async ({ page }) => {
await expect(page).toHaveTitle(/Playwright/);
await expect(page).toHaveURL('https://playwright.dev/');
});
The await use(page) line is the handoff point. Code before it is setup. Code after it is teardown. If the test fails, Playwright still resumes the fixture after use() so cleanup can run. Do not return the value instead of calling use; a fixture callback follows a lifecycle protocol, not a factory-function contract.
Import test from the fixture module in every spec that needs the override. Importing test directly from @playwright/test bypasses the extension and is a common reason an override seems inconsistent. Re-exporting expect is not mandatory, but it creates one clear import path for a suite.
3. Override page Without Breaking Browser Isolation
Overriding page is appropriate for universal behavior such as opening a start route, registering event listeners, or applying a page-level guard. The original page already belongs to a fresh browser context for the test. Reuse it instead of calling browser.newPage() unless you deliberately need a separate context or page lifecycle.
// guarded-fixtures.ts
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
const pageErrors: Error[] = [];
page.on('pageerror', error => pageErrors.push(error));
await use(page);
expect(pageErrors, 'unexpected errors were logged by the page').toEqual([]);
},
});
export { expect };
This override gives every test the normal isolated page and adds a teardown assertion. It does not leak listeners because the built-in context and page are disposed after the test. Whether a teardown assertion is desirable depends on policy. A known third-party script error could make unrelated functional tests fail, so teams should filter only well-understood noise and document the reason.
Avoid silently performing a full login in every page override if some tests cover anonymous behavior. A separate authenticatedPage fixture is clearer. Also avoid navigation in an override when tests need to verify initial pages, popups, or blank-page behavior. In those suites, a startPage fixture communicates the precondition without changing the meaning of built-in page.
The governing rule is substitutability: any test that asks for page should still receive something that behaves like Playwright's normal page. If the override changes permissions, identity, network routing, or starting state in a surprising way, use a new fixture name.
4. Override storageState for Authentication Data
storageState is a configurable option used when Playwright creates the browser context. Overriding it can supply cookies and local storage dynamically. This occurs before context and page are created, so the authentication state is available from the first navigation.
// auth-fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
storageState: async ({}, use) => {
await use({
cookies: [],
origins: [
{
origin: 'https://example.test',
localStorage: [
{ name: 'preferredTheme', value: 'dark' },
],
},
],
});
},
});
For real authentication, prefer acquiring state through a supported login API or a dedicated setup project, then pass a storage-state file through configuration. Do not hard-code production tokens or credentials in the repository. Dynamic fixture code is useful when each worker must obtain a unique account or when a short-lived token cannot be reused across a complete run.
Be precise about what storage state contains. It covers cookies and origin-scoped storage represented by Playwright's storage state format. Application state held only in memory, IndexedDB behavior not included by your chosen state workflow, and server-side sessions may need additional setup. Verify login with a user-visible assertion rather than assuming that the presence of a token means the session is valid.
If authentication is the main topic, pair this pattern with an explicit state-generation strategy and keep anonymous tests on the base test object. A reliable design usually exports two test objects, one neutral and one authenticated, rather than forcing every scenario through the same override.
5. Use Option Fixtures for Type-Safe Configuration
An option fixture is a configurable input marked with { option: true }. It can be set in playwright.config.ts, in a project, or with test.use(). Option fixtures work well for domain values such as region, account tier, or API flavor. They are better than untyped environment-variable reads scattered through tests.
// app-fixtures.ts
import { test as base, expect } from '@playwright/test';
type AppOptions = {
startPath: string;
};
export const test = base.extend<AppOptions>({
startPath: ['/docs/intro', { option: true }],
page: async ({ page, baseURL, startPath }, use) => {
const origin = baseURL ?? 'https://playwright.dev';
await page.goto(new URL(startPath, origin).toString());
await use(page);
},
});
export { expect };
// intro.spec.ts
import { test, expect } from './app-fixtures';
test.use({ startPath: '/docs/intro' });
test('loads the selected documentation route', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
The option and the overridden page are separate concerns. startPath describes configuration, while page enforces how that configuration is applied. A project can choose another path without duplicating setup code.
Keep option defaults conservative and deterministic. An option fixture should not call a service just to decide its value. If computing a value requires asynchronous setup or cleanup, use a regular fixture that depends on the option. This separation yields clearer reports and easier local overrides.
6. Worker-Scoped Accounts With a Test-Scoped Page Override
Worker fixtures are created once per worker process. They fit expensive resources that can be safely shared by the tests executed in that worker, such as a unique account or an API client. The page should remain test-scoped so each test receives an isolated context.
// account-fixtures.ts
import { test as base, expect, type APIRequestContext } from '@playwright/test';
type Account = { email: string; password: string };
type TestFixtures = {
account: Account;
};
type WorkerFixtures = {
workerAccount: Account;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
workerAccount: [async ({ playwright }, use, workerInfo) => {
const api = await playwright.request.newContext({
baseURL: process.env.BASE_URL ?? 'https://example.test',
});
const account = {
email: `qa-${workerInfo.workerIndex}@example.test`,
password: 'local-test-password',
};
await provisionAccount(api, account);
await use(account);
await deleteAccount(api, account.email);
await api.dispose();
}, { scope: 'worker' }],
account: async ({ workerAccount }, use) => {
await use(workerAccount);
},
page: async ({ page, account }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(account.email);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('banner')).toContainText(account.email);
await use(page);
},
});
async function provisionAccount(
request: APIRequestContext,
account: Account,
) {
const response = await request.post('/test-api/accounts', { data: account });
expect(response.ok()).toBeTruthy();
}
async function deleteAccount(request: APIRequestContext, email: string) {
await request.delete(`/test-api/accounts/${encodeURIComponent(email)}`);
}
This sample expects the application under test to expose test account endpoints, but every Playwright API and fixture signature shown is real. The two generic parameters matter: the first types test-scoped fixtures, and the second types worker-scoped fixtures.
Sharing one account can still create data collisions if tests mutate the same profile, cart, or permissions. Either allocate unique records per test beneath the worker account or make the test data immutable. Worker scope is an optimization, not permission to share uncontrolled state.
7. Override Versus New Fixture Versus Hook
The best design is not always an override. Use the following reference during review.
| Technique | Activation | Best use | Main risk |
|---|---|---|---|
| Override existing fixture | When that fixture is requested | Preserve an interface while enforcing an invariant | Hidden behavior under a familiar name |
| New named fixture | When its new name is requested | Represent a distinct role, page, client, or resource | Too many narrow fixtures |
| Automatic fixture | Every test or worker in its scope | Universal logging, artifact capture, policy checks | Cost paid even when not obvious |
beforeEach hook |
Every test in the containing scope | Simple local preparation for one suite | Dependencies and reuse are less explicit |
| Setup project | Before dependent projects | Generate reusable authentication or environment state | State can become stale or overly shared |
A useful question is, "Would a reader be surprised that { page } is already logged in and navigated?" If yes, name it memberPage. If no because every test in that test object is defined as an authenticated application test, an override may be clean.
Hooks remain valuable for scenario-level preparation. For example, a describe block may create a project record in beforeEach while fixtures provide the authenticated API client and page objects. Avoid converting every three-line hook into a fixture. The abstraction should improve ownership, reuse, or dependency clarity.
For failures that appear to be timing problems after complex setup, use the guide to fixing Playwright timeout errors before adding delays. Fixture design should reduce nondeterminism, not hide it.
8. Compose Fixture Modules With mergeTests
Large repositories often have independent fixture packs for database helpers, accessibility checks, API clients, and UI roles. mergeTests() combines extended test objects so a spec can consume fixtures from all of them.
// merged-fixtures.ts
import { mergeTests, expect } from '@playwright/test';
import { test as accountTest } from './account-fixtures';
import { test as auditTest } from './audit-fixtures';
export const test = mergeTests(accountTest, auditTest);
export { expect };
// account-audit.spec.ts
import { test, expect } from './merged-fixtures';
test('member page has no critical audit findings', async ({
page,
auditPage,
}) => {
await page.goto('/account');
const findings = await auditPage(page);
expect(findings.filter(item => item.impact === 'critical')).toEqual([]);
});
The exact auditPage implementation belongs to the local fixture module. The relevant API here is mergeTests(accountTest, auditTest). Use it when modules are genuinely independent and their fixture names do not conflict.
If two merged modules override page differently, the final behavior becomes hard to reason about. Prefer one composition boundary that owns foundational fixtures. Give feature-specific capabilities unique names, then merge those. Also keep imports acyclic: a fixture module can depend on a lower-level module, but two fixture packs should not import each other.
Use a single public fixture entry point per test family. It prevents accidental imports from the base runner and makes the active fixture contract discoverable in code review.
9. Teardown, Failures, and Fixture Execution Order
Fixture setup ends at await use(value), and teardown begins after it resolves or rejects. When fixture A depends on fixture B, Playwright sets up B first and tears it down after A. This reverse cleanup order makes dependencies available while their consumers release resources.
import { test as base, expect } from '@playwright/test';
type Fixtures = {
seededLabel: string;
};
export const test = base.extend<Fixtures>({
seededLabel: async ({ request }, use) => {
const label = `run-${Date.now()}`;
const created = await request.post('/test-api/labels', {
data: { name: label },
});
expect(created.ok()).toBeTruthy();
await use(label);
const removed = await request.delete(
`/test-api/labels/${encodeURIComponent(label)}`,
);
expect(removed.ok()).toBeTruthy();
},
});
Cleanup should be idempotent where practical. A test might delete the same entity as part of its scenario, or a partial setup might fail. A cleanup endpoint that treats an already missing test record as success is safer than teardown that masks the original assertion with a second error.
Do not put cleanup after await use() if the resource must be preserved for post-failure inspection and your policy requires manual retention. Instead, record the resource identifier as an attachment or annotation and apply a deliberate retention rule. Most CI environments benefit from automatic cleanup, but regulated or complex systems may need a traceable quarantine workflow.
Fixture setup time counts toward the test timeout for test-scoped fixtures. A slow worker fixture has its own fixture timeout behavior and can specify a timeout in its tuple options. Measure expensive provisioning rather than raising every test timeout.
10. Reporting, Boxing, and Debuggable Fixture Titles
Fixtures appear as steps in Playwright reports, UI mode, and Trace Viewer. That visibility is useful when setup fails, but helper fixtures can create noise. Fixture tuple options support box: true to hide the fixture step, or box: 'self' to hide only the fixture wrapper while preserving steps inside it. A custom title can replace an obscure fixture property name in reports.
import { test as base } from '@playwright/test';
export const test = base.extend<{ diagnostics: void }>({
diagnostics: [async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('final-url', {
body: page.url(),
contentType: 'text/plain',
});
}
}, {
auto: true,
box: 'self',
title: 'collect failure diagnostics',
}],
});
This automatic fixture does not override a built-in fixture, but it is often a better solution than bloating a page override. It activates for each test, observes the built-in page, and attaches a small diagnostic only on an unexpected outcome.
Box routine mechanics, not information that an engineer needs to diagnose a setup failure. Give fixtures domain-oriented titles such as "provision tenant" or "authenticate billing admin". Avoid secret values in titles and attachments because HTML reports are commonly retained as CI artifacts.
For action-level readability inside tests and page objects, use Playwright test.step examples. Steps and fixtures solve different reporting problems and can coexist.
11. Best Practices for Playwright Test Fixtures Override Examples
Treat the fixture module as infrastructure code. Keep it typed, reviewed, and smaller than the test features that consume it. The following practices scale well:
- Export a canonical
testandexpectfrom one fixture entry point for each test family. - Override familiar fixtures only when the replacement preserves their expected meaning.
- Prefer explicit named fixtures for roles such as
adminPage,guestPage, andapiClient. - Keep
pageandcontexttest-scoped unless there is a documented, isolated reason to change scope. - Use worker scope for expensive, shareable resources, then prevent state collisions among tests.
- Put resource deletion after
await use()and make cleanup safe after partial failures. - Use option fixtures for configurable domain inputs and regular fixtures for asynchronous lifecycle work.
- Keep secrets out of source, test titles, fixture titles, console output, and attachments.
- Assert critical setup outcomes. A login click without a post-login assertion creates confusing downstream failures.
- Test the fixture contract with a small smoke spec before hundreds of tests depend on it.
Also review retries carefully. A retry gets a clean worker after failure, so fixture setup may run again. Provisioning must tolerate that behavior and create independent data. The Playwright retry annotation guide explains how retry attempts and runtime metadata interact.
Interview Questions and Answers
Q: What does it mean to override a Playwright fixture?
It means extending a Playwright test object and defining a fixture with a name that already exists. The override can depend on the base fixture, wrap it with setup and teardown, and pass the resulting value through use(). Tests importing the extended test receive the overridden behavior.
Q: Why must a fixture call and await use()?
use() separates setup from teardown and hands the fixture value to dependent fixtures or the test. Awaiting it pauses the fixture while the consumer runs. When it completes, teardown code executes even if the consumer failed.
Q: When is authenticatedPage better than overriding page?
Use authenticatedPage when authentication is optional, role-specific, or surprising for a normal page. The descriptive name exposes the precondition at the test signature. Override page only when authentication is an invariant of that entire extended test object.
Q: What is the difference between test-scoped and worker-scoped fixtures?
A test-scoped fixture is created for each test and is the default. A worker-scoped fixture is created once per worker process and can be reused by multiple tests in that worker. Worker fixtures reduce repeated setup but require strict control of shared state.
Q: How does Playwright decide fixture execution order?
It resolves the dependency graph from the fixtures requested by the test and automatic fixtures. Dependencies set up before their consumers. Teardown happens in reverse dependency order, and unused non-automatic fixtures are not initialized.
Q: Can test.use() replace a fixture?
Yes, test.use() can configure test options and can override fixtures for its allowed file or describe scope. It cannot be called inside beforeEach or beforeAll. Reusable lifecycle behavior usually belongs in test.extend() so it remains typed and centralized.
Q: What problem does mergeTests() solve?
It combines independently extended test objects so a spec can consume their fixtures through one test. It is useful for modular capabilities such as database and accessibility utilities. Conflicting foundational overrides should be resolved before merging.
Common Mistakes
- Importing
testfrom@playwright/testin a spec that expects the custom override. Import from the fixture entry point instead. - Forgetting
await use(value), which breaks the fixture lifecycle and prevents the test from receiving the value correctly. - Creating a new context in a
pageoverride without closing it. Prefer the existing isolated page or own the full lifecycle explicitly. - Logging every user in through the default page, then discovering that anonymous and authentication tests cannot start cleanly.
- Sharing mutable accounts or records in worker scope without unique per-test data.
- Performing setup through fixed sleeps. Use web-first assertions, response checks, and application readiness signals.
- Swallowing provisioning or cleanup failures, which leaves contaminated environments and misleading reports.
- Merging fixture modules that both redefine the same core fixture with incompatible assumptions.
- Reading secrets directly in many specs instead of providing a controlled, redacted fixture contract.
Conclusion
The safest Playwright test fixtures override examples preserve the meaning of the fixture they replace, use await use() as the lifecycle boundary, and keep isolation intact. Override page for true suite invariants, use option fixtures for typed configuration, and create newly named fixtures whenever the behavior represents a distinct capability.
Start with the minimal navigation override, add an explicit teardown assertion, and review whether consumers would be surprised by the result. That small design test prevents fixture convenience from turning into invisible coupling.
Interview Questions and Answers
Explain the Playwright fixture override lifecycle.
The runner resolves dependencies and executes setup before await use(value). The test or dependent fixture receives that value while the provider is paused. After the consumer completes, teardown runs after use(), and dependent fixtures are torn down before their dependencies.
When would you override page instead of creating authenticatedPage?
I would override page only when authenticated state is an invariant for every consumer of that extended test object. If authentication is optional or role-specific, I would create authenticatedPage or a role-named fixture. That keeps the test signature honest and protects anonymous coverage.
How do test-scoped and worker-scoped fixtures differ?
Test-scoped fixtures are created independently for each test and are the default. Worker-scoped fixtures are created once per worker process and reused by tests assigned to that worker. Worker scope reduces setup cost but requires careful control of shared external state.
Why is await use() required in a fixture?
It hands the fixture value to the consumer and marks the boundary between setup and teardown. Awaiting it keeps the provider alive during the consumer execution. Code after it can reliably release resources even when the test fails.
How would you make a fixture safe for retries?
I would generate unique external identifiers, make provisioning and deletion idempotent, and avoid relying on module memory. A failed worker is replaced, so setup can run again in another process. I would also assert setup outcomes and attach sanitized resource identifiers for diagnosis.
What are option fixtures used for?
Option fixtures expose typed configuration values that can be set in config, projects, or test.use(). They are good for domain inputs such as region or start path. Asynchronous resource lifecycle belongs in a regular fixture that can depend on the option.
What risk does mergeTests() introduce?
Merging modules with conflicting fixture names can make the final behavior unclear, especially when both override page. I keep foundational ownership in one layer and give feature capabilities unique names. The merged entry point should have an explicit, reviewable contract.
Frequently Asked Questions
How do I override the page fixture in Playwright?
Create an extended test with base.extend() and define page as an async fixture that depends on the base page. Perform setup before await use(page), then put any teardown after that call. Specs must import test from the extended fixture module.
Can a Playwright fixture use the fixture it overrides?
Yes. An override of page can request page in its dependency object, and Playwright supplies the base implementation. The override can prepare that value and pass it to downstream consumers through use().
Should login happen in an overridden page fixture?
Only when every test using that extended test object is defined as authenticated. If anonymous or role-specific scenarios share the same suite, an authenticatedPage or adminPage fixture makes the precondition clearer.
What is the difference between a fixture and beforeEach in Playwright?
A fixture is dependency-driven, lazy by default, scoped, typed, and has an explicit setup and teardown boundary. A beforeEach hook runs for tests in its lexical suite scope and is often appropriate for small scenario-local preparation.
When should a Playwright fixture use worker scope?
Use worker scope for expensive resources that multiple tests in one worker can safely share, such as a unique account or service client. Do not share mutable scenario state unless collisions are prevented.
Can I combine fixtures from different Playwright modules?
Yes. mergeTests() combines independently extended test objects into one test export. Resolve conflicting overrides of foundational fixtures before merging so the final page or context behavior remains predictable.
Does fixture teardown run after a failed test?
Playwright resumes the fixture after await use() when the consumer finishes or fails, allowing teardown to run. Cleanup should still tolerate partial setup, already deleted data, and external service failures.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright route continue with override: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright test retry annotation: Examples and Best Practices