Built-in Fixtures — page, browser, context

8 min read

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 cy as 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 Page across tests via global state. Tempting to write let sharedPage; beforeAll(async ({ browser }) => { sharedPage = await browser.newPage() }). Now tests share state — one test's localStorage poisons the next. Don't fight the per-test page fixture; 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.goto with context.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 called newPage() when you meant goto().

🎯 Practice task

Write a test that exercises every built-in fixture. 20-25 minutes.

  1. 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);
      });
    });
  2. Run all six tests across all three browsers. Notice the browserName skip test reports skipped only on WebKit; the others run everywhere.

  3. Inspect the lifecycle. Add a console.log at the top of each test (console.log('test starts:', test.info().title)) and a console.log('test ends') in test.afterEach. Run with npx 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.

  4. 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.

// tip to track lessons you complete and pick up where you left off across devices.