PRACTICAL GUIDE / Playwright test runner lifecycle fixture architecture

Why your Playwright fixtures change behavior in CI

Design Playwright fixtures that respect test and worker lifecycles, clean up reliably, and expose enough evidence to diagnose CI-only failures quickly.

By The Testing AcademyUpdated August 7, 202620 min read
All field guides
In this guide7 sections
  1. Trace the lifecycle before changing the timeout
  2. Put mutable state at the narrowest useful scope
  3. Capture evidence from the failing attempt
  4. Distinguish a worker restart from an ordinary second attempt
  5. Separate fixture sharing from allocator collisions
  6. Treat teardown as production code
  7. Roll out lifecycle changes without hiding regressions

What you will learn

  • Trace the lifecycle before changing the timeout
  • Put mutable state at the narrowest useful scope
  • Capture evidence from the failing attempt
  • Distinguish a worker restart from an ordinary second attempt

A checkout test passes by itself, then fails when the full suite runs with four workers. The failure appears in the second test, but the account was created in a fixture used by the first. A retry passes because it starts with a different account in a fresh worker. Increasing the timeout will not repair that ownership mistake.

Fixture bugs are difficult because the assertion often reports the damage, not the cause. A cart is already populated, a feature flag belongs to another test, or teardown never released a lease. The right question is not simply which hook ran. It is which process owned the resource, which test was allowed to mutate it, and what Playwright did with that process after failure.

Trace the lifecycle before changing the timeout

Playwright Test builds a dependency graph from the fixtures requested by a test, its hooks, and other fixtures. A fixture is normally lazy. Defining it does not run it; requesting it does. An automatic fixture is the exception because { auto: true } makes the runner request it for every test or worker in its scope.

Each fixture function has a clear boundary around await use(value). Code before use is setup. The test, hook, or dependent fixture receives the value while use is suspended. Code after use is teardown. If fixture A depends on fixture B, B is set up first and torn down last. That reverse order matters when one resource needs another resource during cleanup.

Scope decides how long the value is cached. A test-scoped fixture is created for one test and torn down after that test. A worker-scoped fixture is created once in a worker process and torn down when that worker exits. It is not a suite-wide singleton. Parallel workers each get their own instance, and a replacement worker creates another one.

The following self-contained fixture file makes the boundary visible without relying on an application. A temporary workspace belongs to one worker. Each test gets a separate audit file inside it. The test fixture depends on the worker fixture, so the directory remains available until all audit files have been torn down.

TypeScript
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

type TestFixtures = {
  auditFile: string;
};

type WorkerFixtures = {
  workerWorkspace: string;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
  workerWorkspace: [async ({}, use, workerInfo) => {
    const directory = await mkdtemp(
      join(tmpdir(), `pw-worker-${workerInfo.workerIndex}-`),
    );
    console.log(`WORKER_SETUP ${workerInfo.workerIndex} ${directory}`);

    await use(directory);

    console.log(`WORKER_TEARDOWN ${workerInfo.workerIndex} ${directory}`);
    await rm(directory, { recursive: true, force: true });
  }, { scope: 'worker' }],

  auditFile: async ({ workerWorkspace }, use, testInfo) => {
    const safeTestId = createHash('sha256')
      .update(testInfo.testId)
      .digest('hex')
      .slice(0, 16);
    const file = join(workerWorkspace, `${safeTestId}.txt`);
    await writeFile(
      file,
      `test=${testInfo.testId} setup retry=${testInfo.retry}\n`,
      'utf8',
    );

    await use(file);

    await writeFile(file, `teardown retry=${testInfo.retry}\n`, {
      encoding: 'utf8',
      flag: 'a',
    });
  },
});

export { expect };

A test can request auditFile directly. A second test in the same worker may share the directory, but it cannot share the file because the test fixture creates a new path from testInfo.testId.

TypeScript
// tests/lifecycle.spec.ts
import { readFile } from 'node:fs/promises';
import { test, expect } from './fixtures';

