In Playwright, how do you decide what becomes a custom fixture and what stays in test.beforeEach?
answer
- Ask what the setup hands to the test
- Ask who pays for it
- Ask where the cleanup lives
- Reach across files decides it
- Hooks can still name fixtures
basics
~20 sSetup 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 sThe 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 linesimport { 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
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.
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.
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.
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