skip to content

In Playwright, how do you decide what becomes a custom fixture and what stays in test.beforeEach?

level: principalimportance: should knowfreq 40%

answer

  1. Ask what the setup hands to the test
  2. Ask who pays for it
  3. Ask where the cleanup lives
  4. Reach across files decides it
  5. Hooks can still name fixtures

basics

~20 s

Setup that produces something the test uses belongs in a fixture: it names what it provides, pairs its own teardown, and runs only for tests that ask for it. Local arrangement for one file can stay in a hook.

solid answer

~50 s

The mechanical difference drives the decision. A fixture **provides a named value** into the test signature, is **opt-in per test**, and keeps setup and teardown in one function; `test.beforeEach` runs for every test in its scope, returns nothing, and needs a matching `afterEach` somebody can delete by accident. So: anything that creates a resource, anything several spec files need, and anything with cleanup is a fixture. A one-file arrangement that produces no value — collapsing a sidebar three tests want closed — is fine as a hook, and hooks can name fixtures anyway, so the two compose. The cost of a fixture is indirection: a shared extended `test` is a module every spec imports and a second file a newcomer must open. Judge by reach — suite-wide vocabulary earns a fixture, local arrangement does not.

code

typescript · 23 lines
typescript
import { test as base, expect, type Page } from '@playwright/test';

type Fixtures = { supervisorConsole: { page: Page } };

export const test = base.extend<Fixtures>({
  supervisorConsole: async ({ page }, use) => {
    await page.goto('/admin/sign-in');
    await page.getByLabel('Staff email').fill('[email protected]');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use({ page });
  },
});

test('only a supervisor sees the seat-revocation control', async ({ supervisorConsole }) => {
  await expect(
    supervisorConsole.page.getByRole('button', { name: 'Revoke seat' }),
  ).toBeVisible();
});

test('the public audit screen needs no session', async ({ page }) => {
  await page.goto('/admin/audit/public');
  await expect(page.getByRole('heading', { name: 'Public audit log' })).toBeVisible();
});

go deeper

for a junior

Use the rule of thumb: if the setup produces something the test uses, make it a fixture; if it only arranges state for one file, a beforeEach hook is fine.

for a middle

Explain the mechanical difference. A fixture provides a named value into the test signature and runs only for tests that ask for it, while a hook runs for its whole scope and hands back nothing.

for a senior

Argue from the suite: fixtures make each test's dependencies visible in its signature and pair teardown with setup, but every fixture is an indirection a reader must open a second file to understand.

for a principal

Own the boundary between shared vocabulary and local arrangement, because a shared test object is a dependency every spec inherits and its blast radius grows with each fixture added to it.

## The mechanical differences that decide it | | Custom fixture | `test.beforeEach` | |---|---|---| | Provides a value | yes, by name, into the test's arguments | no, only side effects | | Who pays for it | only tests that name it | every test in its scope | | Teardown | the lines after `use`, in the same function | a separate `afterEach` | | Composition | fixtures can name other fixtures | hooks can name fixtures, not other hooks | | Discoverability | visible in each test's signature | invisible unless you read the file's top | | Reach | any file importing the extended `test` | the file or describe block it is written in | Everything below follows from that table rather than from taste. ## A decision procedure 1. **Does the setup produce something the test uses?** If yes, a fixture — that is the only way to hand a typed value into the test's arguments without a module-level variable. 2. **Does it need cleanup?** If yes, a fixture, so the create and the destroy cannot drift apart. 3. **Do several spec files need it?** If yes, a fixture, because a hook cannot be shared without exporting a function everybody remembers to call. 4. **Do only some tests in the file need it?** If yes, a fixture, because it runs only for the tests that name it while a hook runs for all of them. 5. **Otherwise, leave it in the hook.** Local arrangement that produces nothing is clearer where it happens. ## What a fixture costs - **Indirection.** The test says `async ({ supervisorConsole }) => {}` and a reader must open the fixture module to learn what state that implies. - **A shared dependency.** One extended `test` imported by the whole suite means any change to it has suite-wide blast radius, and a new fixture added there is one more thing every spec's types drag in. - **A tempting dumping ground.** Once the module exists, unrelated setup accretes into it, and eventually one fixture provisions everything because that was easier than adding a second. - **Harder onboarding.** A newcomer reading one spec no longer sees the whole story in one file. ## What a hook costs - **No value.** Anything the hook created has to reach the test through a variable in module scope, which is invisible in the signature and awkward once tests run in parallel. - **Split teardown.** The matching `afterEach` sits somewhere else and can be deleted, reordered or forgotten independently. - **All-or-nothing.** Every test in the scope pays, including the ones that would rather start from a clean slate. - **No reuse.** Two files needing the same arrangement means two hooks, or an exported helper each hook calls. ## Hooks and fixtures are not rivals `test.beforeEach(async ({ adminShell }) => { ... })` receives fixtures the same way a test does. The realistic end state for a suite is both: fixtures own the resources and the values, and a hook in one spec file does the small local arrangement those tests happen to share. Choosing a fixture does not mean deleting every hook. ## Where the line usually lands in practice For an internal admin console with several staff roles: - **Fixture:** provisioning a staff seat, signing in as a given role, capturing the audit trail, anything that must be removed afterwards. - **Fixture:** anything two or more spec files need, even when it is small — the second copy of a hook is the signal. - **Hook:** navigating to the one screen this file is about, dismissing a banner, setting a viewport those particular tests share. - **Neither:** setup only one test needs. Write it in the test, where a reader will find it. ## The judgment part The real tension is between visibility and reuse. Every fixture you promote to the shared module buys reuse and pays a little readability, and a suite that promotes everything ends up with tests whose starting state cannot be reconstructed from the file you are looking at. A useful rule is to require a **second caller** before a hook becomes a shared fixture, and to keep fixtures single-purpose so a test can name exactly the two it wants instead of accepting a bundle it did not ask for.

  • What is the cost of turning every hook into a fixture?
    Every spec imports one growing `test` module, so a change there reaches the whole suite, and a reader can no longer reconstruct a test's starting state from the file in front of them. Keep single-file arrangement in `beforeEach` and promote to a fixture when a second caller appears.
  • Can a beforeEach hook use your custom fixtures?
    Yes. `test.beforeEach(async ({ adminShell }) => {})` destructures fixtures exactly as a test does, so hooks and fixtures compose rather than compete. That is what lets a spec keep its local arrangement in a hook while the resources it needs still come from fixtures.
  • How do you keep a shared fixture module from becoming a dumping ground?
    Keep fixtures single-purpose so tests can name the two they want, require a second real caller before promoting anything, and resist adding an all-in-one fixture that provisions everything — the moment one exists, every test starts paying for setup it never asked for.

saying these in an interview costs you the question

  • Says fixtures and beforeEach hooks are interchangeable everywhere
  • Moves all suite setup into one fixture every spec imports
  • Claims a beforeEach hook cannot use custom fixtures
  • Uses module-level variables to pass a hook's value to tests
  • Promotes setup to a shared fixture with only one caller