test('writes into its own audit file', async ({ auditFile }) => {
  const content = await readFile(auditFile, 'utf8');
  expect(content).toContain('setup retry=');
});

test('gets a different audit file', async ({ auditFile }, testInfo) => {
  const content = await readFile(auditFile, 'utf8');
  expect(content).toContain(`test=${testInfo.testId}`);
});

This example also exposes a cleanup detail that production fixtures often miss. The audit file teardown uses the worker directory, so the worker directory must be removed after the audit fixture finishes. Declaring that dependency gives Playwright enough information to enforce the order. Two unrelated afterEach and afterAll hooks do not communicate the relationship as clearly.

Requesting a fixture is itself the trigger. A test that lists auditFile in its parameter object causes setup even if the body never reads the variable. Remove that parameter and the lazy fixture does not run unless a hook or another fixture depends on it. This matters during migrations: deleting an apparently unused destructured name can remove setup, cleanup, and evidence. Automatic fixtures make that dependency unconditional and should therefore remain limited to policy every test needs.

Put mutable state at the narrowest useful scope

The most expensive setup is not automatically a worker fixture. Scope describes allowed sharing, not desired speed. If two tests can change a resource in conflicting ways, broadening its scope trades startup time for order dependence.

Consider an account fixture. Creating an account may be slow, so a team makes it worker-scoped. One test changes the locale, another disables notifications, and a third expects default preferences. All three are valid in isolation. In one worker they are three operations on the same account. The failing test depends on execution order even if every page and browser context is test-scoped.

A better split keeps allocation broad only when the allocated unit is safe to share. One worker can own a unique namespace, while every test creates its own account inside that namespace. The example below uses an application-specific API contract, but every Playwright API shown is real. The request fixture is test-scoped, so the test-scoped account may depend on it. The worker namespace does not.

TypeScript
// tests/app-fixtures.ts
import { test as base, expect } from '@playwright/test';

type Account = { id: string; email: string };
type TestFixtures = { account: Account };
type WorkerFixtures = { namespace: string };

export const test = base.extend<TestFixtures, WorkerFixtures>({
  namespace: [async ({}, use, workerInfo) => {
    await use(`e2e-w${workerInfo.parallelIndex}`);
  }, { scope: 'worker' }],

  account: async ({ request, namespace }, use, testInfo) => {
    const response = await request.post('/test-support/accounts', {
      data: {
        emailPrefix: `${namespace}-${testInfo.testId}`,
      },
    });
    expect(response.ok()).toBeTruthy();
    const account = await response.json() as Account;

    await use(account);

    const cleanup = await request.delete(`/test-support/accounts/${account.id}`);
    expect(cleanup.ok()).toBeTruthy();
  },
});

export { expect };

There is a cost. Function-level account creation adds one create and one delete request per test. That latency is visible and honest. If it becomes material, improve the test-support endpoint, create accounts concurrently at an external orchestration layer, or reset a leased account through a verified API. Do not silently share mutable accounts and call the resulting suite fast.

Browser objects follow the same rule. Playwright's built-in browser fixture is worker-scoped. The built-in context and page fixtures are test-scoped, which gives each test an isolated browser context by default. Replacing that model with a worker-scoped page shares cookies, storage, open dialogs, event listeners, routes, and navigation history. That is almost never the ownership contract an end-to-end test expects.

An override can also hide scope. If a custom page fixture logs in and hands the built-in page onward, it remains test-scoped unless configured otherwise. If a worker fixture creates a page manually from browser.newPage(), that page belongs to the worker fixture. The variable name does not determine the lifecycle; the fixture declaration does.

Capture evidence from the failing attempt

Console lines are useful locally, but parallel CI output interleaves workers. Record identity with every lifecycle event: test ID, retry number, worker index, parallel index, resource ID, and phase. A timestamp can help order events, but do not use wall-clock ordering as the only ownership proof because CI machines and remote services may disagree about time.

