playwright-fixture-builder
Builds reusable Playwright fixtures via `test.extend` - picks the right scope (test vs worker), wires the `use(value)` setup/teardown split, composes auth (storageState per worker), database (per-test snapshot/restore), and feature-flag fixtures into one custom `test` object the whole suite imports. Outputs the `fixtures.ts` file plus per-fixture review notes (scope rationale, teardown ordering, `workerInfo.workerIndex` for parallel isolation). Use when the suite has copy-pasted `beforeEach` boilerplate that should be a fixture, or when adding auth / db / flag setup that crosses many specs.
Install with skills.sh (any agent)
npx skills add testland/qa --skill playwright-fixture-builderplaywright-fixture-builder
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
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
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.
| Fixture | Scope | Why |
|---|---|---|
| Authenticated user | worker | Auth handshake is expensive (UI login, cookie set, OTP); state is shareable. |
| Storage-state file path | worker | One storageState per worker keeps server-side cohorts isolated. |
| Page object (TodoPage) | test | Each test needs a clean DOM and navigation start. |
| Test-DB snapshot | test | Per-test isolation requires per-test restore. |
| Feature-flag overrides | test | Different tests may want different flag combos (compose with feature-flag-test-harness). |
| Browser instance | worker | (Playwright default) - sharing cuts ~500ms per test. |
| Test-data factory | test | Each 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
Anti-patterns and limitations
View source (opens in new window)Anti-patterns and limitations
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Worker-scoped fixture for state that changes between tests | Tests 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 suite | beforeAll 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 setup | Reverse-order teardown means the dependency is gone when teardown runs. | Invert composition: dependent fixture extends the dependency. |
Manual await context.close() inside a test | Bypasses Playwright's cleanup; flake on the next test. | Let the page/context fixture handle close in its teardown. |
| Hard-coded port / DB name in fixtures | Two parallel workers fight over the same resource. | Derive from workerInfo.workerIndex per pw-fix (opens in new window). |
Storing playwright/.auth/*.json in git | Per 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+flags | Tests can't opt out of pieces; one tweak breaks everyone. | Atomic fixtures composed via extend per Step 6. |
Limitations
Composition, teardown ordering, boxing, output format
View source (opens in new window)Composition, teardown ordering, boxing, output format
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:
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.Fixture recipes - auth, page object, DB, feature flags
View source (opens in new window)Fixture recipes - auth, page object, DB, feature flags
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.workerIndexto 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.
Related skills
docker-compose-tests
Authors a `compose.test.yaml` for tests - declares the SUT plus its real backing services as one declarative topology, wires healthcheck-driven `depends_on: condition: service_healthy` start ordering, isolates parallel CI jobs via per-job `--project-name`, gates the test step on `--wait` / `--wait-timeout` / `--exit-code-from`, and tears the stack down deterministically with `down --volumes --remove-orphans`. Use when the test environment is multi-service (app + db + cache + queue) and the topology is best expressed in YAML rather than imperative test code.
feature-flag-test-harness
Builds a test harness that runs the same suite under every relevant flag combination - picks the minimum cover (single flags + pairwise interactions where the team marks them, not the full 2^N cartesian product), wires an OpenFeature in-memory provider so the suite never hits the production flag service, runs each combination as its own labeled CI matrix shard, and emits a per-combination result matrix. Use when a feature behind a flag must be verified on AND off (release toggles + experiment toggles per Hodgson) and the team wants those runs deterministic and parallel.
testcontainers
Brings up real backing services (databases, message brokers, browsers, anything dockerizable) as throwaway containers from inside a test process - Java, Node.js, Python, Go, .NET, Ruby and ten other languages - using the Testcontainers library family. Wires the per-test container lifecycle, exposed-port → host-port mapping, wait strategies (port / log / HTTP / SQL), Ryuk-based cleanup, container-to-container networks, and the (experimental) `withReuse` shortcut for local dev. Use when integration tests need a real Postgres / Redis / Kafka / Selenium / etc. and the team wants per-test isolation without hand-rolled docker-compose teardown.