Skip to main content
Back to Blog
Guide
2026-08-07

Playwright Custom Fixture Composition Guide for Scalable Test Suites

Use this playwright custom fixture composition guide to build typed fixtures, prevent hidden coupling, and make AI-assisted Playwright tests easier to scale.

Playwright Custom Fixture Composition Guide for Scalable Test Suites

A practical Playwright custom fixture composition guide starts with one rule: fixtures should describe test capabilities, not become a hidden application framework. Compose fixtures when they remove repeated setup, make state ownership explicit, and give test authors a typed, stable surface. Do not compose them just because every helper can technically be injected through Playwright's test.extend.

For QA engineers using AI coding agents, fixture design matters more than usual. Agents are good at following visible contracts and bad at guessing invisible lifecycle constraints. A suite with clear fixtures lets an agent create a logged-in page, an API client, or seeded data without copying login flows into every spec. A suite with tangled fixtures produces fragile tests that pass locally, fail under parallel workers, and hide the real reason in a setup phase nobody reads.

The workflow below shows how to compose custom Playwright fixtures around roles, API clients, page objects, seeded records, and diagnostics. It also explains worker scope versus test scope, how to avoid fixture dependency cycles, and how to diagnose the common failure mode where a fixture succeeds but leaves the test in a polluted state.

Start fixture composition with ownership, not convenience

Before writing a fixture, decide who owns the state it creates and when that state must be removed. Playwright's fixture model is powerful because each fixture can declare dependencies, perform setup, call await use(value), and then clean up. That lifecycle is also where teams create confusion. A fixture that logs in, creates a project, mutates feature flags, and returns a dashboard page is not one fixture. It is a bundle of unrelated ownership decisions.

Use a small inventory before adding a fixture. The inventory should name the capability, its scope, its cleanup policy, and the risk of sharing it across tests.

CapabilityGood fixture boundaryScope to preferCleanup responsibility
Authenticated browser stateA page or context already representing one roleTest scope unless state is read-onlyContext closes automatically, server data stays separate
API clientThin client configured with base URL and credentialsWorker scope if token is stableNo cleanup, but requests must include test identifiers
Seeded entityOne record or graph needed by a scenarioTest scopeFixture deletes or marks test-owned data
Page objectNavigation and locators for one screenTest scopeNo server cleanup, relies on page lifecycle
External fakeTest-specific sink for email, webhooks, or eventsTest scope for assertions, worker scope for processFixture resets only the current test channel

The table exposes an important distinction. A fixture can return a convenient object, but the harder question is whether it owns browser state, server state, or only a typed wrapper. Browser state is cheap to isolate. Server state is where parallel test suites get burned. If a fixture creates shared records in a worker and individual tests mutate them, the tests are coupled even if the TypeScript types look clean.

Here is a minimal directory layout that keeps fixture layers readable:

tests/
  fixtures/
    base-test.ts
    auth-fixtures.ts
    api-fixtures.ts
    project-fixtures.ts
    page-fixtures.ts
  pages/
    dashboard-page.ts
    project-page.ts
  support/
    test-ids.ts
  specs/
    project-permissions.spec.ts

The layout is not a rule, but it makes dependencies visible. Auth fixtures should not import page fixtures that import project fixtures that import auth fixtures again. When each file has one reason to exist, cycles become easier to spot during review. AI coding agents also perform better when they can inspect a small fixture file and see the public contract quickly.

Build a narrow base test first

Every composition chain needs a base. Start from Playwright's test, add only shared test infrastructure, and export expect from the same module so specs import from one place. The base should not know about application roles or business entities. Its job is to provide boring, universal utilities such as a unique test ID, a consistent annotation helper, or a logger bound to the current test.

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

type BaseFixtures = {
  testRunId: string;
  note: (message: string) => Promise<void>;
};

export const test = base.extend<BaseFixtures>({
  testRunId: async ({}, use, testInfo) => {
    const safeTitle = testInfo.title.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
    await use([testInfo.workerIndex, Date.now(), safeTitle].join('-'));
  },

  note: async ({}, use, testInfo) => {
    await use(async (message: string) => {
      testInfo.annotations.push({ type: 'note', description: message });
    });
  },
});

export { expect };