An automatic test-scoped fixture can attach a structured log after each test. It runs even when the test does not mention it. The fixture below keeps events in memory, marks whether the observed status matched the expected status, and attaches JSON through testInfo.attach. It does not turn cleanup failure into a passing test.

TypeScript
// tests/lifecycle-evidence.ts
import { test as base } from '@playwright/test';

type Event = {
  phase: 'setup' | 'handoff' | 'teardown';
  testId: string;
  retry: number;
  workerIndex: number;
  parallelIndex: number;
};

export const test = base.extend<{ lifecycleEvidence: void }>({
  lifecycleEvidence: [async ({}, use, testInfo) => {
    const events: Event[] = [];
    const record = (phase: Event['phase']) => events.push({
      phase,
      testId: testInfo.testId,
      retry: testInfo.retry,
      workerIndex: testInfo.workerIndex,
      parallelIndex: testInfo.parallelIndex,
    });

    record('setup');
    record('handoff');
    await use();
    record('teardown');

    await testInfo.attach('fixture-lifecycle', {
      body: Buffer.from(JSON.stringify({
        expectedStatus: testInfo.expectedStatus,
        observedStatus: testInfo.status,
        events,
      }, null, 2)),
      contentType: 'application/json',
    });
  }, { auto: true }],
});

Import the extended test from one controlled fixture module. If some files import from @playwright/test directly, the automatic fixture will not exist in those files. Mixed imports are a common reason evidence seems to disappear only for part of a suite.

Run the smallest failing unit with one worker first, then repeat it with the CI worker count. The two commands answer different questions.

Shell
npx playwright test tests/checkout.spec.ts --workers=1 --repeat-each=2 --reporter=list
npx playwright test tests/checkout.spec.ts --workers=4 --repeat-each=10 --reporter=list

The first run checks whether state leaks between sequential tests in one worker. The second increases scheduling variation and creates multiple worker fixture instances. --repeat-each repeats tests as independent runs; it is not the same as a retry after failure. Keep retries at zero during reproduction when you need the original error to remain obvious.

In Trace Viewer, select the failed action and inspect the source, call log, network requests, and DOM snapshot. A trace can show that the page was already logged in as the wrong user. It cannot, by itself, prove which fixture allocated that user unless you record the resource ID. That is why lifecycle attachments and traces complement each other.

Distinguish a worker restart from an ordinary second attempt

Playwright runs tests in worker processes. After a test failure, the runner discards the worker process and starts another worker for subsequent work. With retries enabled, the failed test is tried again in a new worker. Its test-scoped fixtures are new, and its worker-scoped fixtures are new as well.

That behavior creates a diagnostic trap. Suppose the first attempt uses a worker-scoped account whose cart was polluted by an earlier test. The assertion fails. The worker is discarded. The retry provisions another account with an empty cart, so it passes. The report calls the test flaky, but the useful finding is more specific: process replacement removed shared state.

A typical lifecycle log for that situation has this shape. The identifiers are illustrative, not measurements from a run:

Do not infer that the account caused the failure merely because its ID changed. Confirm the first account's state through an API response, database-safe test endpoint, or UI evidence in the trace. The worker transition is a lead, not proof of the product condition.

Another near-miss produces a similar retry pattern: a test leaves a route, event listener, or clock override on a test-scoped page, then fails. The replacement worker also makes the retry clean. If the resource is actually test-scoped, inspect whether a custom fixture reused a context or page despite its declared scope. If it did not, look for external state such as a server-side account, cache entry, or message queue item. Browser isolation cannot reset systems outside the browser.

Hook semantics can mislead in the same way. beforeAll runs once per worker process for its scope, not once for an entire multi-worker run. A failure that restarts a worker may cause beforeAll to run again. If the hook creates a record with a fixed unique key, the retry can fail in setup with a duplicate error. Make suite-level preparation idempotent or move it to a project dependency whose result and failure are reported explicitly.

