Back to Blog
On this page5 sections

// tutorial

Playwright fixtures, explained without the React metaphors

qa.codesqa.codes · 17 February 2026 · 9 min read
Intermediate
playwrighttypescriptfixtures

Most explanations of Playwright fixtures lean on React-hook metaphors that miss the point. From the ground up: fixtures are scoped factories. Here's what to do with them and the three every project should have.

What fixtures actually are

A fixture is a named factory registered with test.extend. When a test declares a fixture in its argument list, Playwright instantiates it, passes it in, and tears it down after the test. You don't call the fixture yourself — Playwright does, based on what the test asks for.

// base.ts
import { test as base } from '@playwright/test';
 
type Fixtures = {
  greeting: string;
};
 
export const test = base.extend<Fixtures>({
  greeting: async ({}, use) => {
    // setup
    const value = 'hello';
    // hand control to the test
    await use(value);
    // teardown runs after use() returns
    console.log('greeting fixture torn down');
  },
});
 
// my.spec.ts
import { test } from './base';
 
test('uses greeting', async ({ greeting }) => {
  console.log(greeting); // 'hello'
});

The use function is the yield point. Everything before await use(value) is setup; everything after is teardown. Playwright calls setup before the test body runs and teardown after it finishes — even if the test fails.

That's the whole model. No hooks, no beforeEach, no shared mutable state. The fixture declares its own lifecycle.

Test scope vs worker scope

Every fixture has a scope: 'test' (default) or 'worker'.

Test-scoped fixtures are created fresh for each test and torn down after each test. This is what you want for most things: browser pages, authenticated sessions, test-specific users.

Worker-scoped fixtures are created once per worker process and shared across all tests that run in that worker. Use these for expensive, stateless resources: a database connection pool, a compiled binary, a read-only seed dataset.

export const test = base.extend<{}, WorkerFixtures>({
  // worker-scope: shared across tests in the same worker
  dbPool: [async ({}, use) => {
    const pool = await createDbPool();
    await use(pool);
    await pool.end();
  }, { scope: 'worker' }],
});

The rule is: if the fixture has state that tests could mutate and interfere with each other, it must be test-scoped. If the fixture is truly read-only (a connection object, not the data it reads), worker scope is fine.

Getting scope wrong is the main source of fixtures that work in isolation but fail in parallel. If tests run in parallel and share a worker-scoped fixture that writes to disk or mutates an in-memory cache, you'll get intermittent failures that are hard to reproduce.

The three fixtures every project should have

1. authenticatedPage — a Page that's already logged in. This is the most valuable fixture on any project with authentication.

type Fixtures = {
  authenticatedPage: Page;
};
 
export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    // Log in via API, set the cookie or localStorage
    await page.request.post('/api/auth/login', {
      data: { email: process.env.TEST_USER, password: process.env.TEST_PASS },
    });
    await use(page);
    await context.close();
  },
});

Tests that need authentication use authenticatedPage. Tests that explicitly test the unauthenticated state use the built-in page.

2. apiClient — a request context with auth headers pre-set. Useful for test setup that doesn't need a browser.

type Fixtures = {
  apiClient: APIRequestContext;
};
 
export const test = base.extend<Fixtures>({
  apiClient: async ({ playwright }, use) => {
    const client = await playwright.request.newContext({
      baseURL: process.env.API_BASE_URL,
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.API_TOKEN}`,
      },
    });
    await use(client);
    await client.dispose();
  },
});

Use apiClient in test.beforeEach to seed data or clean up after tests without spinning up a browser page.

3. testUser — a user record created fresh for each test, cleaned up after. This is the fixture that eliminates test interdependency.

type Fixtures = {
  testUser: { id: string; email: string };
};
 
export const test = base.extend<Fixtures>({
  testUser: async ({ apiClient }, use) => {
    const response = await apiClient.post('/api/test/users', {
      data: { email: `test+${Date.now()}@example.com` },
    });
    const user = await response.json();
    await use(user);
    // Cleanup
    await apiClient.delete(`/api/test/users/${user.id}`);
  },
});

Notice that testUser depends on apiClient. Playwright resolves fixture dependencies automatically — if a test asks for testUser, Playwright instantiates apiClient first.

How fixtures replace half of POM

The Page Object Model was largely a solution to two problems: selector encapsulation and shared setup. Fixtures solve the second problem better than POM ever did.

In a POM world, shared setup lives in a base class constructor or a static factory method. In a fixture world, it's just a fixture. The test asks for what it needs; Playwright delivers it already set up.

// POM approach — setup is hidden in the constructor
test('user can see dashboard', async ({ page }) => {
  const dashboardPage = new DashboardPage(page); // does setup internally
  await dashboardPage.goto();
  await expect(dashboardPage.chart).toBeVisible();
});
 
// Fixture approach — setup is declared, not hidden
test('user can see dashboard', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/dashboard');
  await expect(authenticatedPage.locator('[data-testid="chart"]')).toBeVisible();
});

The fixture version is explicit about what the test needs. You can read the test signature and know it requires an authenticated page. POM hides that — the page object does setup, but nothing in the test signature tells you what that setup is.

Where fixtures don't replace POM is selector encapsulation — grouping locators for a particular page into one place. That's a legitimate reason to have a page helper. But it's a different reason than "I need shared setup."

Anti-pattern: fixtures that mutate global state

The most common fixture mistake is a fixture that writes to something shared across tests: a global variable, a singleton, a file on disk without a test-specific path.

// Bad: writes to shared location
export const test = base.extend({
  seedData: async ({}, use) => {
    await fs.writeFile('./temp/seed.json', JSON.stringify(testData));
    await use(undefined);
    // No cleanup — if a test fails, the file stays
  },
});

When tests run in parallel (the default in Playwright), two tests racing to write and read ./temp/seed.json will produce intermittent failures. Each test needs its own resource — a test-unique file path, a fresh DB record, an isolated context.

// Better: test-unique path
export const test = base.extend({
  seedData: async ({ workerInfo }, use) => {
    const path = `./temp/seed-${workerInfo.workerIndex}-${Date.now()}.json`;
    await fs.writeFile(path, JSON.stringify(testData));
    await use(path);
    await fs.unlink(path);
  },
});

Playwright's workerInfo fixture gives you the worker index and test ID — use them whenever you need filesystem or database isolation.

Fixtures are the cleanest setup mechanism in any test framework I've used. The scoping model, the dependency injection, the explicit lifecycle — they make beforeEach look like a workaround. Give your project authenticatedPage, apiClient, and testUser. The rest can grow as you need it.


// related

Comparisons·15 April 2026 · 9 min read

Playwright vs Cypress in 2026: an honest comparison

After shipping production suites in both, here's the honest breakdown — where Playwright pulls ahead, where Cypress still wins, and the single factor that should actually decide it.

playwrightcypresscomparison
Opinions·24 April 2026 · 6 min read

You probably don't need a Page Object Model

POM was a Selenium-era solution to a Selenium-era problem. In modern Cypress and Playwright, custom commands and locator helpers cover 90% of what POM was supposed to give you.

patternspage-object-modelcypress