This fixture uses documented Playwright concepts: test.extend, testInfo, annotations, and worker index. It intentionally avoids browser work. That keeps it safe as the root for every later fixture. If a future helper needs a database connection, feature flag client, or authenticated browser, it belongs in a more specific layer.

Compose role fixtures without hiding login behavior

Role fixtures are the most common place to overreach. A good role fixture answers a precise question: what role is this browser page using? It should not silently create every business object the test might need. If the role requires saved storage state, keep the storage-state generation separate from test-level page composition.

The following example creates three page fixtures from existing storage-state files. Each fixture creates its own browser context and page, then closes the context after the test. That prevents cookies, local storage, and page-level mutations from leaking between tests.

import { test as base } from './base-test';

type RoleFixtures = {
  adminPage: import('@playwright/test').Page;
  editorPage: import('@playwright/test').Page;
  viewerPage: import('@playwright/test').Page;
};

export const test = base.extend<RoleFixtures>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  editorPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/editor.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  viewerPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/viewer.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

There is some duplication here, and that is acceptable at first. Premature abstraction in fixture files often creates a helper that hides the important part: each role owns a new context and closes it. Once the pattern stabilizes, extract a small function that returns a page for a role. Keep the fixture names explicit because test authors should see the role in the spec signature.

The role fixture is useful when compared with project-level use.storageState. Both are valid, but they optimize for different suites.

ApproachUse it whenStrengthTradeoff
Project-level storage stateMost specs in a project use one roleSimple config, strong report groupingSwitching roles inside one test is awkward
Custom role page fixtureA test needs multiple roles at onceExplicit multi-user scenariosMore fixture code and more contexts
Helper that logs in during each testLogin is part of the assertionVerifies login flow directlySlow and noisy for ordinary feature tests
API-created session fixtureUI login is unstable or expensiveFast setup and precise role controlRequires supported app endpoint or test hook

For a broader map of Playwright, Vitest, Jest, and Cypress responsibilities, see the JavaScript testing frameworks complete guide. Fixture composition works best when Playwright owns browser behavior and lower-level tools own pure function or component checks.

Layer API clients under browser fixtures only when the test needs both

API clients make fixtures far more useful because they can seed data, clean data, and inspect state without driving every action through the UI. The danger is letting the API client become a secret second application. UI assertions should still verify user-visible behavior. Use API calls to prepare and observe, not to skip the workflow under test.

Create a thin client fixture that carries the base URL, request context, and test run identifier. Playwright provides an request fixture for API testing through APIRequestContext. You can use it directly or wrap it in domain methods.

import { test as base } from './auth-fixtures';

type ProjectPayload = {
  name: string;
  visibility: 'private' | 'team';
};

type ProjectRecord = {
  id: string;
  name: string;
};

type ApiFixtures = {
  projectApi: {
    createProject(payload: ProjectPayload): Promise<ProjectRecord>;
    deleteProject(id: string): Promise<void>;
  };
};

export const test = base.extend<ApiFixtures>({
  projectApi: async ({ request, testRunId }, use) => {
    await use({
      async createProject(payload) {
        const response = await request.post('/api/projects', {
          data: { ...payload, externalTestId: testRunId },
        });

        if (!response.ok()) {
          throw new Error('Project creation failed with status ' + response.status());
        }

        return response.json() as Promise<ProjectRecord>;
      },

      async deleteProject(id) {
        const response = await request.delete('/api/projects/' + id);
        if (!response.ok() && response.status() !== 404) {
          throw new Error('Project cleanup failed with status ' + response.status());
        }
      },
    });
  },
});

This fixture does not create a project by itself. It exposes a capability. That distinction is what keeps composition clean. A test that needs no project pays no setup cost. A separate fixture can compose projectApi into a seeded project when many specs need the same arrangement.

import { test as base } from './api-fixtures';

type SeedFixtures = {
  privateProject: { id: string; name: string };
};

export const test = base.extend<SeedFixtures>({
  privateProject: async ({ projectApi, testRunId }, use) => {
    const project = await projectApi.createProject({
      name: 'private-project-' + testRunId,
      visibility: 'private',
    });

    try {
      await use(project);
    } finally {
      await projectApi.deleteProject(project.id);
    }
  },
});

The try and finally block is not decoration. It is the cleanup contract. If an assertion fails, cleanup still runs. If cleanup fails, Playwright reports the fixture teardown error. In CI, that is better than silently leaving records that contaminate the next run.