Separate fixture sharing from allocator collisions

A dirty account in the second test does not always mean Playwright cached a worker-scoped account. A test-scoped fixture can execute twice and still receive the same external resource twice. The test-support allocator may derive its key from a non-unique title, reuse a namespace after a worker restart, or return an existing record for a repeated idempotency identity. The browser symptom is nearly identical: a later test opens a cart or preference state created earlier. The fix belongs in allocation identity, not fixture scope.

Record the immutable resource ID returned by creation beside the lifecycle phases. Also record the owning test ID, retry, worker index, parallel index, and whether the create call was attempted and accepted. Do not record account secrets or complete response bodies. These few fields reveal which boundary reused state.

A healthy test-scoped allocation has one setup and handoff for a test attempt, a resource ID not simultaneously owned by another attempt, and a teardown outcome for that same ID. A genuinely broad fixture has one worker-level allocation followed by several test IDs using that resource, with no second create operation between them. An allocator collision has two separate test-scoped setup records and two create attempts, but both responses identify the same resource. Moving the fixture from worker scope to test scope fixes the broad case and leaves the collision untouched.

The most misleading field is workerIndex. A changed worker index proves process replacement, but it does not prove a new external namespace. The replacement keeps the same parallel index, and application code may intentionally or accidentally derive identity from that stable slot. Conversely, two tests sharing one worker can still receive different accounts when the allocator includes a unique test-attempt identity. Read process identity and resource identity as separate dimensions.

Retry number can also distract. A passing retry with a new resource suggests state sensitivity, but it does not identify who chose the new resource. Compare the failed and passing allocation records. If the fixture made no create call on the first attempt, scope is the leading defect. If both attempts called create and the first collided with an earlier owner, the service contract or key construction is responsible. If resource IDs differ but both expose the same dirty feature flag, the shared state sits above the account boundary.

The attachment can contain a correct setup, handoff, and teardown sequence while the product remains dirty. Those phases prove that fixture code ran, not that an API implemented isolation correctly. Pair the ledger with the earliest evidence of contamination, such as the account ID visible through a safe test-support response or the user identity shown in the trace. Avoid inferring database state from a page URL alone.

This diagnosis changes the owner. Test-platform maintainers own fixture scope, dependency declarations, and the identity passed to the allocator. The application or test-support service team owns uniqueness, reset semantics, and deletion of related records. CI maintainers own concurrency and shard inputs that feed namespace construction. A useful handoff contains both test IDs, attempts, worker and parallel indexes, every returned resource ID, sanitized create and delete outcomes, and the first trace or response that shows dirty state. Retry passed is not a sufficient service ticket.

The stronger isolation contract costs real capacity. Per-test resources add create, reset, and delete traffic and can exhaust a small pool under full parallelism. A richer ledger adds report data and maintenance around correlation fields. Keeping allocation at worker scope reduces that load but requires tests to accept shared mutation or a verified reset between consumers. Choose with measured queue, endpoint, and cleanup behavior from the suite, not with a guessed runtime percentage.

Treat teardown as production code

Cleanup executes under pressure. The test may already have timed out, the page may be closed, the API may be returning errors, or a worker may be exiting after failure. A fixture that assumes the happy path can replace the original assertion with a teardown error and make triage much harder.

Keep cleanup close to creation and make it target the exact resource created by that fixture. Store immutable IDs rather than searching by a broad name. If deletion is safe to repeat, design the endpoint to return an acceptable result when the resource is already gone. If deletion is not repeatable, record the response and distinguish not found from authentication or infrastructure failure.

Partial setup needs its own path. Imagine that the fixture creates a tenant, enables two flags, and then fails while seeding data. If cleanup exists only after await use(tenant), control never reaches it because setup did not hand a value to the test. The tenant remains even though no test body ran. A try and finally around both the remaining setup and the handoff protects the resource as soon as its ID exists.

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

