As soon as there are many tests, a question surfaces: where to prepare the dependencies scenarios need - an authenticated user, test data, a page object? Laying out creation and cleanup in each test by hand is a path to duplication and forgotten cleanup. Playwright solves this with fixtures: a fixture is a typed lifecycle of a dependency the test gets ready via an argument, without worrying about how it is created and when it is cleaned up.
The mechanism is built on test.extend. The base test is extended by describing a set of fixtures, and each is a function of the form async ({ ...dependencies }, use) => {}. Everything before the use call is preparation, use(value) itself hands the value to the test, and the code after use is cleanup that runs when the test finishes. So creation and deletion live in one place, and the test simply asks for the needed fixture by name in its argument.
The key property is that fixtures see each other. The user fixture can depend on request, and dashboard on page, by declaring them in its argument; Playwright builds the order itself and passes the ready value. This is dependency injection for tests: the scenario declares what it needs - a user and the dashboard page - and the infrastructure assembles the dependency graph and hands everything ready, already with cleanup configured.
It helps to see once how fixtures come together into one module. Below is a file where base.extend describes user (created via the API, deleted after the test) and dashboard (a page object over page), with expect re-exported alongside. Tests then import this extended test instead of the base one - and get their dependencies via an argument. You return to this form when adding a new shared dependency to the project.
Fixtures have a scope, and its choice matters. A test-scoped fixture is created anew for each test - this is the default and the right choice for everything mutable: a fresh user, its own data, clean state. A worker-scoped one is created once per worker and lives its whole run - for the expensive and immutable that is fine to share between tests of one process, like a heavy client or connection.
The boundary between scopes runs along mutability. You must not put mutable test data into a worker-scoped fixture: if tests in one worker share a mutable user or order, they will start affecting each other and failing depending on order - the very flakiness isolation was meant to remove. In worker scope - only the expensive and read-shared; everything a test changes goes into test scope, fresh on each run.
Automatic fixtures are separately useful. An automatic fixture attaches to every test without an explicit request in the argument - handy for cross-cutting infrastructure: enable log collection, attach diagnostic attachments on failure, configure the environment. The test does not know about it and need not, while it does its work around every scenario - the ideal place for what all tests need the same way.
The typical fixture failures are predictable. Mutable data in a worker-scoped fixture - races and order dependence within the worker. Cleanup smeared across the test body instead of the code after use - forgotten cleanup on a mid-test failure. And manual creation of the same dependencies in every test instead of a fixture - duplication that falls apart at the first change. Prepare dependencies with fixtures, keep the mutable test-scoped, and cleanup after use.
import { test as base } from '@playwright/test'
type Fixtures = {
user: { id: string; email: string }
dashboard: DashboardPage
}
export const test = base.extend<Fixtures>({
user: async ({ request }, use) => {
const user = await createUser(request)
await use(user) // hand it to the test
await deleteUser(request, user.id) // cleanup after the test
},
dashboard: async ({ page }, use) => {
await use(new DashboardPage(page))
},
})
export { expect } from '@playwright/test'