Keep page objects injectable but not magical

Page objects pair naturally with fixtures, but they should stay close to Playwright's locator model. They should expose task-oriented methods and assertions for one screen. They should not cache element handles, sleep for arbitrary time, or decide which user role should be active. The fixture can construct them with the correct page, and the spec can still see which role is being used.

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

export class ProjectPage {
  constructor(private readonly page: Page) {}

  async goto(projectId: string) {
    await this.page.goto('/projects/' + projectId);
  }

  async renameTo(name: string) {
    await this.page.getByRole('button', { name: 'Project settings' }).click();
    await this.page.getByLabel('Project name').fill(name);
    await this.page.getByRole('button', { name: 'Save changes' }).click();
  }

  async expectReadOnlyBanner() {
    await expect(this.page.getByText('You have view-only access')).toBeVisible();
  }
}

Then compose it with role pages in a fixture:

import { test as base } from './project-fixtures';
import { ProjectPage } from '../pages/project-page';

type PageFixtures = {
  adminProjectPage: ProjectPage;
  viewerProjectPage: ProjectPage;
};

export const test = base.extend<PageFixtures>({
  adminProjectPage: async ({ adminPage }, use) => {
    await use(new ProjectPage(adminPage));
  },

  viewerProjectPage: async ({ viewerPage }, use) => {
    await use(new ProjectPage(viewerPage));
  },
});

This pattern reads cleanly in a spec:

import { test, expect } from '../fixtures/page-fixtures';

test('viewer cannot rename a private project', async ({
  privateProject,
  viewerProjectPage,
}) => {
  await viewerProjectPage.goto(privateProject.id);
  await viewerProjectPage.expectReadOnlyBanner();

  await expect(
    viewerProjectPage['page'].getByRole('button', { name: 'Save changes' }),
  ).toBeHidden();
});

The last assertion shows a design smell: reaching into a private property means the page object does not expose the assertion the test needs. Fix the page object instead of weakening TypeScript. A better page object would provide expectRenameUnavailable. This is one of the places AI-generated tests often reveal missing test APIs. Treat that as useful feedback, not as permission to make the fixture return everything.

For locator strategy, pair this fixture approach with Playwright locator best practices. Fixtures provide the right page and state. Locators still need accessible roles, labels, and stable user-facing names.

Choose worker scope only for immutable or resettable resources

Playwright supports test-scoped and worker-scoped fixtures. Test-scoped fixtures are recreated for each test. Worker-scoped fixtures are shared by all tests running in the same worker process. Worker scope can speed up expensive setup, but it also creates the most subtle coupling in a parallel suite.

Use worker scope for immutable resources, long-lived clients, or expensive containers that can be reset between tests. Do not use it for a logged-in page, a mutable project, or a shared user account whose preferences can change. A worker-scoped browser context can hold cookies modified by the first test, and every later test in that worker inherits the mutation.

ResourceWorker scope safe?Reasonable condition
API client wrapperUsuallyIt is stateless and attaches per-test IDs to requests
Database schema created for one workerSometimesEvery test receives a transaction or unique namespace
Browser contextRarelyOnly for deliberate stateful journey tests, not independent specs
Seed catalog such as countries or plansYesData is read-only and versioned
Feature flag overrideUsually notA test-level override is safer unless reset is guaranteed

Here is a worker-scoped client paired with a test-scoped namespace. The client is shared. The namespace is not.

import { test as base } from '@playwright/test';

type WorkerFixtures = {
  adminToken: string;
};

type TestFixtures = {
  dataNamespace: string;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
  adminToken: [
    async ({}, use) => {
      const response = await fetch(process.env.AUTH_URL + '/token', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          clientId: process.env.E2E_CLIENT_ID,
          clientSecret: process.env.E2E_CLIENT_SECRET,
        }),
      });

      if (!response.ok) {
        throw new Error('Could not create admin token');
      }

      const body = await response.json() as { accessToken: string };
      await use(body.accessToken);
    },
    { scope: 'worker' },
  ],

  dataNamespace: async ({}, use, testInfo) => {
    await use('w' + testInfo.workerIndex + '-t' + testInfo.testId);
  },
});