type Tenant = { id: string };

export const test = base.extend<{ tenant: Tenant }>({
  tenant: async ({ request }, use, testInfo) => {
    const createResponse = await request.post('/test-support/tenants', {
      data: { owner: testInfo.testId },
    });
    expect(createResponse.ok()).toBeTruthy();
    const tenant = await createResponse.json() as Tenant;

    try {
      const seedResponse = await request.post(
        `/test-support/tenants/${tenant.id}/seed`,
      );
      if (!seedResponse.ok()) {
        throw new Error(`tenant seed failed: HTTP ${seedResponse.status()}`);
      }

      await use(tenant);
    } finally {
      const deleteResponse = await request.delete(
        `/test-support/tenants/${tenant.id}`,
      );
      if (!deleteResponse.ok() && deleteResponse.status() !== 404) {
        await testInfo.attach('tenant-cleanup-failure', {
          body: `tenant=${tenant.id} status=${deleteResponse.status()}`,
          contentType: 'text/plain',
        });
        throw new Error(
          `tenant cleanup failed: HTTP ${deleteResponse.status()}`,
        );
      }
    }
  },
});

This pattern has a sharp edge. A cleanup exception thrown from finally can become the most prominent error when seeding or the test already failed. The attachment keeps the tenant ID and cleanup response available, but it does not preserve every earlier stack automatically in every reporter. If both failures matter, catch the setup error, attach its message, attempt cleanup, then rethrow with the original error as the cause according to your runtime's error-handling policy. Do not reduce the code to an empty catch around deletion.

There is also a boundary before resource creation. If the create request itself fails, no tenant ID exists and no tenant cleanup should run. Logging cleanup succeeded in that branch is misleading. Record the failed create response as setup evidence and let Playwright classify the test as a fixture setup failure rather than an assertion failure. That distinction tells an on-call engineer to inspect the environment or test-support service before reading the checkout trace.

Do not swallow every cleanup exception. A leaked tenant or reserved account can poison later tests and create cost outside the suite. Attach the original test status, attempt cleanup, and let a genuine cleanup failure remain visible. Where the product assertion already failed, preserve both errors through an attachment or structured log so reviewers do not see only the last stack trace.

Fixture time also belongs in the timeout model. Test-scoped fixture setup and teardown normally count toward the test timeout. A fixture can have its own timeout through the tuple options, and worker-scoped fixtures have separate timeout behavior described by Playwright. Use a fixture timeout when setup is legitimately slower than the test body, not to excuse an unbounded poll.

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

export const test = base.extend<{ preparedDataset: string }>({
  preparedDataset: [async ({ request }, use) => {
    const created = await request.post('/test-support/datasets');
    if (!created.ok()) {
      throw new Error(`dataset setup failed: HTTP ${created.status()}`);
    }
    const { id } = await created.json() as { id: string };

    await use(id);

    const removed = await request.delete(`/test-support/datasets/${id}`);
    if (!removed.ok() && removed.status() !== 404) {
      throw new Error(`dataset cleanup failed: HTTP ${removed.status()}`);
    }
  }, { timeout: 60_000 }],
});

The trade-off is that a separate fixture timeout can lengthen a hung job. Keep the limit explicit and monitor which phase consumed it. A setup endpoint that sometimes needs fifty seconds may be an environment problem worth fixing, not a permanent reason to grant every test another minute.

Roll out lifecycle changes without hiding regressions

Start by inventorying fixture declarations and imports. Record each fixture's scope, dependencies, mutable outputs, cleanup action, and consumers. The risky items are worker fixtures that expose pages, contexts, accounts, queues, feature flags, or writable files. Pure configuration values and immutable service clients are usually easier to share.

Next, add evidence before changing scope. Run a representative shard with retries disabled and retain traces on first failure. Resource IDs will reveal actual sharing. Without that baseline, a reduction in failures after a refactor could come from lower concurrency, changed ordering, or lost coverage.

