PLAYWRIGHT UIINTERMEDIATE

Playwright Fixtures: The Complete Guide with Real-World Examples

Master Playwright Fixtures with real-world examples. Learn built-in fixtures, custom fixtures, test.extend(), worker fixtures, dependency injection, and enterprise framework best practices.

iff Solution Academy July 10, 2026 24 min read Updated July 2026
Playwright Fixtures test.extend Worker Fixtures Frameworks SDET

If you have ever worked on a Playwright automation framework, you already know that setup code — logging in, creating browser contexts, generating tokens, seeding data, connecting to APIs — quickly balloons and clutters every test file. Playwright's Fixtures system solves this by turning setup into small, reusable, dependency-injected building blocks that Playwright wires up automatically for each test that asks for them.

This guide is the deep, practical walkthrough of Playwright Fixtures used by SDETs building real enterprise frameworks. You will learn what fixtures are, why they matter, every built-in fixture, how to author custom fixtures with test.extend(), the difference between test-scoped and worker-scoped fixtures, and battle-tested patterns for scaling fixtures across large teams.

What are Playwright Fixtures?

A Playwright fixture is a named value (an object, a function, a page, a client, a user session — anything) that Playwright constructs before your test runs and tears down afterwards. Instead of importing helpers and calling setup functions manually, you declare the fixtures you need in the test callback's destructured parameter list and Playwright injects them for you.

Think of fixtures as dependency injection for tests. Playwright inspects the destructured arguments of your test function, resolves every fixture in the correct order (including fixtures that depend on other fixtures), and passes them in ready to use.

ts
// Playwright sees you asked for `page` and injects it automatically.
test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/W3TestAutomation/);
});

Fixtures are the mechanism that makes tests read like specifications rather than plumbing.

Why Fixtures Matter

Without fixtures, every single test repeats the same boilerplate: launching browsers, creating contexts, logging in, waiting for the app to be ready, seeding data, and cleaning up. Multiply this across hundreds of tests and you end up with brittle, slow, hard-to-maintain suites.

Consider the pre-fixture version of a simple logged-in test:

ts
test('open dashboard', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto('/login');
  await page.fill('#email', 'admin@test.com');
  await page.fill('#password', 'password');
  await page.click('button[type=submit]');
  await page.waitForURL('/dashboard');
  // ...actual assertions start here
  await context.close();
});

Now imagine 300 tests that all begin with those ten lines. Fixtures let you extract that setup once and reduce every test to the part that actually describes behavior.

  • Eliminate duplication: setup lives in one place.
  • Speed up execution: worker-scoped fixtures run once per worker.
  • Improve reliability: setup is authored, reviewed, and cached in one location.
  • Enable composition: fixtures can depend on other fixtures.
  • Make tests readable: the test body only expresses business intent.

Built-in Fixtures

Playwright ships with a set of core fixtures that are available in every test out of the box. You do not import them — you simply destructure them from the test callback's first argument.

  • browser — a shared Browser instance for the worker.
  • context — a fresh BrowserContext isolated per test.
  • page — a new Page inside the per-test context (most common).
  • request — an APIRequestContext for HTTP calls that share cookies with the browser context.
  • browserName — the current project's browser name ('chromium' | 'firefox' | 'webkit').
  • baseURL, viewport, storageState, video, trace, screenshot — configuration-driven fixtures set from playwright.config.ts.

Each of these has a defined lifecycle. context and page are re-created for every test to guarantee isolation. browser is reused across tests in the same worker. Understanding these scopes is the difference between a fast, stable suite and one that leaks state between tests.

The page Fixture

The page fixture is by far the most used. It gives you a fresh Page inside a fresh BrowserContext for every test, so cookies, storage, and permissions never leak between tests.

ts
import { test, expect } from '@playwright/test';

test('search returns results', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('searchbox').fill('playwright fixtures');
  await page.keyboard.press('Enter');
  await expect(page.getByRole('heading', { name: /results/i })).toBeVisible();
});

Use page for 90% of UI tests. Reach for context or browser only when a test genuinely needs multiple pages or custom context options.

The browser Fixture

The browser fixture exposes the underlying Browser instance. It is shared across tests in the same worker, so it is the right choice when you need to create additional contexts (for example, to simulate two users in the same test).