Notice what the worker fixture returns: a token, not a page with accumulated state. If the token has permissions but no mutable browser storage, it is easier to reason about. If the token's permissions change during a test run, that is a test-environment problem you should detect with a preflight check.

Put fixture contracts where AI agents can read them

AI coding agents can be productive in a Playwright suite when the fixture contracts are obvious. Give them a small exported type surface, examples of approved imports, and comments only where lifecycle is surprising. Ready-made QA skills can install from qaskills.sh with the qaskills CLI, but the local fixture contract still needs to be clear inside your repository.

A short usage file works better than a long style guide hidden in a wiki:

// tests/fixtures/README.fixture-contract.ts
// This file is documentation by example. It is not imported by tests.

import { test, expect } from './page-fixtures';

test('approved fixture usage example', async ({
  adminProjectPage,
  privateProject,
}) => {
  await adminProjectPage.goto(privateProject.id);
  await expect(adminProjectPage.heading()).toContainText(privateProject.name);
});

// Use projectApi only for setup and observation.
// Use page objects for user-visible behavior.
// Do not call login helpers from ordinary feature specs.
// Do not create worker-scoped browser pages.

This kind of file gives an agent a runnable-looking pattern without turning production tests into documentation. It also gives reviewers a compact place to update when fixture policy changes. If an agent repeatedly imports @playwright/test directly instead of your fixture module, add a lint rule or a review check. The mistake is usually not malicious. The contract is simply too hard to discover.

Diagnose fixture failures by locating the lifecycle phase

When a composed fixture fails, the first question is not “which assertion failed?” The first question is “which lifecycle phase failed?” Setup, handoff, test execution, and teardown produce different symptoms. Treat fixture diagnosis as a timeline.

SymptomLikely phaseDiagnostic moveCommon fix
Test body never startsFixture setupAdd annotations and inspect Playwright report setup errorFail fast with missing env var or bad seed response
First assertion sees wrong userFixture handoffCapture current user from UI and API in the fixtureUse separate contexts and correct storage-state path
Only parallel CI failsShared state during testFilter data by test run ID and worker indexMove mutable resource to test scope
Test passes but later suites failTeardownSearch for leftover records tagged with test IDAdd finally cleanup or isolated namespace
Trace starts after the broken actionHidden helper outside PlaywrightMove setup into a fixture or setup projectKeep browser actions under Playwright reporting

Add lightweight diagnostics to fixtures when the failure would otherwise be ambiguous. Do not spam every passing test. Attach useful context only when setup creates an entity or assumes a role.

import { test as base } from './page-fixtures';

export const test = base.extend({
  verifiedAdminPage: async ({ adminPage }, use, testInfo) => {
    await adminPage.goto('/me');
    const email = await adminPage.getByTestId('current-user-email').textContent();

    testInfo.annotations.push({
      type: 'admin-user',
      description: email ?? 'unknown',
    });

    if (!email || !email.endsWith('@example.test')) {
      throw new Error('Admin storage state resolved to unexpected user: ' + email);
    }

    await use(adminPage);
  },
});

This fixture verifies role identity once and leaves a breadcrumb in the report. It does not screenshot every successful setup or log credentials. For sensitive environments, redact emails or attach only the role and account ID. The goal is to make diagnosis faster without creating a new data leak.

What teams get wrong with fixture composition

The most common mistake is treating fixtures as dependency injection for every helper. Playwright fixtures are test lifecycle tools. If a helper has no lifecycle, no asynchronous setup, and no cleanup, a plain function may be clearer. A domain assertion such as expectOrderTotal does not need to be a fixture just because it is reused.

The second mistake is using fixtures to hide test intent. This spec signature is too vague:

test('can edit project', async ({ app }) => {
  await app.project.rename('New name');
  await app.project.expectSaved();
});

The test does not reveal the role, the project source, or whether the browser or API performed the rename. It may be convenient, but it gives reviewers little evidence. A more explicit signature is longer and better:

test('editor can rename a team project', async ({
  editorProjectPage,
  teamProject,
}) => {
  await editorProjectPage.goto(teamProject.id);
  await editorProjectPage.renameTo('Release notes workspace');
  await editorProjectPage.expectName('Release notes workspace');
});

The third mistake is composing fixtures in the wrong direction. Page fixtures can depend on role pages. Seed fixtures can depend on API clients. Specs can depend on all of them. But API clients should not depend on page objects, and base fixtures should not depend on business entities. Direction keeps the graph acyclic and keeps setup work proportional to the test.