Move one mutable fixture to test scope and measure the operational cost using real CI data from your run. Do not publish guessed savings or overhead. If session creation dominates, split allocation from reset, or improve the test-support API. Keep the product assertion unchanged while changing ownership so the rollout tests one variable.

Wire CI to preserve the first failing attempt and its attachments. A conservative configuration can retry only in CI while retaining a trace for the first retry, but teams investigating lifecycle bugs may temporarily set retries to zero on the diagnostic job.

TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  fullyParallel: true,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['line'],
    ['html', { open: 'never' }],
  ],
  use: {
    screenshot: 'only-on-failure',
    trace: 'on-first-retry',
  },
});

Finally, add a deliberate isolation test. Create state in one test and assert the next test begins clean, or run two tests concurrently against separate identities. This is not a substitute for product coverage. It is a contract test for the test platform itself.

An existing suite should roll out the ownership change along two axes: lifecycle first, allocation second. Land resource identity evidence while the old scopes remain in place, then run the same representative shard with retries disabled. That baseline tells reviewers whether the old failure uses one fixture allocation or two colliding allocations.

Next, add contract cases for concurrent uniqueness and cleanup outcomes at the test-support boundary. Only then split a worker namespace from the test-owned account or tenant. Move one fixture family and leave selectors, navigation, and product assertions unchanged. The tests most likely to break first are those that assume an earlier login, reuse a server record by title, or delete data through a broader search instead of the returned immutable ID.

Run the migrated family sequentially and at intended concurrency before restoring retries. Sequential success proves only that the new setup can work. Concurrent success with distinct resource IDs exercises the isolation promise. A forced assertion failure should also leave a visible teardown outcome, because cleanup behavior after failure is part of the change.

The rollout is working when each concurrent attempt has an unambiguous resource owner, cleanup targets the same ID creation returned, and a retry no longer passes merely because process replacement selected clean state. Track actual create latency, delete failures, pool utilization, and first-attempt failure classification. If job time rises, that is the visible price of isolation and a prompt to improve provisioning, not a reason to merge the resource scopes again without evidence.

This lifecycle technique does not catch state whose ownership key is missing from the ledger. A global feature flag, shared message topic, or tenant-wide cache can contaminate tests even while account IDs are unique and every fixture tears down in order. Add the real shared key to the ownership model or isolate that service separately. A perfect page and account fixture cannot prove isolation for an unnamed external resource.

Do not broaden fixture scope merely to reduce runtime, turn every helper into an automatic fixture, or use a shared page to avoid login. Avoid fixtures for one-line pure values that are clearer as constants. Skip custom lifecycle layers when the built-in page, context, and request fixtures already express the ownership you need. The best fixture architecture is usually the smallest graph that makes unsafe sharing impossible and leaves enough evidence to explain the first failure.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Should a browser or account fixture use worker scope in Playwright?

Choose worker scope only when tests in one worker may safely share the resource. A browser service or immutable account allocation may fit; a mutable page, cart, or logged-in context usually does not.

Why does a Playwright retry get different fixture state?

After a test failure, Playwright discards that worker process and continues in a new one. Worker-scoped fixtures are therefore created again, which can make a retry pass for a different reason than the original attempt failed.

Does beforeAll run once for the whole Playwright suite?

No. A beforeAll hook runs once per worker process for its file or describe block, and it can run again after a worker restart. Use a project dependency or an external idempotent setup when work must happen once beyond a worker's lifetime.

How can I prove fixture teardown ran in CI?

Attach a small lifecycle log from an automatic fixture and record setup, handoff, teardown, worker index, retry number, and test ID. The trace proves browser activity, while the attachment proves your fixture code reached its cleanup branch.

When should a Playwright fixture be automatic?

Reserve automatic fixtures for behavior every test genuinely needs, such as failure logging or a mandatory test boundary. Auto fixtures add setup and teardown to all affected tests, so a slow or stateful one can quietly tax the entire suite.