ts
test('two users in one test', async ({ browser }) => {
  const userA = await browser.newContext({ storageState: 'auth/userA.json' });
  const userB = await browser.newContext({ storageState: 'auth/userB.json' });

  const pageA = await userA.newPage();
  const pageB = await userB.newPage();

  await pageA.goto('/chat');
  await pageB.goto('/chat');

  await pageA.getByRole('textbox').fill('Hello from A');
  await pageA.keyboard.press('Enter');

  await expect(pageB.getByText('Hello from A')).toBeVisible();

  await userA.close();
  await userB.close();
});

Always close any context you open manually — Playwright only auto-closes the ones it created for you.

The context Fixture

The context fixture is the fresh BrowserContext behind page. Use it when you need a second page, want to open a new tab, or need to configure permissions, geolocation, or storage on the same context page uses.

ts
test('open a second tab', async ({ context, page }) => {
  await page.goto('/');
  const [popup] = await Promise.all([
    context.waitForEvent('page'),
    page.getByRole('link', { name: 'Open Docs' }).click(),
  ]);
  await popup.waitForLoadState();
  await expect(popup).toHaveURL(/docs/);
});

The request Fixture

request is an APIRequestContext for hitting HTTP endpoints directly. It automatically inherits baseURL and can share cookies with the browser context, which makes it ideal for hybrid API + UI tests: authenticate over API for speed, then verify in the UI.

ts
test('create user via API then verify in UI', async ({ request, page }) => {
  const res = await request.post('/api/users', {
    data: { email: 'jane@test.com', name: 'Jane' },
  });
  expect(res.ok()).toBeTruthy();
  const { id } = await res.json();

  await page.goto(`/users/${id}`);
  await expect(page.getByRole('heading', { name: 'Jane' })).toBeVisible();
});

Creating Custom Fixtures

Once you understand built-in fixtures, custom fixtures are where the real productivity gains show up. A custom fixture is any value you want Playwright to build and inject: an authenticated page, a seeded database, an API client, feature flags, a specific user role.

You create custom fixtures by extending the base test object with test.extend(). Each fixture is an async function that receives dependencies and a use callback. Everything before use() is setup; everything after is teardown.

fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';

type AuthFixtures = {
  loggedInPage: Page;
};

export const test = base.extend<AuthFixtures>({
  loggedInPage: async ({ page }, use) => {
    // ---- setup ----
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@test.com');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');

    // hand the value to the test
    await use(page);

    // ---- teardown (runs after each test that uses this fixture) ----
    // e.g. await page.getByRole('button', { name: 'Sign out' }).click();
  },
});

export { expect } from '@playwright/test';

Now every test that imports this test object gets a pre-authenticated page:

ts
import { test, expect } from '../fixtures/auth.fixture';

test('dashboard shows welcome banner', async ({ loggedInPage }) => {
  await expect(loggedInPage.getByRole('heading', { name: /welcome/i })).toBeVisible();
});

test.extend() — The Foundation of Framework Design

test.extend() is how you compose your own test object. Fixtures are typed via generics, they can depend on other fixtures (Playwright resolves the graph), and you can override built-in fixtures — for example, to give every test a pre-configured baseURL, storageState, or a wrapped page with custom helpers.

Overriding a built-in fixture (adding tracing on every page):

ts
export const test = base.extend({
  page: async ({ page }, use, testInfo) => {
    page.on('console', (msg) => testInfo.attach('console', { body: msg.text(), contentType: 'text/plain' }));
    await use(page);
  },
});

Composing multiple custom fixtures:

ts
type Fixtures = {
  apiClient: ApiClient;
  loggedInPage: Page;
};

export const test = base.extend<Fixtures>({
  apiClient: async ({ request }, use) => {
    const client = new ApiClient(request);
    await client.authenticate();
    await use(client);
  },
  loggedInPage: async ({ page, apiClient }, use) => {
    const { token } = await apiClient.login('admin@test.com', 'password');
    await page.context().addCookies([{ name: 'session', value: token, url: 'http://localhost:3000' }]);
    await page.goto('/dashboard');
    await use(page);
  },
});

Notice how loggedInPage depends on apiClient — Playwright figures out the order and instantiates apiClient first. This dependency injection is what turns fixtures into a real framework primitive.

Worker Fixtures — Set Up Once Per Worker