A migration path for an existing messy suite

Do not rewrite every spec at once. Start by creating a base fixture module, then move one repeated setup pattern at a time. Pick the pattern with the clearest failure cost, usually login, seeded test data, or repeated navigation. Leave old helpers in place until the first composed fixture proves stable in CI.

Use this migration checklist:

StepDone whenRisk controlled
Create base fixture moduleNew specs import test and expect from one local fileFuture composition has a stable root
Move role setupOne role page fixture replaces copied login in a small groupAuth state no longer drifts by spec
Add seeded data fixtureA fixture creates and cleans one entity typeTests stop sharing mutable records
Introduce page object fixtureOne page object wraps stable locatorsUI tests read as workflows
Enforce importsDirect @playwright/test imports are flagged in spec foldersNew tests follow the contract

A simple CI check can catch direct imports after migration. Use a script that scans spec files and fails when they bypass the fixture module.

import fs from 'node:fs';
import path from 'node:path';

function walk(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const full = path.join(dir, entry.name);
    return entry.isDirectory() ? walk(full) : [full];
  });
}

const offenders = walk('tests')
  .filter((file) => file.endsWith('.spec.ts'))
  .filter((file) => fs.readFileSync(file, 'utf8').includes("from '@playwright/test'"));

if (offenders.length > 0) {
  console.error('Import test from tests/fixtures/page-fixtures instead:');
  for (const file of offenders) console.error(file);
  process.exit(1);
}

This is intentionally crude. Use ESLint if your project already has it. The point is to protect the convention once the convention exists. Without enforcement, AI-generated specs and hurried human patches will copy old imports from nearby files.

A complete composed fixture example

The final shape should feel boring. A spec imports from one fixture module, asks for the capabilities it needs, and reads like a user workflow plus a few setup facts.

import { test, expect } from '../fixtures/page-fixtures';

test.describe('project permissions', () => {
  test('admin can rename a private project while viewer cannot', async ({
    adminProjectPage,
    viewerProjectPage,
    privateProject,
  }) => {
    await adminProjectPage.goto(privateProject.id);
    await adminProjectPage.renameTo('Q3 automation plan');
    await adminProjectPage.expectName('Q3 automation plan');

    await viewerProjectPage.goto(privateProject.id);
    await viewerProjectPage.expectReadOnlyBanner();

    await expect(viewerProjectPage.renameButton()).toBeHidden();
  });
});

The test is not short because it hides everything. It is short because the repeated lifecycle work has names. That is the standard to aim for: explicit capabilities, narrow ownership, and cleanup that does not depend on the assertion path succeeding.

Frequently Asked Questions

Should every Playwright helper become a custom fixture?

No. Use a fixture when the helper owns lifecycle: setup, dependency injection, isolation, teardown, or test metadata. Use a plain function for pure assertions, data builders, string formatting, and small actions that do not need Playwright's fixture lifecycle. Over-fixturing makes tests harder to read because the spec signature stops describing meaningful state. A good fixture changes what resources are available to the test. A simple helper just computes or performs a reusable operation.

Is worker scope safe for authenticated Playwright pages?

Usually no. A worker-scoped page keeps browser state across multiple tests in the same worker, including local storage, cookies, visited routes, and in-app changes. That can be useful for a deliberate stateful journey, but it undermines independent specs. Prefer test-scoped contexts created from storage state. If login is expensive, generate storage state once in a setup project, then create fresh contexts from that file for each test that needs the role.

How should fixtures clean server data after failures?

Put cleanup after await use(value) inside a finally block when the fixture creates server data. Tag created records with a test run identifier so cleanup can target only the current test. Cleanup should tolerate already-deleted records when the UI action under test removes them. If cleanup fails, let the test report show it. Silent cleanup failures create delayed, confusing CI problems that look unrelated to the original spec.

What fixture guidance should I give AI coding agents?

Give agents one approved import path, one example spec, and a short contract describing which fixtures create state and which only wrap pages or clients. Keep that contract in the repository near the fixtures, not only in a chat prompt. Agents copy nearby patterns, so remove outdated direct imports from @playwright/test after migration. When an agent reaches into private page-object fields, treat it as a signal that the fixture or page object lacks a needed public assertion.