You've been using page in every test for the last four chapters without naming what it actually is. The mechanism behind it is fixtures — Playwright's dependency-injection system. When you write async ({ page }) => { ... }, Playwright sees you've asked for a page and provides one. The same shape gives you context, browser, request, and browserName on demand. Understanding the fixture model unlocks two big things: the test isolation that makes Playwright stable by default, and the ability to write your own fixtures (next lesson) for shared setup. This lesson is the model, the four built-ins, and how the lifecycle plays out across a full test run.
Fixtures, in one sentence
A fixture is a named resource that Playwright provides to a test by destructuring it from the test function's parameter object:
test("uses the page fixture", async ({ page }) => {
await page.goto("/dashboard");
});
test("uses page and context", async ({ page, context }) => {
await context.addCookies([{ name: "theme", value: "dark", domain: "localhost", path: "/" }]);
await page.goto("/dashboard");
});
test("uses request only — no browser involved", async ({ request }) => {
const res = await request.get("/api/users");
expect(res.ok()).toBeTruthy();
});You declare what you need, Playwright builds it before the test runs and tears it down after. No imports, no instantiation, no manual cleanup.
The four built-ins you'll use daily
page — a single browser tab. The fixture you'll use in 90% of tests. Fresh per test, automatically closed when the test ends.
context — the BrowserContext that owns page. An isolated profile, like an incognito window: its own cookies, localStorage, IndexedDB, cache. Fresh per test by default. Use it when you need to add cookies, grant permissions, capture HAR, or open a second tab.
browser — the launched browser process (Chromium, Firefox, or WebKit). Shared across tests in the same worker for efficiency. You rarely access it directly; you'd use it to manually create a context with non-default options inside a custom fixture (next lesson).
request — an APIRequestContext for HTTP calls without a browser. Already covered in chapter 4. Shares cookies with context so a logged-in user via API stays logged in for the UI.
A fifth one worth knowing:
browserName — a string: 'chromium', 'firefox', or 'webkit'. Useful for browser-conditional logic.
test("skips on webkit because of a known bug", async ({ page, browserName }) => {
test.skip(browserName === "webkit", "Tracked in PROJECT-1234");
await page.goto("/feature");
await expect(page.getByText("Loaded")).toBeVisible();
});The fixture lifecycle
The layering matters. From outermost (slowest, shared) to innermost (fastest, per-test):
worker — one Node process, runs many tests in sequence
└── browser — launched once per worker
└── context — fresh per test
└── page — fresh per test (one tab inside the context)
A worker spins up a browser once and reuses it across every test the worker handles. Each test inside that worker gets a brand-new context (new cookies, new storage), and a brand-new page inside that context. Closing the page closes the tab; closing the context wipes its state. The browser keeps running for the next test.
That layering is what makes Playwright fast and isolated:
- Fast, because launching a browser is the expensive step (hundreds of ms) and you only pay it once per worker.
- Isolated, because every test starts with a clean context — no cookies, no localStorage, no leftover state from the previous test.
If you've ever debugged a Cypress flake where one test set a cookie that broke the next, this is the model that prevents that.
The lifecycle, step by step
Step 1 of 6
Worker starts
Playwright spawns a Node process. The browser binary launches once. This is the slowest part — 200-500ms — and it's shared across every test the worker handles.
Reading the model in real test code
A test that uses three fixtures together:
import { test, expect } from "@playwright/test";
test("logs in via API, then drives UI as the same user", async ({ page, context, request }) => {
// 1. Use request to seed an auth session
const res = await request.post("/api/login", {
data: { email: "alice@test.com", password: "pw123" }
});
expect(res.ok()).toBeTruthy();
// request and context share the same cookie jar — auth is now visible to the browser
const cookies = await context.cookies();
expect(cookies.find(c => c.name === "session")).toBeDefined();
// 2. Use page to drive the UI as the logged-in user
await page.goto("/dashboard");
await expect(page.getByRole("heading")).toContainText("Welcome");
});This works because request, context, and page come from the same BrowserContext. The cookie the API set is automatically present in the browser session — the canonical "log in once via API, drive the UI as that user" pattern.
Browser-conditional tests
Sometimes a test needs to behave differently per browser — usually because of a known platform difference, not a test smell. Two patterns:
import { test, expect } from "@playwright/test";
// Pattern 1: skip a single test on a specific browser
test("uses File System Access API (Chromium-only)", async ({ page, browserName }) => {
test.skip(browserName !== "chromium", "API only available in Chromium");
await page.goto("/file-picker");
// ...
});
// Pattern 2: branch behaviour without skipping
test("clipboard read", async ({ page, browserName, context }) => {
if (browserName === "chromium") {
await context.grantPermissions(["clipboard-read"]);
}
await page.goto("/copy-test");
await page.getByRole("button", { name: "Copy" }).click();
// assertion that works on all browsers
});Use test.skip sparingly — it hides coverage. Use it when the feature isn't supposed to work on that browser; don't use it to paper over flakes.
Worker-scoped fixtures (a teaser)
Test-scoped fixtures (the default) reset per test for isolation. Sometimes you want a resource that persists across many tests in the same worker — a seeded database, a started backend service, a long-lived API token. For those, fixtures can opt into worker scope:
const test = base.extend<{}, { dbConnection: Connection }>({
dbConnection: [async ({}, use) => {
const conn = await connect(process.env.DATABASE_URL);
await use(conn);
await conn.close();
}, { scope: "worker" }],
});{ scope: 'worker' } says: create this fixture once per worker, reuse it across every test that asks for it. The full pattern is in the next lesson; mention it here so you know the shape exists.
Coming from Cypress?
The mappings:
- Cypress provides
cyas a global — you don't declare it. - Playwright provides nothing globally. You destructure exactly what each test needs.
- Cypress's per-test isolation is automatic via "clear cookies, localStorage, sessionStorage between tests."
- Playwright's per-test isolation is automatic via "every test gets a fresh BrowserContext."
The Playwright model is more granular — a test can choose to share cookies with API calls (context + request), spin up multiple tabs (context.newPage), or skip the browser entirely (request only). That granularity is what makes the rest of this chapter's patterns possible.
⚠️ Common mistakes
- Reusing a
Pageacross tests via global state. Tempting to writelet sharedPage; beforeAll(async ({ browser }) => { sharedPage = await browser.newPage() }). Now tests share state — one test's localStorage poisons the next. Don't fight the per-testpagefixture; if you genuinely need shared state, scope a worker-fixture for the resource (a token, a DB seed) and let each test build its own page on top. - Destructuring fixtures you don't use.
async ({ page, context, browser, request }) => { /* only uses page */ }— Playwright still creates the unused fixtures, paying the (small) setup cost. List only what you use; the framework optimises around what you ask for. - Confusing
page.gotowithcontext.newPage.page.goto(url)navigates the existing tab.context.newPage()opens a second tab inside the same context. If you find yourself with two tabs unexpectedly, you probably callednewPage()when you meantgoto().
🎯 Practice task
Write a test that exercises every built-in fixture. 20-25 minutes.
-
Create
tests/fixtures-tour.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Built-in fixtures tour", () => { test("uses page only", async ({ page }) => { await page.goto("https://www.saucedemo.com"); await expect(page.getByText("Swag Labs")).toBeVisible(); }); test("uses page + context — set a cookie before navigating", async ({ page, context }) => { await context.addCookies([ { name: "theme", value: "dark", domain: "saucedemo.com", path: "/" } ]); await page.goto("https://www.saucedemo.com"); const cookies = await context.cookies(); expect(cookies.find(c => c.name === "theme")?.value).toBe("dark"); }); test("uses request only — no browser launches a page", async ({ request }) => { const res = await request.get("https://jsonplaceholder.typicode.com/posts/1"); expect(res.ok()).toBeTruthy(); const post = await res.json(); expect(post.id).toBe(1); }); test("uses request + page — auth via API, navigate via UI", async ({ page, request }) => { // (Sauce Demo doesn't expose a real login API — this is the shape you'd use against a real app) const res = await request.get("https://jsonplaceholder.typicode.com/users/1"); const user = await res.json(); expect(user.name).toBeDefined(); await page.goto("https://www.saucedemo.com"); await expect(page.getByPlaceholder("Username")).toBeVisible(); }); test("uses browserName — skip on webkit", async ({ page, browserName }) => { test.skip(browserName === "webkit", "Demo skip — not actually a webkit-only feature"); await page.goto("https://www.saucedemo.com"); await expect(page.getByText("Swag Labs")).toBeVisible(); }); test("opens a second tab via context.newPage", async ({ page, context }) => { await page.goto("https://www.saucedemo.com"); const secondPage = await context.newPage(); await secondPage.goto("https://playwright.dev"); expect(context.pages().length).toBe(2); await secondPage.close(); expect(context.pages().length).toBe(1); }); }); -
Run all six tests across all three browsers. Notice the
browserNameskip test reportsskippedonly on WebKit; the others run everywhere. -
Inspect the lifecycle. Add a
console.logat the top of each test (console.log('test starts:', test.info().title)) and aconsole.log('test ends')intest.afterEach. Run withnpx playwright test --workers=1 fixtures-tour.spec.ts --reporter=list. Note how each test's "starts/ends" prints sequentially with no overlap — that's per-test isolation in action. -
Stretch: add a worker-scoped fixture for a counter that increments each time a test runs in the same worker. Print the counter in each test. Run with
--workers=2. Note that with two workers, each gets its own counter sequence (1, 2, 3 in worker A; 1, 2, 3 in worker B), confirming the worker-scope boundary.
You now have a precise mental model of Playwright's resource layers. The next lesson uses that model to build your own fixtures — the patterns that turn copy-paste setup blocks into reusable, typed building blocks.