playwright-fixture-builder

Use when a Playwright suite has copy-pasted beforeEach setup that should be a fixture, or needs shared login, database or feature-flag setup across many specs. Builds test.extend fixtures with the right test or worker scope, setup and teardown, and per-worker isolation.

/plugin install qa-test-environment@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Overview

Playwright Test fixtures replace beforeEach / afterEach boilerplate with composable, lazy-initialized values that hang off the test function (pw-fixtures (opens in new window)).

This skill is build-an-X - it produces the fixtures.ts (or language-equivalent) file from the team's actual setup needs (auth, database state, feature flags, app instance), picking the right scope and teardown ordering for each.

When to use

  • Multiple spec files copy-paste beforeEach/afterEach setup blocks that should be a fixture.
  • A new test suite needs auth + DB + flags wired and the team wants one cohesive fixtures.ts rather than ad-hoc helpers.
  • An existing suite is slow because each test re-authenticates; there's a worker-scope shortcut available.
  • The team is adding parallelism and current fixtures don't honor workerInfo.workerIndex for per-worker isolation.

If the suite has only a handful of specs and one common helper, a shared helpers.ts file is enough - fixtures pay off when 5+ specs share setup or when teardown ordering matters.

How to use

  1. List every beforeEach/afterEach block the specs share (auth, DB reset, flag setup, page objects).
  2. Assign each a scope with Step 1's rule of thumb - worker for immutable expensive state, test for anything that changes between tests.
  3. Write each fixture with the use(value) setup/teardown split - recipes for auth, page object, DB, and flags are in references/fixture-recipes.md.
  4. Compose them in dependency order (auth → db → flags) into one test export; see references/composition-and-output.md.
  5. Confirm teardown runs in reverse of setup, and box internal helper fixtures out of the report.
  6. Replace the specs' copy-pasted blocks with a single import from the composed fixtures/index.ts.
  7. Emit the per-fixture review note (scope, auto, boxed, setup cost, teardown) so a reviewer can sanity-check the scoping.

Step 1 - Identify the right scope per fixture

Fixtures are test-scoped by default (run and torn down per test); declare { scope: 'worker' } for state shared across a worker's tests (pw-fixtures (opens in new window)). The table assigns a scope per fixture.

FixtureScopeWhy
Authenticated userworkerAuth handshake is expensive (UI login, cookie set, OTP); state is shareable.
Storage-state file pathworkerOne storageState per worker keeps server-side cohorts isolated.
Page object (TodoPage)testEach test needs a clean DOM and navigation start.
Test-DB snapshottestPer-test isolation requires per-test restore.
Feature-flag overridestestDifferent tests may want different flag combos (compose with feature-flag-test-harness).
Browser instanceworker(Playwright default) - sharing cuts ~500ms per test.
Test-data factorytestEach test gets fresh fixtures with worker-namespaced IDs.

Rule of thumb: if changing the fixture between two tests would cause a test to fail, scope it test. Otherwise scope it worker.

Fixture recipes, composition, output

A complete worker-scoped storageState auth fixture - signs in once per worker and reuses the cookie / localStorage snapshot across that worker's tests:

// fixtures/auth.ts
import { test as base } from '@playwright/test';
import path from 'node:path';

export const test = base.extend<{}, { storageState: string }>({
  storageState: [async ({ browser }, use, workerInfo) => {
    const username = `user${workerInfo.workerIndex}`;
    const fileName = path.resolve(`playwright/.auth/${username}.json`);

    const page = await browser.newPage({ storageState: undefined });
    await page.goto('/login');
    await page.getByLabel('Email').fill(`${username}@example.com`);
    await page.getByLabel('Password').fill('test-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');

    await page.context().storageState({ path: fileName });
    await page.close();
    await use(fileName);
  }, { scope: 'worker' }],
});

Worked example

A suite of 12 checkout specs each opens with the same beforeEach: log in through the UI, reset the test DB, then set a feature flag. That block re-authenticates once per test, so the run spends ~12s just logging in.

Refactor it into four fixtures. storageState moves to worker scope keyed by user${workerInfo.workerIndex}, so each worker logs in once (~1.2s) instead of every test. cleanDb is test-scoped with { auto: true } so every spec starts from a restored snapshot without listing it. flags is test-scoped and restores an empty provider on teardown so a leaked override can't poison the next test. todoPage stays test-scoped because the page object holds per-test state.

They compose auth → db → flags in fixtures/index.ts; each spec now imports { test, expect } from there and drops its beforeEach. A checkout test becomes async ({ page, flags, cleanDb }) => { await flags({ promo_codes: true }); ... }. The per-fixture review note records that worker-scoped auth cut ~600ms × 12 tests and that flags teardown is what keeps combinations isolated.

Anti-patterns and limitations

