tldr: A Playwright fixture is a piece of test environment that is set up before your test runs and torn down after, with isolation guaranteed per test. The built-ins (page, context, request) are why Playwright tests do not leak state into each other, and custom fixtures via test.extend() are the cleanest way to share setup across a suite. The expensive mistake is worker scope: it trades isolation for speed, and the bill arrives as shared-state flake.
Every Playwright test you have ever written already uses fixtures. The { page } in async ({ page }) => {...} is one: a fresh, isolated page built for that test and disposed afterward. The framework's whole isolation story runs through this mechanism, which is why understanding it pays off well beyond tidier code.
This page covers how fixtures in Playwright actually work: the built-ins you already depend on, writing your own test fixtures, what fixture scope costs, and why fixtures usually beat a page-object base class. Every code sample below was executed against Playwright 1.62.1 before publishing.
What a fixture is, and why it is not just beforeEach
A fixture bundles three things a beforeEach hook keeps separate: the setup, the teardown, and the value your test receives. The test declares what it needs in its arguments, and Playwright builds exactly that, in dependency order, per test.
Two properties fall out of this design. First, isolation: each test gets its own instances, so a failure in one test cannot poison the next through shared state. Second, laziness: a fixture only runs if a test actually asks for it, where a beforeEach runs for everything in the file whether needed or not. In a large suite, that difference is measurable setup time.
The built-ins you already use
| Fixture | What it provides |
|---|---|
page | An isolated page for this test run |
context | The isolated browser context that page belongs to |
browser | The browser instance, shared across tests to save resources |
browserName | The current browser: chromium, firefox, or webkit |
request | An isolated APIRequestContext for API calls in tests |
Note the asymmetry: page and context are per test, browser is shared per worker. That split is the scope system doing its job, and it previews the tradeoff below.
Writing a custom fixture
test.extend() produces a new test object with your fixture available everywhere. The pattern that matters is setup before use(), teardown after:
import { test as base, expect } from '@playwright/test';
import { TodoPage } from './todo-page';
type MyFixtures = { todoPage: TodoPage };
export const test = base.extend<MyFixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto(); // setup
await use(todoPage); // the test runs here
await todoPage.removeAll(); // teardown, even if the test failed
},
});
Tests then declare it like a built-in: test('adds an item', async ({ todoPage }) => {...}). Fixtures can depend on other fixtures (this one uses page), and Playwright resolves the graph. Teardown after use() runs even when the test fails, which is what makes fixtures the right home for cleanup that must not be skipped.
You can also override a built-in. A common one is a page that always starts at baseURL:
export const test = base.extend({
page: async ({ baseURL, page }, use) => {
await page.goto(baseURL);
await use(page);
},
});
Fixture scope, and the cost of getting it wrong
The default scope is test: fresh instance per test. The alternative is worker: one instance shared by every test that worker runs.
export const test = base.extend<{}, { account: Account }>({
account: [async ({}, use, workerInfo) => {
const account = await createAccount(`user-${workerInfo.workerIndex}`);
await use(account); // shared by all tests in this worker
}, { scope: 'worker' }],
});

Worker scope exists for setup too expensive to repeat: a provisioned account, a seeded database, an authenticated session. Used well, it cuts minutes off a suite.
Used casually, it is where flake comes from. A worker-scoped resource is shared mutable state. If one test changes the account's plan and another asserts on pricing, the outcome now depends on execution order, and the failure only appears in parallel runs. This is precisely the class of bug that Playwright 1.62's retryStrategy: 'isolated' was built to expose: a test that fails in the parallel run and passes when retried alone is not flaky, it has a worker-scope collision. The rule that holds up in practice: worker scope for resources tests only read, test scope for anything a test mutates. The workerIndex in the example is the standard trick for keeping parallel workers out of each other's data.
Fixtures and the page object model
Teams often carry a page-object base class from Selenium habits: a constructor that logs in, navigates, and exposes helpers. Fixtures do that job with less machinery. The todoPage fixture above is a page object plus its lifecycle, and composing fixtures replaces inheritance chains: a test that needs todoPage and adminApi declares both, and each carries its own setup and teardown independently.
The practical guidance is not either-or. Keep page objects as plain classes that know their selectors and actions, and let fixtures own construction and lifecycle. This is also the layer where natural-language testing engines sit; Passmark, the open-source engine under Bug0, effectively replaces hand-built fixture-and-page-object plumbing with steps resolved by AI against the live page.
FAQs
What is a fixture in Playwright?
A unit of test environment with setup, teardown, and a value, built fresh for each test that declares it. The built-in page, context, and request are fixtures, and test.extend() adds custom ones. Fixtures are why Playwright tests are isolated by default.
What is the difference between a fixture and beforeEach?
A beforeEach hook runs for every test in its scope and shares values through closure variables. A fixture runs only for tests that declare it, delivers its value as a typed argument, and pairs teardown with setup in one place. Fixtures also compose through dependencies, which hooks cannot.
What is a worker-scoped fixture?
One instance shared by all tests in a worker process instead of per test, declared with { scope: 'worker' }. Use it for expensive read-only resources like accounts or seeded data. Anything a test mutates belongs in test scope, or parallel runs will produce order-dependent failures.
Can fixtures depend on other fixtures?
Yes. A fixture lists the fixtures it needs in its first argument, exactly like a test does. Playwright resolves the dependency graph and builds them in order, so a todoPage fixture can consume page, and a login fixture can consume both.
How do I override a built-in fixture?
Pass the same name to test.extend(). The override receives the original as a dependency, wraps it, and calls use(). Navigating page to baseURL before every test is the canonical example; anything from viewport tweaks to response stubbing works the same way.