By default, fixtures are test-scoped: they run before and after every single test. That is safe but can be slow when the setup is expensive (spinning up a database, seeding reference data, minting an OAuth token). Worker-scoped fixtures run once per worker process and are shared across every test on that worker.

ts
type WorkerFixtures = {
  adminToken: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  adminToken: [async ({}, use) => {
    const token = await mintAdminToken(); // expensive
    await use(token);
  }, { scope: 'worker' }],
});

Rules of thumb for choosing a scope:

  • Test scope (default): anything that must be fresh per test — pages, contexts, DB rows created by the test.
  • Worker scope: expensive, immutable setup — auth tokens, seed data, config, DB pools, external service handshakes.

Never mutate worker-scoped values from within a test. Because they are shared, mutations will leak into other tests on the same worker and cause the hardest-to-debug failures in your suite.

Sharing Test Data Through Fixtures

Fixtures are the cleanest way to hand test data to tests. Rather than hardcoding users or reading JSON files in each test, wrap the data in a fixture so every test gets a consistent, typed view of it — and so you can swap the source (JSON, database, API, faker) in one place.

ts
type Data = { users: { admin: User; guest: User } };

export const test = base.extend<{}, Data>({
  users: [async ({}, use) => {
    await use({
      admin: { email: 'admin@test.com', password: 'password' },
      guest: { email: 'guest@test.com', password: 'password' },
    });
  }, { scope: 'worker' }],
});

test('admin can access settings', async ({ page, users }) => {
  await loginAs(page, users.admin);
  await page.goto('/settings');
  await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});

Enterprise Framework Example

In production frameworks you rarely have a single fixture file. Instead you split fixtures by concern and merge them into one exported test object. A typical layout at a large organization looks like this:

  • fixtures/auth.fixture.ts — logged-in pages per role (admin, editor, guest).
  • fixtures/api.fixture.ts — an authenticated APIRequestContext wrapper.
  • fixtures/db.fixture.ts — a database client that seeds and cleans up per test.
  • fixtures/aws.fixture.ts — S3 / SQS clients for integration tests.
  • fixtures/featureFlag.fixture.ts — toggle flags per test via API.
  • fixtures/index.ts — merges everything into a single test export.
fixtures/index.ts
import { mergeTests } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as apiTest } from './api.fixture';
import { test as dbTest } from './db.fixture';
import { test as flagsTest } from './featureFlag.fixture';

export const test = mergeTests(authTest, apiTest, dbTest, flagsTest);
export { expect } from '@playwright/test';

Tests then read like specifications, focused entirely on behavior:

ts
import { test, expect } from '@/fixtures';

test('admin can create an order with feature flag on', async ({
  loggedInAsAdmin,
  apiClient,
  db,
  flags,
}) => {
  await flags.enable('new-checkout');
  const product = await db.seedProduct({ price: 19.99 });

  await loggedInAsAdmin.goto(`/products/${product.id}`);
  await loggedInAsAdmin.getByRole('button', { name: 'Buy now' }).click();

  const orders = await apiClient.get('/api/orders?mine=1');
  expect(orders).toHaveLength(1);
});

Best Practices

  • One responsibility per fixture. If a fixture does auth AND seeding AND flags, split it.
  • Prefer worker scope for expensive, read-only setup (tokens, config, seed data).
  • Keep business logic in Page Objects. Fixtures should assemble things, not click things.
  • Type your fixtures with generics on test.extend<Fixtures>() — untyped fixtures kill autocomplete and hide bugs.
  • Do teardown after use(). Even if the test throws, teardown still runs.
  • Never mutate worker-scoped values from a test. Treat them as immutable.
  • Share fixtures across projects via a small internal package rather than copy-pasting.
  • Compose with mergeTests() instead of a single 500-line fixture file.

Common Mistakes

  • Building a god-fixture that returns { page, api, db, flags, aws }. It hides dependencies and forces every test to pay for setup it does not need.
  • Forgetting to call use(). Playwright cannot inject a value you never handed off, and the test hangs.
  • Doing work after use() that should be setup, or setup that should be teardown — leading to ordering bugs.
  • Using worker scope for anything mutable. State bleeds between tests and produces flaky, order-dependent failures.
  • Reaching for beforeEach when a fixture would be cleaner. beforeEach cannot inject a value into the test callback; fixtures can.
  • Importing the base @playwright/test in some files and the extended test in others — you lose your custom fixtures silently.

Interview Questions

What are Playwright fixtures?

They are reusable, dependency-injected setup objects that Playwright constructs before a test and tears down after. You declare them as destructured parameters and Playwright resolves them automatically.

What is the difference between page, context, and browser?

browser is shared across the worker. context is a fresh, isolated BrowserContext created per test. page is a new Page inside that per-test context. Use page by default; use context for multiple tabs or custom permissions; use browser to create additional isolated user sessions in the same test.

Why use test.extend()?

To create custom fixtures — such as a logged-in page, an API client, or seeded data — and expose them through your own test object. It is the foundation of every serious Playwright framework.

Test-scoped vs worker-scoped fixtures?

Test-scoped fixtures run before/after every test and are safe to mutate. Worker-scoped fixtures run once per worker process and must be treated as immutable — they are ideal for expensive, read-only setup like auth tokens or seed data.

How do fixtures depend on other fixtures?

Any fixture can destructure other fixtures in its own setup function. Playwright resolves the dependency graph and instantiates them in the correct order, exactly like a DI container.

How do you share fixtures across projects?

Publish them as an internal npm package (or path-aliased folder) that exports a merged test object via mergeTests(), and have every project import test/expect from that package instead of @playwright/test.

Summary

Fixtures are the single most impactful feature in Playwright for teams building real automation frameworks. They turn setup from copy-pasted boilerplate into a typed, composable, dependency-injected system that keeps tests readable and fast.

Start with the built-in fixtures. Extract common setup into custom fixtures with test.extend(). Push expensive, read-only work into worker-scoped fixtures. Split fixtures by concern and merge them with mergeTests(). Do that, and your Playwright suite will scale cleanly from ten tests to ten thousand.

🚀 Build Enterprise Playwright Frameworks — Want to learn how professional SDETs build production-ready Playwright frameworks? Explore more tutorials on W3TestAutomation and follow our complete Playwright learning path.

Get Playwright tutorials in your inbox

Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.

Playwright Framework Series

View all →
  1. 1Getting Started with Playwright: Installation, Setup, and Your First Test
  2. 2Playwright Locators: The Complete Guide with Real-World Examples
  3. 3Playwright Actions: Complete Guide to Click, Fill, Hover, Keyboard, Mouse & File Upload
  4. 4Playwright Assertions: The Complete Guide with Real-World Examples
  5. 5Playwright Auto Waiting: The Complete Guide with Real-World Examples
  6. 6Playwright Fixtures: The Complete Guide with Real-World Examples
  7. 7Playwright Browser Context & Multiple Tabs: The Complete Guide with Real-World Examples
  8. 8Playwright Authentication & Session Management: Complete Guide with Enterprise Examples
  9. 9Playwright Network Interception & API Mocking: Complete Guide with Real-World Examples
  10. 10Playwright Page Object Model (POM): The Complete Guide with Enterprise Examples
  11. 11Page Object Model with Playwright: A Practical Guide
  12. 12How to Build a Robust Professional Playwright Framework from Scratch (Step-by-Step)
  13. 13Building an Enterprise Playwright Framework from Scratch

API Testing Series

View all →
  1. 1Playwright API Testing: The Complete Guide with Real-World Examples
  2. 2Part 1 : Playwright API Testing Tutorial: Build Enterprise-Level API Automation Framework
  3. 3Part 2: Playwright API Testing Tutorial: Setting Up Project & Sending Your First API Request
  4. 4Part 3: Mastering CRUD Operations in Playwright API Testing
  5. 5Part 4: Authentication in Playwright API Testing (Bearer Token, JWT, API Key & Reusable Fixtures)
  6. 6Part 5: Building an Enterprise-Level Playwright API Automation Framework
  7. 7Part 6: API Models, Schema Validation & Test Data Management in Playwright
  8. 8Part 7: Custom Fixtures, Hooks, Logging & Parallel Execution in Playwright API Testing
  9. 9Part 8: Building a Production-Ready Playwright API Framework (Environment Management, CI/CD, Reporting & Best Practices)
  10. 10Part 9: Advanced Playwright API Testing Techniques for Enterprise Automation
  11. 11Part 10: Building a Complete Enterprise Playwright API Automation Framework (Final Part)
  12. 12API Testing with Playwright: Request Context, Auth, and Assertions

Recommended Next Articles