A fixture prepares a resource, gives it to a test, and may clean it afterward. Playwright’s built-in page is a fixture.
A stage crew prepares a microphone, the singer uses it, and the crew removes it.
fixture setup → test receives resource → test runs → cleanup
Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com
You’ve been using a fixture since Part 6 without formally naming it: { page } in every single test you’ve written is a fixture, provided to you automatically by Playwright. This part makes that mechanism explicit, and then teaches you to build your own — one of the genuine turning points between “writing tests” and “building a framework.”
What a Fixture Actually Is, and Why It Exists
Recall your original spec’s analogy: a fixture is like preparing a clean workstation for every employee before their shift starts — the tools they need are already laid out, ready to use, without them having to gather everything themselves each time.
Concretely: every test function you write receives { page } (and, as you’ve now seen in Part 10, { context }) as an argument. Where does this actually come from? Playwright’s test runner, before running your test function at all, creates a browser context, creates a page within it, and hands both to your test — automatically, without you ever writing that setup code yourself. page is a built-in fixture.
Now consider a genuine, common need: nearly every test in your SauceDemo suite needs a logged-in user. Part 14’s beforeEach hook is one reasonable way to handle this. But fixtures offer something meaningfully more powerful and more explicit: a way to define “a page that’s already logged in” as its own reusable, named building block — one that any test can simply ask for by name, and receive, fully prepared, without needing to know or care how the login actually happens internally.
Analogy: The Composable Magnetic Tool Belt
- Hooks Setup (The Heavy Toolbox): Standard hooks are like carrying a large, heavy toolbox to every task. Even if you only need a screwdriver, you carry the whole toolbox, open it, rummage through, and clean up the entire box afterward.
- Fixtures (The Composable Tool Belt): You wear a magnetic tool belt. When a test starts, it declares exactly what tools it needs: “I need the authenticatedPage tool.” Playwright reads this request and snaps that tool onto your belt. The
authenticatedPagetool itself relies on thepagetool, which relies on thebrowsertool. Playwright links these dependencies together automatically and hands you the fully assembled tool. If a test doesn’t ask for a tool, it is never loaded, saving time.
📊 Visual Flowchart: Fixture Dependency Resolution Graph
Here is how Playwright builds layered test dependencies automatically based on parameters:
graph TD
subgraph BrowserEngine ["Browser Engine Layer (Built-in)"]
Browser["browser (Launch Instance)"] --> Context["context (Isolated Cookies)"]
Context --> Page["page (Browser Tab/Page)"]
end
subgraph CustomFramework ["Custom Framework Layer"]
Page --> AuthPage["authenticatedPage<br>(Logs in standard_user)"]
AuthPage --> CartPage["cartWithOneItem<br>(Adds backpack to cart)"]
end
Test["test('checkout flow', async ({ cartWithOneItem }) => ...)"] -.->|Request parameter| CartPage
Test-Scoped vs. Worker-Scoped Fixtures
Before building a custom fixture, it’s worth understanding scope, because it’s the single most consequential decision you make when defining one, and getting it wrong silently undermines test isolation (Part 6’s whole reason for browser contexts existing in the first place).
A test-scoped fixture is created fresh for every single test — exactly like page itself, which is a new instance every time.
A worker-scoped fixture is created once per worker — a worker being one of the parallel processes Playwright uses to run multiple tests simultaneously (a full, proper explanation of workers and parallelization is coming in Part 27; for now, think of a worker as one “lane” of test execution, potentially running many tests, one after another, within that same lane).
The trade-off is genuinely important to reason through, not just memorize: a worker-scoped fixture is more efficient — expensive setup (like, say, seeding a database with test data, or authenticating once) happens only once per worker, not once per test — but it’s shared across every test that runs in that same worker, meaning any state that fixture holds is not isolated between those tests. A test-scoped fixture is created fresh every single time, so it’s fully isolated, but potentially repeats expensive setup work unnecessarily for every test.
Test-scoped: Test 1 → new fixture → Test 2 → new fixture → Test 3 → new fixture
Worker-scoped: ─────────── one fixture, shared ───────────
Test 1 Test 2 Test 3 (same worker)
Building a Custom Fixture
Here’s a genuinely realistic, common one: an authenticatedPage fixture that hands every test an already-logged-in page, so no individual test file needs to repeat login steps at all — not even via beforeEach.
// fixtures.ts
import { test as base, expect } from "@playwright/test";
type MyFixtures = {
authenticatedPage: import("@playwright/test").Page;
};
export const test = base.extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
// SETUP — runs before the test that uses this fixture
await page.goto("/");
await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill("secret_sauce");
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByText("Products")).toBeVisible();
// Hand control to the actual test, passing it the prepared page
await use(page);
// TEARDOWN — runs after the test finishes, whether it passed or failed
// (nothing needed here for this example, but this is exactly where cleanup would go)
},
});
export { expect };
// login-not-needed.spec.ts
import { test, expect } from "./fixtures";
test("logged-in user can add item to cart", async ({ authenticatedPage }) => {
// No login steps here at all — the fixture already handled it
await authenticatedPage
.getByText("Sauce Labs Backpack")
.locator("..")
.getByRole("button", { name: "Add to cart" })
.click();
await expect(authenticatedPage.locator(".shopping_cart_badge")).toHaveText(
"1",
);
});
Read this properly, piece by piece, since the shape here (base.extend<...>({...})) is the exact real-world application of Part 3’s generics discussion, now made concrete. base.extend(...) takes Playwright’s own built-in test object and returns a new one with your additional fixture attached to it — this is why the test file imports test from your own local fixtures.ts file rather than directly from @playwright/test.
The use parameter is the mechanism that separates setup from teardown — everything before await use(page) is setup, everything after it is teardown, and the test function itself runs during the use(...) call, receiving whatever was passed into it.
This is a genuinely different, more powerful pattern than a beforeEach hook, worth being explicit about why: a fixture is composable and reusable across files just by importing it, it can itself depend on other fixtures (fixture dependency chaining, covered next), and — importantly — it only actually runs its setup for tests that explicitly ask for it by declaring it as a parameter, whereas a beforeEach inside a describe block runs unconditionally for every test inside that block, whether each individual test genuinely needs that setup or not.
Fixture Dependencies
Fixtures can depend on other fixtures, and Playwright resolves this chain automatically:
export const test = base.extend<{
authenticatedPage: Page;
cartWithOneItem: Page;
}>({
authenticatedPage: async ({ page }, use) => {
// ... login steps as above ...
await use(page);
},
cartWithOneItem: async ({ authenticatedPage }, use) => {
// This fixture DEPENDS on authenticatedPage — Playwright runs that setup first, automatically
await authenticatedPage
.getByText("Sauce Labs Backpack")
.locator("..")
.getByRole("button", { name: "Add to cart" })
.click();
await use(authenticatedPage);
},
});
test("checkout works correctly with one item already in cart", async ({
cartWithOneItem,
}) => {
// This test starts already logged in AND with an item already in the cart —
// neither setup step needed to be written here at all
await cartWithOneItem.getByRole("button", { name: "Open Cart" }).click();
// ...
});
This chaining is genuinely powerful for building up realistic, layered test preconditions — “logged in,” “logged in with an item in cart,” “logged in with an item in cart and on the checkout page” — each building cleanly on the last, without any single test needing to know or repeat the full chain of steps required to reach its actual starting point.
How It Works in a Real Test Run
A fixture is dependency injection with lifecycle management. Playwright resolves the fixtures named by a test, creates dependencies in order, supplies the value at use, and runs teardown afterward—even when the test fails.
Test-scoped fixtures protect isolation. Worker-scoped fixtures trade isolation for setup efficiency and must not hold user-specific mutable state. The important design question is not “can this be a fixture?” but “who should own this resource, and how long should it live?”
Interview Questions
Q: What is a Playwright fixture, and what’s a concrete example you’ve already been using without necessarily naming it?
Ans: A fixture is a reusable piece of setup (and optional teardown) that Playwright’s test runner provides to a test automatically. page itself is a built-in fixture — every test receives a freshly created page, in a freshly created browser context, without the test ever writing that setup code directly.
Q: What’s the practical difference between a test-scoped and a worker-scoped fixture, and what’s the trade-off between them?
Ans: A test-scoped fixture is created fresh for every individual test, guaranteeing full isolation but potentially repeating expensive setup work unnecessarily for each one. A worker-scoped fixture is created once per worker and shared across every test that runs within that same worker, which is more efficient for genuinely expensive setup, but means any state the fixture holds is shared, not isolated, between those tests — making it inappropriate for anything that needs to be independent per test, like a fresh login session.
Q: How is a custom fixture meaningfully different from just using a beforeEach hook to achieve similar setup?
Ans: A fixture only runs its setup for tests that explicitly request it by declaring it as a parameter, is reusable and composable simply by importing it into any test file, and can itself depend on other fixtures, letting Playwright automatically resolve a whole chain of setup. A beforeEach inside a describe block runs unconditionally for every test within that block, regardless of whether each individual test genuinely needs that particular setup.
Q: In a custom fixture definition, what is the purpose of the use parameter, and what determines what counts as “setup” versus “teardown”?
Ans: Everything written before the await use(...) call is setup, executed before the actual test runs. The use(...) call itself hands control to the test function, passing along whatever value is given to it. Everything written after await use(...) is teardown, executed after the test finishes, regardless of whether it passed or failed.
Q: Why would you choose worker-scoped over test-scoped for something like authenticating a user via an API call before tests run, but not for something like adding an item to a shopping cart?
Ans: Authenticating once per worker, rather than once per test, saves real, repeated cost across many tests that all just need to be logged in, without needing that authentication itself to be test-specific or isolated in any meaningful way — the token or session can safely be reused. Adding an item to the cart, by contrast, is often specific to what an individual test is actually verifying, and sharing that cart state across multiple tests in the same worker could cause one test’s actions to unintentionally affect another test’s starting conditions, undermining isolation in a way that matters for correctness.
Q: A junior engineer on your team put authenticatedPage setup inside a worker-scoped fixture, and now tests are intermittently failing when run in parallel, seemingly interfering with each other. What would you investigate?
Ans: I’d check whether the fixture is sharing a single logged-in page (or its underlying context/session) across multiple tests running in the same worker, rather than authenticating once but still handing each test its own isolated page or context. If the same actual page object is being reused across tests, one test’s navigation or state changes could leak into another’s, causing exactly this kind of intermittent, parallel-execution-dependent failure — worker-scoped setup for something like an auth token is fine, but the resulting page or context handed to each individual test generally still needs to be test-scoped to preserve real isolation.
Exercises — Part 15
Understand:
Explain, in your own words, why page being a fixture (rather than something you manually create at the top of every test) is a meaningful convenience, tying your answer back to Part 6’s browser context discussion.
Simple Practice:
Build an authenticatedPage fixture, following the pattern in this part, and rewrite at least two tests from earlier parts of this series to use it instead of repeating login steps manually.
Real-World Scenario:
Design a fixture dependency chain for a realistic SauceDemo test scenario: a base authenticatedPage fixture, a cartWithOneItem fixture depending on it, and a readyForCheckout fixture depending on cartWithOneItem that also fills in the checkout’s shipping information. Write out the chain (code or clear pseudocode) and one test that uses only the final readyForCheckout fixture.
Challenge: Research Playwright’s own documentation on fixture scope and write a short explanation, in your own words, of a genuinely realistic scenario where a worker-scoped fixture would be the wrong choice — specifically, one where sharing that fixture’s state across tests in the same worker would cause a real, incorrect test result rather than just an efficiency trade-off.
Next: Part 16 — Configuration, Deep Dive
— projects, multiple browsers, environments, timeouts, retries, and the rest of playwright.config.ts we didn’t fully cover in Part 6.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed