C.W.K.
Stream
Lesson 03 of 04 · published

Custom Fixtures + Parallel Execution

~17 min · playwright-advanced, fixtures, parallel

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"A custom fixture is the boilerplate you don't have to write fifty times."

What Fixtures Already Give You

Every Playwright test destructures fixtures from its first argument: test('thing', async ({ page, context, request }) => {...}). page, context, request are built-in fixtures. Playwright creates a fresh page (and context, by default) per test, so isolation is automatic.

What's less obvious is that you can extend the fixture set. A custom fixture is a small piece of setup-teardown logic that yields a value into your test. The value can be anything — a logged-in page, a seeded database connection, a feature-flag overrider.

The test.extend Pattern

To create a custom fixture, you call test.extend({ fixtureName: async ({ deps }, use) => { ... } }). Inside the fixture function, you set up the value, call await use(value) to hand it to the test, then run teardown after the test finishes. The shape is identical to React's useEffect cleanup pattern — setup, yield, cleanup.

The returned test object is a NEW test runner that has both the built-in fixtures AND yours. You import this from your test files instead of @playwright/test.

Test-Scoped vs Worker-Scoped Fixtures

Fixtures have a SCOPE — how often they get set up and torn down:

  • test scope (default) — runs before every test, tears down after. Use for things that need fresh state per test (a logged-in page, a clean DB).
  • worker scope — runs once per worker process. Use for expensive setup that can be shared (a database connection, an API client). The fixture is reused across all tests in that worker.

Pick worker scope only when reuse is safe. A shared DB connection is fine; a shared 'logged-in user' state is not — multiple parallel tests writing to the same session is a bug.

Fixtures earn their keep when they hide repetitive setup that's coupled to the test. If every test starts with await ensureLoggedIn(page); await seedThreeUsers();, fold both into a seededLoggedInPage fixture and just write test('...', async ({ seededLoggedInPage }) => {...}). The intent surfaces; the ceremony disappears.

Parallel Execution Model

Playwright runs tests in PARALLEL by default. Two dimensions of parallelism:

  • Across files — multiple test files run in separate worker processes simultaneously. Controlled by the workers config option (default: half your CPU cores).
  • Within a file — set test.describe.configure({ mode: 'parallel' }) at the top of a file (or fullyParallel: true in config) and tests within the same file also run in parallel across workers.

Default behavior is conservative: parallel across files, serial within. Set fullyParallel: true globally if your tests are well-isolated; turn it off per-file with mode: 'serial' when a sequence inside one file must order strictly.

The Isolation Contract

Parallel tests require isolation. Playwright gives you the browser-side isolation (separate contexts), but the server / database side is your responsibility. Strategies:

  • Per-test database row scoping (every test uses unique data).
  • Per-worker database (each worker connects to its own DB).
  • Per-test transaction rollback (each test runs in a transaction that's rolled back at the end).

Without server-side isolation, your parallelism caps at 1 — and you've thrown away most of Playwright's speed.

Code

Custom fixture — seeded, logged-in dashboard·typescript
// e2e/fixtures.ts — define custom fixtures
import { test as base, expect, type Page } from '@playwright/test';

type MyFixtures = {
  /** A page that's already navigated to /dashboard with three users seeded. */
  seededDashboard: Page;
};

export const test = base.extend<MyFixtures>({
  seededDashboard: async ({ page, request }, use) => {
    // Setup — seed the data and navigate
    await request.post('/api/test/reset');
    await request.post('/api/test/seed', {
      data: { users: [{ name: 'Pippa' }, { name: 'Dad' }, { name: 'Mom' }] },
    });
    await page.goto('/dashboard');
    await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();

    // Yield the configured page to the test
    await use(page);

    // Teardown — clean up after
    await request.post('/api/test/reset');
  },
});

export { expect };
Tests using the custom fixture·typescript
// e2e/dashboard.spec.ts — use the custom fixture
import { test, expect } from './fixtures';

test('renders all three seeded users', async ({ seededDashboard }) => {
  // No setup needed in the test — the fixture handled it
  await expect(seededDashboard.getByRole('listitem')).toHaveCount(3);
  await expect(seededDashboard.getByText('Pippa')).toBeVisible();
});

test('clicking a user opens their detail page', async ({ seededDashboard }) => {
  await seededDashboard.getByText('Dad').click();
  await expect(seededDashboard).toHaveURL(/\/users\/\d+/);
});
Worker-scoped fixture — shared across tests in one worker·typescript
// Worker-scoped fixture — set up once per worker process
import { test as base } from '@playwright/test';

type WorkerFixtures = {
  /** Shared API client — created once per worker, reused across tests in that worker. */
  apiClient: { post: (url: string, body: unknown) => Promise<Response> };
};

export const test = base.extend<{}, WorkerFixtures>({
  apiClient: [
    async ({}, use) => {
      // Setup: create the client once
      const client = await createApiClient({
        baseUrl: process.env.API_URL!,
        token: process.env.SERVICE_TOKEN!,
      });

      // Yield to all tests in this worker
      await use(client);

      // Teardown: close the client when the worker exits
      await client.close();
    },
    { scope: 'worker' },   // ← the magic that changes scope
  ],
});
Parallel vs serial within a file·typescript
// Parallel mode inside a file
import { test, expect } from '@playwright/test';

// All tests in this file run in parallel across workers
test.describe.configure({ mode: 'parallel' });

test('test A', async ({ page }) => { /* ... */ });
test('test B', async ({ page }) => { /* ... */ });
test('test C', async ({ page }) => { /* ... */ });

// Or force serial when a file's tests depend on each other (rare and a smell)
test.describe.configure({ mode: 'serial' });

test('step 1 — create user', async ({ page }) => { /* ... */ });
test('step 2 — use the user from step 1', async ({ page }) => { /* ... */ });
// (Better: collapse 'step 1' + 'step 2' into one test that does both)

External links

Exercise

Identify a setup block that repeats across at least three of your E2E tests (logging in, seeding data, navigating to a starting page). Extract it into a custom fixture. Update the three tests to use it via destructuring. Confirm the tests still pass and the test bodies are shorter and more readable. Bonus: convert one expensive setup into a worker-scoped fixture and time the difference.
Hint
If your custom fixture throws an error during setup, the test fails with a confusing 'fixture setup failed' message. Wrap risky setup in try/catch and re-throw with a clearer error — fixture authors are expected to provide good diagnostics for their consumers.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.