"A custom fixture is the boilerplate you don't have to write fifty times."
What Fixtures Already Give You
Every Playwright test destructures fixtures from its first argument: test('thing', async ({ page, context, request }) => {...}). page, context, request are built-in fixtures. Playwright creates a fresh page (and context, by default) per test, so isolation is automatic.
What's less obvious is that you can extend the fixture set. A custom fixture is a small piece of setup-teardown logic that yields a value into your test. The value can be anything — a logged-in page, a seeded database connection, a feature-flag overrider.
The test.extend Pattern
To create a custom fixture, you call test.extend({ fixtureName: async ({ deps }, use) => { ... } }). Inside the fixture function, you set up the value, call await use(value) to hand it to the test, then run teardown after the test finishes. The shape is identical to React's useEffect cleanup pattern — setup, yield, cleanup.
The returned test object is a NEW test runner that has both the built-in fixtures AND yours. You import this from your test files instead of @playwright/test.
Test-Scoped vs Worker-Scoped Fixtures
Fixtures have a SCOPE — how often they get set up and torn down:
- test scope (default) — runs before every test, tears down after. Use for things that need fresh state per test (a logged-in page, a clean DB).
- worker scope — runs once per worker process. Use for expensive setup that can be shared (a database connection, an API client). The fixture is reused across all tests in that worker.
Pick worker scope only when reuse is safe. A shared DB connection is fine; a shared 'logged-in user' state is not — multiple parallel tests writing to the same session is a bug.
await ensureLoggedIn(page); await seedThreeUsers();, fold both into a seededLoggedInPage fixture and just write test('...', async ({ seededLoggedInPage }) => {...}). The intent surfaces; the ceremony disappears.Parallel Execution Model
Playwright runs tests in PARALLEL by default. Two dimensions of parallelism:
- Across files — multiple test files run in separate worker processes simultaneously. Controlled by the
workersconfig option (default: half your CPU cores). - Within a file — set
test.describe.configure({ mode: 'parallel' })at the top of a file (orfullyParallel: truein config) and tests within the same file also run in parallel across workers.
Default behavior is conservative: parallel across files, serial within. Set fullyParallel: true globally if your tests are well-isolated; turn it off per-file with mode: 'serial' when a sequence inside one file must order strictly.
The Isolation Contract
Parallel tests require isolation. Playwright gives you the browser-side isolation (separate contexts), but the server / database side is your responsibility. Strategies:
- Per-test database row scoping (every test uses unique data).
- Per-worker database (each worker connects to its own DB).
- Per-test transaction rollback (each test runs in a transaction that's rolled back at the end).
Without server-side isolation, your parallelism caps at 1 — and you've thrown away most of Playwright's speed.