page, context, request, and browser cover the basics. Real test suites rapidly need more: a logged-in admin page, a freshly-seeded test product, a typed API client with the auth token already attached. You can copy-paste setup into every test, or you can write a fixture once and have every test that asks for it inherit the setup and the cleanup. This lesson is the test.extend pattern — typed custom fixtures, fixture composition, and worker-scoped fixtures for expensive resources. It's the single highest-leverage refactor most Playwright codebases ever go through.
The test.extend pattern
Custom fixtures are added by extending the base test object. The shape:
// fixtures/index.ts
import { test as base, type Page } from "@playwright/test";
type MyFixtures = {
loggedInPage: Page;
};
export const test = base.extend<MyFixtures>({
loggedInPage: async ({ page }, use) => {
// SETUP — runs before the test that uses this fixture
await page.goto("/login");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("Sup3rS3cret!");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/dashboard/);
// PROVIDE — pass the fixture value to the test
await use(page);
// TEARDOWN — runs after the test (use storageState elsewhere if you want speed)
// For this fixture there's no specific cleanup; the page is closed by Playwright automatically
}
});
export { expect } from "@playwright/test";Three things to internalise:
- Type the fixture.
base.extend<MyFixtures>(...)makesloggedInPagetyped everywhere it's destructured. TypeScript autocompletes it; refactors are caught at compile time. use(value)is the "yield" point. Code beforeuse()is setup. Code after is teardown. The value passed touse()is what the test sees.- Ship a custom
testfrom your fixtures file. Every spec then imports{ test, expect }from your file instead of@playwright/test. The custom fixtures are now available; the original ones still work.
Using a custom fixture
// tests/dashboard.spec.ts
import { test, expect } from "../fixtures";
test("admin sees the user table", async ({ loggedInPage }) => {
await loggedInPage.goto("/admin/users");
await expect(loggedInPage.getByRole("table")).toBeVisible();
});
test("admin can navigate to settings", async ({ loggedInPage }) => {
await loggedInPage.getByRole("link", { name: "Settings" }).click();
await expect(loggedInPage).toHaveURL(/settings/);
});Both tests start logged in. Neither has a beforeEach calling login. The fixture is opt-in: tests that don't ask for loggedInPage don't pay the login cost.
Fixture with proper teardown — try/finally of the fixture world
When a fixture creates a resource that needs explicit cleanup, the structure is the same before/use/after:
type Fixtures = {
testUser: { id: number; email: string };
};
export const test = base.extend<Fixtures>({
testUser: async ({ request }, use) => {
// SETUP — create
const res = await request.post("/api/users", {
data: {
email: `test-${Date.now()}@example.com`,
password: "pw123"
}
});
const user = await res.json();
// PROVIDE
await use(user);
// TEARDOWN — always runs, even if the test failed
await request.delete(`/api/users/${user.id}`);
}
});The teardown block runs after the test, regardless of whether the test passed or threw. No try/finally boilerplate inside the test. This is the canonical "API setup + UI test + API cleanup" pattern from chapter 4, lifted into a reusable fixture.
Composing fixtures — fixtures that depend on fixtures
A fixture can take other fixtures (built-in or custom) as its dependencies:
type Fixtures = {
testUser: { id: number; email: string; password: string };
loggedInPage: Page;
};
export const test = base.extend<Fixtures>({
testUser: async ({ request }, use) => {
const password = "pw123";
const res = await request.post("/api/users", {
data: { email: `test-${Date.now()}@example.com`, password }
});
const user = await res.json();
await use({ ...user, password });
await request.delete(`/api/users/${user.id}`);
},
loggedInPage: async ({ page, testUser }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/dashboard/);
await use(page);
}
});A test that asks for loggedInPage triggers the chain: request and page exist (built-ins), testUser builds on request, loggedInPage builds on page and testUser. Playwright resolves the dependency graph automatically and tears everything down in reverse order.
The DAG visualised:
Scoping — test vs worker
By default, fixtures are test-scoped: created fresh for each test that uses them, torn down at test end. That's the right default for state that should be isolated.
For expensive setup that's safe to share (a seeded database, a started backend service, a long-lived API token), worker-scoped fixtures create the resource once per worker process and reuse it across every test in that worker:
type WorkerFixtures = {
apiToken: string;
};
export const test = base.extend<{}, WorkerFixtures>({
apiToken: [
async ({}, use) => {
// Runs once per worker
const res = await fetch("https://auth.example.com/token", {
method: "POST",
body: JSON.stringify({ client: process.env.CLIENT_ID, secret: process.env.CLIENT_SECRET })
});
const { token } = await res.json();
await use(token);
// No teardown needed for a token; if you started a server, you'd shut it down here
},
{ scope: "worker" }
]
});Note the second type parameter on extend<{}, WorkerFixtures> — that's the worker-scope slot. And the array form [fn, { scope: 'worker' }] — that's how you opt in.
Use worker scope when:
- The setup is expensive (>500ms)
- The resulting resource is safe to share — pure data or read-only services. Don't worker-scope a logged-in page; per-test contexts are how Playwright stays isolated.
A multi-fixture file in practice
Real projects keep all custom fixtures in one file:
// fixtures/index.ts
import { test as base, expect, type Page, type APIRequestContext } from "@playwright/test";
import type { Product, User } from "../types";
type Fixtures = {
testUser: User;
testProduct: Product;
loggedInPage: Page;
apiClient: APIRequestContext;
};
export const test = base.extend<Fixtures>({
apiClient: async ({ request }, use) => {
// Provide an authenticated request context
await use(request);
},
testUser: async ({ apiClient }, use) => {
const res = await apiClient.post("/api/users", {
data: { email: `u-${Date.now()}@test.com`, password: "pw123", role: "user" }
});
const user = await res.json();
await use(user);
await apiClient.delete(`/api/users/${user.id}`);
},
testProduct: async ({ apiClient }, use) => {
const res = await apiClient.post("/api/products", {
data: { name: `Test Product ${Date.now()}`, price: 29.99, stock: 10 }
});
const product = await res.json();
await use(product);
await apiClient.delete(`/api/products/${product.id}`);
},
loggedInPage: async ({ page, testUser }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/dashboard/);
await use(page);
}
});
export { expect };Then a test asking for "a logged-in user with a product to test against" looks like:
import { test, expect } from "../fixtures";
test("logged-in user can buy a test product", async ({ loggedInPage, testProduct }) => {
await loggedInPage.goto(`/products/${testProduct.id}`);
await loggedInPage.getByRole("button", { name: "Add to cart" }).click();
await loggedInPage.getByRole("link", { name: "Cart" }).click();
await expect(loggedInPage.getByText(testProduct.name)).toBeVisible();
});Three resources spin up automatically: a user (via API), a product (via API), and a logged-in browser session. Three teardowns happen automatically: product deleted, user deleted, page closed. The test body is six lines and reads like a story.
Coming from Cypress?
Custom commands (Cypress.Commands.add('login', ...)) are the closest equivalent. Two differences:
- Cypress commands attach to
cy.*and run inside the Cypress chain. Playwright fixtures are values destructured into the test signature. - Cypress has no real teardown story for commands. Playwright's
use()boundary gives you setup and teardown in one place.
If your Cypress codebase has commands like cy.loginViaApi() and cy.createProduct(), those become Playwright fixtures: testUser, testProduct. The migration is usually a clean win — the test code reads more like a story than a sequence of helper invocations.
⚠️ Common mistakes
- Forgetting
await use(...). If you writeuse(page)withoutawait, the test runs before setup completes — you get races, "element not found" errors, weird flakes. Alwaysawait use(...). ESLint with@typescript-eslint/no-floating-promisescatches this. - Putting test-specific logic in a fixture. A fixture should produce a reusable resource. If your
loggedInPagefixture also clicks "Accept cookies banner" and "dismiss the welcome modal," that's specific to one test's UI state. Keep fixtures focused; let the test decide what to do with the resource. - Worker-scoping things that share state. A worker-scoped logged-in page is a recipe for cross-test contamination — test A logs out, test B sees the logged-out state. Per-test scope is the safe default; worker scope is for immutable shared resources only (tokens, configs, seeded data).
🎯 Practice task
Write a fixtures file with three composed fixtures. 30 minutes.
-
Create
fixtures/index.tswith three fixtures:testPost(API-creates a JSONPlaceholder post),apiClient(just re-exposesrequestfor convenience), andpageWithMockedPosts(a page with the/postsendpoint pre-mocked):import { test as base, expect, type Page } from "@playwright/test"; type Post = { id: number; title: string; body: string; userId: number }; type Fixtures = { testPost: Post; pageWithMockedPosts: Page; }; export const test = base.extend<Fixtures>({ testPost: async ({ request }, use) => { const res = await request.post("https://jsonplaceholder.typicode.com/posts", { data: { title: `Test ${Date.now()}`, body: "Body", userId: 1 } }); const post = await res.json(); await use(post); // JSONPlaceholder doesn't actually persist, so cleanup is a no-op here; // in a real app you'd: await request.delete(`/posts/${post.id}`) }, pageWithMockedPosts: async ({ page }, use) => { await page.route("**/posts", async route => { await route.fulfill({ status: 200, json: [ { id: 1, title: "Mocked post 1", body: "First", userId: 1 }, { id: 2, title: "Mocked post 2", body: "Second", userId: 2 } ] }); }); await use(page); } }); export { expect }; -
Create
tests/fixtures-practice.spec.ts:import { test, expect } from "../fixtures"; test.describe("Custom fixtures practice", () => { test("uses testPost — created via API", async ({ testPost }) => { expect(testPost.id).toBeDefined(); expect(testPost.title).toContain("Test"); }); test("uses pageWithMockedPosts — fetches mocked data", async ({ pageWithMockedPosts }) => { const responsePromise = pageWithMockedPosts.waitForResponse("**/posts"); await pageWithMockedPosts.goto("https://jsonplaceholder.typicode.com/posts"); await responsePromise; const text = await pageWithMockedPosts.locator("body").textContent(); expect(text).toContain("Mocked post 1"); expect(text).not.toContain("sunt aut facere"); // a string from the real first post }); test("uses both — testPost + pageWithMockedPosts", async ({ testPost, pageWithMockedPosts }) => { expect(testPost.id).toBeGreaterThan(0); await pageWithMockedPosts.goto("https://jsonplaceholder.typicode.com/posts"); await expect(pageWithMockedPosts.locator("body")).toContainText("Mocked"); }); }); -
Run all three tests across all three browsers. The third test demonstrates fixture composition — both fixtures resolve before the test runs.
-
Force a fixture failure. Add
throw new Error("forced setup failure")to thetestPostsetup. Run. Notice that tests asking fortestPostfail with the setup error before the test body runs — and tests that don't ask fortestPost(none in this file) would still pass. This is the isolation property in action. -
Stretch: add a worker-scoped fixture
appConfigthat fetches a JSON config file once and exposes it to every test. Verify by addingconsole.log('config loaded')in the setup — with--workers=2, you should see "config loaded" exactly twice (once per worker), regardless of how many tests use it.
You can now build typed, composable, self-cleaning building blocks for any test surface your app exposes. The next lesson zooms in on the content of those fixtures — the data-management strategies that keep parallel tests from stepping on each other.