CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

fixture-recipes.mdreferences/

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

Auth fixture (storageState per worker)

Per pw-auth, 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, 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, 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:

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, { 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.

SKILL.md

tile.json