11.7 — Fixtures & hooks
Since 11.1 you’ve typed async ({ page }) without asking the obvious question: you never created page — who hands it in? Fixtures: prepared resources the runner builds and injects by name — each test receiving a genuinely FRESH browser world. 10.6’s dependency injection, promoted to infrastructure.
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("search filters the list", async ({ page }) => {
// page: fresh browser context, navigated by the hook
});
// a CUSTOM fixture: a logged-in page
const testWithShopper = test.extend({
shopper: async ({ page }, use) => {
await page.goto("/login"); // setup
await page.getByLabel("Email").fill("t@shop.com");
await page.getByRole("button", { name: "Log in" })
.click();
await use(page); // the test runs
// teardown would go here
},
});
testWithShopper("cart persists",
async ({ shopper }) => { /* born logged in */ });Start with the mystery hiding in plain sight: every test signature says async ({ page }) — 4.11’s destructuring — but no line of yours ever created a page. Something is building it and passing it in. That something is the fixture system, and it’s the runner’s deepest idea.
The deeper story, with the real names for things — this part is what turns “I saw it” into “I can explain it.”
Fixtures form a dependency GRAPH (8.1’s picture, again): your shopper uses page, page uses context, context uses browser. The runner resolves the chain lazily and tears it down in reverse order — construction and destruction as mirror images, guaranteed.
Fixtures have scopes: the default is per-test, but { scope: "worker" } builds once per worker process (11.15) — right for expensive resources safe to share across tests in one worker, like a database connection. Per-test freshness stays the default because isolation is the prize.
The UI-login-per-test shown here is honest but expensive — which is exactly why 11.11 exists: log in ONCE, bottle the session (storageState), and hand every context the bottle. The shopper fixture then becomes a one-liner that loads state instead of driving the login form. Same injection shape, hundred× cheaper.
Job note: reading an unfamiliar suite, open its fixtures.ts first — it’s the cast of characters. A suite’s maturity is visible there: good ones read like a menu of prepared worlds (shopper, admin, emptyCart, seededCatalog); bad ones have one god-fixture doing everything for everyone.
⌨️ build the fixture sandwich
The use() pattern in miniature: a fixture that sets up, hands the resource to the test, and tears down — GUARANTEED, in that order, with the test as the filling.
requirements:
- Write
shopperFixture(testFn): printsetup: log in, build the resourceconst shopper = "logged-in page", calltestFn(shopper)(the use() moment), then printteardown: log out. - Write two “tests” as functions: one printing
test A sees: logged-in page(build it from the parameter — 3.8’s functions-as-values carry the day), one printingtest B sees: logged-in page. - Run both through the fixture — each test gets the full sandwich: setup, filling, teardown, twice over.
when you press RUN, the console must show exactly:
✏️ Quick check 1
Test 1 sets a cookie and adds items to localStorage. What does test 2’s page see in them?
✏️ Quick check 2
In a custom fixture, code written AFTER await use(page) runs when?
✏️ Quick check 3
A test’s parameters say only ({ page }). Does the shopper fixture’s login run for it? Type yes or no.
🗣️ Now teach it back
Solve the { page } mystery for a friend: what a fixture is, what “fresh context per test” means and why it’s cheap, hooks vs fixtures, and the use() sandwich (with why teardown-after-use beats a separate afterEach).
Write it as if your friend is sitting next to you. Saved to your journal — future-you will use these notes to teach others.