Fixtures, Hooks, and Parameterization
Playwright's fixture system as dependency injection for tests, how it composes cleanly where hooks don't, and running the same test logic across many data variations.
What you'll learn
- Explain what a fixture provides that a beforeEach hook does not
- Define a custom fixture with proper setup and teardown
- Parameterize a test across a set of input values without duplicating test logic
Prerequisites
Explanation
A Playwright fixture is a named, reusable piece of test setup (and teardown) that a test declares it needs by naming it as a parameter: test("...", async ({ page, myFixture }) => { ... }). page itself is a built-in fixture — every test gets a fresh one automatically, without ever writing setup code for it. A custom fixture is defined once (test.extend({ apiClient: async ({ request }, use) => { const client = await request.newContext(); await use(client); await client.dispose(); } })) and can then be requested by name in any test, with Playwright automatically running its setup before the test body and its teardown after — even if the test fails or throws.
This is genuinely dependency injection, and it composes in a way beforeEach/afterEach hooks structurally cannot: a fixture can itself depend on other fixtures (an authenticatedPage fixture built from the base page fixture plus a sign-in step), and only the specific tests that actually request a given fixture pay its setup cost — a beforeEach hook, by contrast, runs unconditionally for every test in its scope, whether or not that particular test needs whatever it sets up. A test suite with many hooks accumulated over time, each added for one specific test's needs but now running before every test in the file, is a common, real source of slow, hard-to-reason-about suites — fixtures avoid this by making each test's actual dependencies explicit and opt-in.
Parameterization — running the same test logic across many input values — avoids copy-pasting a test body with only the input changed: a loop over ["learner", "instructor", "admin"] calling test( + role + sees the correct dashboard, async ({ page }) => { ... }) for each one generates one distinct, individually-reportable test per role, sharing one body. This is the same principle as a data-driven test in any testing framework — a real bug found in the "admin" case shows up as a failure specifically labeled "admin," not as one generic failure requiring you to guess which of three cases actually broke.
Example
Modeling fixture composition (a fixture built from another fixture) and the opt-in-per-test cost this gives you over a blanket beforeEach.
// A simplified fixture system: each fixture is a function that sets up, yields a value, then tears down.
async function pageFixture(use) {
const page = { closed: false };
await use(page);
page.closed = true; // teardown, always runs after the test body, even on failure
}
async function authenticatedPageFixture(use) {
await pageFixture(async (page) => {
page.signedIn = true; // setup built ON TOP of the base page fixture
await use(page);
});
}
async function runTest(name, fixture, testBody) {
await fixture(async (resource) => {
console.log("running:", name);
await testBody(resource);
});
}
runTest("uses base page fixture", pageFixture, async (page) => console.log("signedIn:", page.signedIn));
runTest("uses authenticated fixture", authenticatedPageFixture, async (page) => console.log("signedIn:", page.signedIn));Try it yourself
Add a third layer -- an adminPageFixture built on top of authenticatedPageFixture -- and confirm the composition still works.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Write runWithFixture(setup, teardown, testBody) modeling a fixture's lifecycle: call setup() to get a resource, run testBody(resource), then ALWAYS call teardown(resource) afterward -- even if testBody throws (use try/finally). Return whatever testBody returned, or re-throw its error after teardown still ran.
Checks: runs the test body and tears down on success, returning the result · tears down even when the test body throws, and still propagates the error
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write parameterizedTestNames(baseName, values) returning an array of test-name strings, one per value, in the form `${baseName} - ${value}` -- modeling how a parameterized loop generates distinct, individually-reportable test names instead of one generic test covering every case silently.
Checks: generates a distinct, correctly-labeled name per value · handles an empty values array
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Common mistakes
- Using beforeEach for setup that only a few specific tests actually need -- it runs unconditionally for every test in scope, paying its cost even for tests that don't use it; a fixture makes the dependency explicit and opt-in.
- Writing setup logic without matching teardown logic, or without using try/finally -- a fixture (or manual code) that doesn't guarantee cleanup on failure can leak resources across a long test run.
- Copy-pasting a test body three times with only one value changed, instead of parameterizing -- this triples the maintenance burden for any future change to the shared logic, and often lets the three copies quietly drift out of sync.
Knowledge check
Takeaway
Fixtures are dependency injection for tests — opt-in, composable, and guaranteed to tear down even on failure — while beforeEach runs unconditionally for every test in scope; parameterization keeps shared test logic in one place while still reporting each input case as its own distinct, individually-failing test.
Summary
A fixture provides named setup/teardown a test explicitly requests as a parameter, composing cleanly (fixtures can depend on other fixtures) and guaranteeing teardown even on failure. beforeEach runs for every test in scope unconditionally. Parameterizing a test loop generates one distinct, individually-reportable test per input value instead of duplicating test bodies.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.