The full anti-pattern table (wrong-scope fixtures, beforeAll in a parallel suite, manual context.close(), committing auth files, one mega-fixture) and the limitations (no mid-test scope changes, session storage not auto-captured, teardown failures don't fail the test, per-fixture timeout) are in references/anti-patterns-and-limits.md.

References

  • pw-fix (opens in new window) - test.extend, scope (test vs worker), workerInfo.workerIndex, automatic / boxed fixtures, default fixtures.
  • pw-auth (opens in new window) - storageState pattern, per-worker authentication via parallelIndex, role-based auth files, security note on .gitignore.
  • feature-flag-test-harness - the matrix-shard pattern that complements per-test flags fixture overrides.
  • testcontainers, docker-compose-tests - the underlying stack the fixtures point at.

Anti-patterns

Anti-patternWhy it failsFix
Worker-scoped fixture for state that changes between testsTests on the same worker pollute each other; intermittent failures.Move to test scope. Per pw-fix (opens in new window): test-scoped fixtures "are torn down immediately after".
Test-scoped fixture for immutable expensive state (e.g. logged-in user)Per-test login = N × ~1s. CI time balloons.Worker scope + workerInfo.workerIndex per pw-fix (opens in new window).
beforeAll for shared state in a parallel suitebeforeAll runs once per spec file, not once per worker; doesn't compose.Worker-scoped fixture with the right use() boundary.
Teardown that depends on a downstream fixture's setupReverse-order teardown means the dependency is gone when teardown runs.Invert composition: dependent fixture extends the dependency.
Manual await context.close() inside a testBypasses Playwright's cleanup; flake on the next test.Let the page/context fixture handle close in its teardown.
Hard-coded port / DB name in fixturesTwo parallel workers fight over the same resource.Derive from workerInfo.workerIndex per pw-fix (opens in new window).
Storing playwright/.auth/*.json in gitPer pw-auth (opens in new window): "these files contain sensitive cookies and headers"..gitignore the auth dir; reauthenticate in CI per worker.
One mega-fixture that bundles auth+db+flagsTests can't opt out of pieces; one tweak breaks everyone.Atomic fixtures composed via extend per Step 6.

Limitations

  • No mid-test scope changes. A worker-scoped fixture can't be reset for one test without re-architecting; if mid-test reset is needed, the fixture should be test-scoped.
  • Session storage isn't auto-captured. Per pw-auth (opens in new window): "Manually persist session data since Playwright doesn't automatically capture it" - for sessionStorage-based auth, write custom serialization in the fixture.
  • Teardown failures don't fail the test. A throw inside the post-use() block produces a warning, not a failure. Wrap critical teardown in a runtime check that fails the next test if state is dirty.
  • Fixture timeout is per-fixture, not per-test. Long auth fixtures need { timeout: 60_000 }; the test's own timeout is separate.
Reference

Composition, teardown ordering, boxing, output format

View source (opens in a new tab) (opens in new window)

Compose into one test export

The pyramid: each layer extends the previous one. Tests import from the top:

// fixtures/index.ts
export { test, expect } from './flags';
// tests/checkout.spec.ts
import { test, expect } from '../fixtures';

test('promo code applies when feature flag is on', async ({
  page, flags, cleanDb,
}) => {
  await flags({ promo_codes: true });
  // page is authenticated (from auth fixture, worker scope)
  // cleanDb already ran (auto, test scope)
  await page.goto('/checkout');
  // ...
});

The composition order is the dependency order: auth → db → flags. A fixture can pull anything declared earlier in the chain via its own destructured params.

Teardown ordering

Per pw-fix (opens in new window), teardown runs in reverse order of setup: last-setup-first-teardown. Critical for fixtures that depend on each other:

  • DB fixture sets up before app fixture; app teardown runs first (clean shutdown), then DB teardown.
  • Auth fixture sets up before page fixture; page closes first, then auth state is rolled back.

If a teardown depends on something a downstream fixture set up, the dependency direction is wrong - invert the fixture composition.

Box internal fixtures from the report

Helper fixtures that aren't user-meaningful clutter the test report. Per pw-fix (opens in new window), { box: true } hides them:

export const test = base.extend({
  _internalSetup: [async ({}, use) => {
    // ...setup nobody needs to see in the report
    await use();
  }, { box: true }],
});

Output format

## Playwright fixtures - `<suite>`

**Fixtures produced:** N
**File:** `tests/fixtures/index.ts`

| Fixture          | Scope   | Auto | Boxed | Setup cost | Teardown |
|------------------|---------|------|-------|------------|----------|
| `storageState`   | worker  | no   | no    | ~1.2s      | none     |
| `cleanDb`        | test    | yes  | yes   | ~0.4s      | none     |
| `flags`          | test    | no   | no    | ~5ms       | restore empty provider |
| `todoPage`       | test    | no   | no    | ~50ms      | `removeAll()` |

### Scope rationale

- `storageState`: worker-scope cuts ~600ms × N tests. Per-worker
  index avoids cross-worker auth conflicts.
- `cleanDb`: test-scope + auto: every test starts clean; reviewer
  doesn't have to remember to wire it.
- `flags`: test-scope: different tests want different combinations;
  teardown restores empty provider so a leaked override can't
  poison the next test.
- `todoPage`: test-scope: page object holds state.

### Recommended next step

Wire `playwright.config.ts` to use the `authenticated` project per
[pw-auth][pw-auth] for the auth-default suite, and a separate
`anonymous` project for tests that explicitly opt out of auth.
Reference

Fixture recipes - auth, page object, DB, feature flags

View source (opens in a new tab) (opens in new window)

Auth fixture (storageState per worker)

Per pw-auth (opens in new window), the storageState pattern signs in once and reuses cookies + localStorage across tests:

// fixtures/auth.ts
import { test as base, type BrowserContext } from '@playwright/test';
import path from 'node:path';

type AuthFixtures = {
  storageState: string;
};

export const test = base.extend<{}, AuthFixtures>({
  storageState: [async ({ browser }, use, workerInfo) => {
    const username = `user${workerInfo.workerIndex}`;
    const fileName = path.resolve(`playwright/.auth/${username}.json`);

    const page = await browser.newPage({ storageState: undefined });
    await page.goto('/login');
    await page.getByLabel('Email').fill(`${username}@example.com`);
    await page.getByLabel('Password').fill('test-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');

    await page.context().storageState({ path: fileName });
    await page.close();

    await use(fileName);
  }, { scope: 'worker' }],
});

Per pw-auth (opens in new window), the storageState pattern uses await page.context().storageState({ path: authFile }); to write the cookie/localStorage snapshot, and tests pick it up via storageState: 'playwright/.auth/user.json' on the test or project.

Per pw-fix (opens in new window), workerInfo.workerIndex is the canonical way to derive per-worker unique values:

"A common use case is accessing workerInfo.workerIndex to create unique resources per worker."

For per-worker accounts (when each worker mutates its own server-side state), user${workerInfo.workerIndex} is the canonical pattern.

Wire it via test.use

// tests/dashboard.spec.ts
import { test, expect } from './fixtures/auth';

test.use({ storageState: ({ storageState }, use) => use(storageState) });

test('shows the user dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading')).toContainText('Welcome');
});

Or set globally in playwright.config.ts:

projects: [
  {
    name: 'authenticated',
    use: { storageState: 'playwright/.auth/user.json' },
  },
],

Page Object fixture (test-scoped)

Page objects encapsulate per-page selectors and behaviors. The canonical example from pw-fix (opens in new window):

const test = base.extend<{ todoPage: TodoPage }>({
  todoPage: async ({ page }, use) => {
    const todoPage = new TodoPage(page);
    await todoPage.goto();
    await use(todoPage);
    await todoPage.removeAll();
  },
});

The use() call demarcates setup vs teardown: code before is setup, code after is teardown. The teardown runs even if the test fails.

DB fixture (per-test snapshot/restore)

// fixtures/db.ts
import { test as base } from './auth';
import { execSync } from 'node:child_process';

type DbFixtures = {
  cleanDb: void;
};

export const test = base.extend<DbFixtures>({
  cleanDb: [async ({}, use) => {
    execSync('bash scripts/restore-test-db.sh', { stdio: 'inherit' });
    await use();
    // No teardown - next test runs `restore` itself.
  }, { auto: true }],
});

Per pw-fix (opens in new window), { auto: true } makes a fixture run for every test even if the test doesn't list it in its parameters - the right choice for cross-cutting state like DB reset.

The shell script delegates to a database snapshot/restore in restore mode.

Feature-flag fixture (composes with feature-flag-test-harness)

// fixtures/flags.ts
import { test as base } from './db';
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

type FlagFixtures = {
  flags: (overrides: Record<string, unknown>) => Promise<void>;
};

export const test = base.extend<FlagFixtures>({
  flags: async ({}, use) => {
    const setFlags = async (overrides: Record<string, unknown>) => {
      const provider = new InMemoryProvider(buildVariants(overrides));
      await OpenFeature.setProviderAndWait(provider);
    };
    await use(setFlags);
    // Teardown: restore an empty provider so the next test starts clean.
    await OpenFeature.setProviderAndWait(new InMemoryProvider({}));
  },
});

function buildVariants(overrides: Record<string, unknown>) {
  return Object.fromEntries(
    Object.entries(overrides).map(([k, v]) => [k, {
      defaultVariant: 'configured',
      variants: { configured: v },
      disabled: false,
    }]),
  );
}

A test that needs a flag picks the value:

test('shows new checkout when flag is on', async ({ page, flags }) => {
  await flags({ new_checkout: true });
  await page.goto('/checkout');
  await expect(page.getByTestId('new-checkout-banner')).toBeVisible();
});

For the matrix harness pattern (one shard per combo), see feature-flag-test-harness.