Playwright architecture, selector reliability, and advanced interaction patterns.

Playwright Config & Fixtures

playwright.config.ts and the fixture system are the wiring that turns isolated browser objects into a coherent test run. The config file is the single source of truth for where tests live, how long they may run, how many workers execute them, and which artifacts to capture. Fixtures replace lifecycle hooks with dependency injection: you declare named factories, and the runner builds exactly what each test asks for, in dependency order, then tears them down in reverse — even when an assertion throws. Get these two layers right and the same suite behaves identically on a laptop and across sharded CI runners. This guide covers typed config, environment overrides, the worker-versus-test scope decision, automatic fixtures, global setup, the option hierarchy, failure modes, and parallel-safe isolation. It builds on Playwright Setup & Core Architecture, and it is the layer where a dedicated setup project for Authentication & Session State is declared.

Config and fixture build-and-teardown order The config feeds global setup, then per-worker fixtures, then per-test fixtures which build in order and tear down in reverse around the test body. defineConfig global setup Worker fixture seed / token Test fixture fresh context Test body use() value teardown in reverse
Config drives global setup, then worker fixtures, then test fixtures; each is torn down in reverse order after the test body returns.

Typed configuration with defineConfig

Wrap the config object in defineConfig so TypeScript validates every option at compile time and your editor autocompletes the schema. Type-safe config is not cosmetic: a misspelled testDir or an out-of-range timeout is caught before it silently changes CI behavior. Declare the structural options — testDir, timeout, retries, fullyParallel — explicitly so configuration drift cannot compromise pipeline stability. The baseline directory layout and runner initialization this builds on are described in Playwright Setup & Core Architecture.

Two options carry more weight than the rest because they determine how the suite parallelizes. workers sets how many operating-system processes run specs concurrently; fullyParallel: true allows tests inside a single file to spread across those workers rather than running serially within one. Together they set the concurrency ceiling that every fixture-scoping decision later in this guide has to respect. Leaving workers unset lets Playwright pick a value from the CPU count, which is fine locally but should be pinned explicitly in CI so a runner with a different core count does not quietly change your effective parallelism — and, with it, the collision surface your seed data has to survive.

Environment-specific overrides

Production automation must resolve configuration dynamically rather than hardcoding values. Inject environment-specific parameters through process.env while keeping the base object immutable, so the same file targets local, staging, and CI by reading the environment rather than by branching into separate config files. Map baseURL, viewport, and trace settings conditionally, and avoid synchronous file reads or runtime mutations that can race during parallel execution.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: {
    // Resolve the target environment at runtime, immutably.
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'retain-on-failure',
    viewport: { width: 1280, height: 720 },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

This is also where the engine matrix is declared, so the project list feeds directly into Cross-Browser Execution, and where shared storageState, viewport, and routing are centralized for Browser Contexts & Isolation. Resolve every environment variable once, at module top level, into typed constants; scattering process.env reads through fixtures makes the effective configuration impossible to reason about and reintroduces exactly the race conditions the immutable base object was meant to eliminate.

Worker versus test fixtures

Playwright replaces beforeEach and afterAll with a dependency-injection model, and the central decision is fixture scope. A test-scoped fixture rebuilds for every spec, giving each test a fresh, fully isolated browser context — the default for clean isolation. A worker-scoped fixture persists across all the files one worker runs, which is where expensive one-time work belongs: seeding a database, minting an auth token, warming a cache. Choosing the wrong scope is a common cost: per-test database seeding that should have been per-worker turns a fast suite slow.

Worker-scoped versus test-scoped fixtures A comparison matrix contrasting the two fixture scopes across rebuild frequency, isolation, ideal use, and cost. Aspect Worker-scoped Test-scoped Rebuild Once per worker Once per test Isolation Shared across files Full per-test Best for Seed, tokens, cache Fresh context Cost Amortized once Paid every test
Match the scope to the work: amortize expensive shared setup once per worker, and keep mutable per-test state fully isolated.
import { test as base, expect } from '@playwright/test';

// Test-scoped: a fresh, authenticated context per spec.
export const test = base.extend({
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('/login');
    await page.getByLabel('Username').fill('admin');
    await page.getByRole('button', { name: /sign in/i }).click();
    // Synchronize on a real condition before handing the page over.
    await expect(page.locator('#dashboard')).toBeVisible({ timeout: 10_000 });
    await use(page);          // test runs with the ready page
    await context.close();    // teardown runs even if the test fails
  },
});

Note that page.fill(selector, value) is deprecated in favor of locator.fill(value); the example uses getByLabel() and getByRole(), the preferred locator-based approach. These fixtures pair naturally with the encapsulated classes in Page Object Model Design, where a fixture constructs and injects a page object per test.

The rule of thumb is data mutability. If a fixture hands back something the test will mutate — a page, a context, a row it will update — it must be test-scoped, because sharing mutable state across tests is precisely how order-dependent flakiness enters a suite. If a fixture hands back something read-only that every test merely consumes — a bearer token, a connection pool, a compiled schema — it is a candidate for worker scope. The grey area is anything read-mostly, such as a seeded reference dataset; there, prefer worker scope but give each worker its own partition so a stray write in one test cannot surface in another.

The worker lifetime and fixture ordering

A worker is a long-lived process that runs many spec files back to back, and understanding its lifetime is what makes worker-scoped fixtures safe to use. A worker-scoped fixture is built the first time any test in that worker requests it and is not torn down until the worker has finished its last test. Every test-scoped fixture, by contrast, is built and destroyed inside the boundary of a single test. The two nest cleanly: worker fixtures form an outer envelope, test fixtures live inside it, and teardown unwinds strictly in reverse of construction.

Fixture lifetimes across one worker process A worker fixture is built once and spans three tests, each of which builds and tears down its own test-scoped context, before the worker fixture is torn down. Worker process worker fixture built once per worker test 1 fresh context test 2 fresh context test 3 fresh context worker start time
The worker fixture is torn down only after the last test in the worker finishes, so its cost is paid once no matter how many tests share it.

This nesting is exactly why offloading a slow one-time cost to worker scope pays off: a database seed, a headless auth handshake, or a Docker container start amortizes across every test the worker runs rather than repeating per spec. The trade-offs, the { scope: 'worker' } syntax, and how to keep a shared resource from leaking mutable state between tests are the subject of Worker-Scoped Fixtures for Expensive Setup. When a worker crashes mid-file, Playwright starts a fresh worker and re-runs the remaining tests, which means a worker fixture must also be safe to build more than once across a run — never assume it holds a globally unique lock.

Global setup and teardown

globalSetup and globalTeardown run once per entire test run, outside the worker lifecycle, which makes them the right home for infrastructure work: provisioning a test database, generating a shared auth token, or warming an external cache. Their output — for example a saved storageState — is then consumed by every worker.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  globalSetup: './global-setup.ts',
  globalTeardown: './global-teardown.ts',
  use: { storageState: 'auth/state.json' },   // produced by globalSetup
});

The subtlety is concurrency: global setup must partition data so workers do not collide when they initialize state in parallel. Worker-index-aware data partitioning and shard-safe seeding are the focus of Setting Up Global Fixtures for Parallel Tests. Keep global teardown defensive: it runs after the entire suite, including after a run that failed partway, so it should tolerate resources that were never created rather than assume a clean setup completed. A teardown that throws on a missing artifact turns a single test failure into a red run with a misleading error.

Resource isolation and fixture chaining

Parallel execution introduces concurrency risk, so fixtures must declare their dependencies explicitly. A fixture can depend on another by naming it in its destructured arguments; the runner then builds them in order and tears them down in reverse, guaranteeing that, for example, seed data exists before the page that consumes it loads. Chaining this way isolates network calls from UI interaction and makes multi-step setup deterministic.

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

const test = base.extend({
  apiClient: async ({ baseURL }, use) => {
    const client = { get: (path: string) => fetch(`${baseURL}${path}`) };
    await use(client);
  },
  dashboardPage: async ({ page, apiClient }, use) => {
    // dashboardPage depends on apiClient, so seeding runs first.
    await (await apiClient.get('/api/seed-data')).json();
    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');
    await use(page);
  },
});

The dependency graph is inferred entirely from destructuring, which has one edge worth remembering: a fixture is only built if some test — directly or transitively — asks for it. A fixture nobody names never runs, so an expensive resource guarded behind a rarely-used fixture costs nothing on the runs that skip it. This lazy construction is what lets a single test object carry dozens of fixtures without slowing the common path.

Automatic fixtures and per-test instrumentation

Not every fixture is something a test asks for by name. An automatic fixture declared with { auto: true } runs for every test in scope whether or not the test destructures it, which makes it the right tool for cross-cutting instrumentation: capturing console errors, attaching per-test metadata, or asserting a global invariant after the body finishes. Because the setup code before use() runs first and the teardown after it runs last, an auto fixture brackets the test body on both sides without any per-test boilerplate.

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

export const test = base.extend<{ failOnConsoleError: void }>({
  // auto: true means this runs for every test, no destructuring required.
  failOnConsoleError: [async ({ page }, use) => {
    const errors: string[] = [];
    // Collect any console errors the page logs during the test.
    page.on('console', (msg) => {
      if (msg.type() === 'error') errors.push(msg.text());
    });
    await use();                      // the test body runs here
    // Teardown assertion: fail the test if the page logged errors.
    expect(errors, `console errors: ${errors.join(', ')}`).toHaveLength(0);
  }, { auto: true }],
});

Use auto fixtures sparingly and keep their teardown assertions cheap, because they tax every test in scope. A useful pattern is to pair an auto fixture with an option fixture (covered below) so a specific spec can opt out — for example a test that deliberately triggers a console error and asserts on it. This composes with test.use() overrides rather than fighting them, and it keeps policy enforcement in one place instead of scattered across beforeEach blocks.

Projects, timeouts, and the option hierarchy

The config has more structure than a flat option list, and understanding the hierarchy prevents a class of confusing overrides. Options set in the top-level use apply to every project. Options set inside a project's own use override the global value for that project only. And test.use() inside a spec file overrides both for the tests in that file. The same layering applies to timeouts: the global timeout caps each test, expect.timeout caps each web-first assertion, and actionTimeout caps each individual action. Knowing which knob a given failure needs — a slow whole test versus a single slow assertion — turns "bump the timeout" guesswork into a targeted fix.

Option override precedence A stack of three layers showing that test.use overrides project use, which overrides the global use block, with precedence increasing upward. Global use — all projects Project use — one project test.use — one file precedence wins
The narrowest scope wins: a value set with test.use overrides the project block, which overrides the global use block.
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  timeout: 30_000,                 // whole-test ceiling
  expect: { timeout: 5_000 },      // per-assertion ceiling
  use: { actionTimeout: 10_000 },  // per-action ceiling, inherited by all projects
  projects: [
    {
      name: 'slow-integration',
      timeout: 90_000,             // this project's tests get a longer ceiling
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Projects are also a dependency mechanism, not just an engine list. A setup project that runs first — authenticating once and writing auth.json — can be declared a dependency of every other project so the session exists before any real test runs. That pattern, combined with the engine matrix from Cross-Browser Execution, is how mature suites bootstrap shared state cleanly, and it is the backbone of a dedicated setup project for Authentication & Session State.

Fixture options and overridable defaults

Fixtures are not only for objects you build; they can also expose tunable options that a spec file overrides with test.use(). By declaring an option fixture with a default, you let individual tests opt into a different configuration without touching the global config. A common use is a failOnConsoleError flag or a per-test seed size — the default covers the suite, and the rare test that needs different behavior overrides it locally. This keeps the config lean while still allowing per-test variation, and it composes with the worker-versus-test scoping above, since an option fixture can itself be consumed by a heavier worker-scoped resource fixture.

Option fixtures also make a suite self-documenting. Because the option and its default live next to the fixture that reads it, a reader sees the full set of knobs a test can turn without hunting through the config, and TypeScript flags a typo in test.use({ ... }) at compile time rather than letting it silently do nothing. Reserve plain config use for options that are genuinely global, and reach for an option fixture whenever the value is something a subset of tests will legitimately want to vary.

Failure modes and debugging fixtures

Most fixture bugs fall into a few recognizable shapes. The first is a fixture that hangs because it never calls use(); the runner waits, the test times out, and the error points at the test rather than the fixture. If a spec times out before its body appears to run, suspect a setup phase that awaited something that never resolves. The second is teardown that silently does not run — which never happens if you place cleanup after use(), because Playwright runs that teardown even when the body throws, but which does happen if you wrote cleanup in a finally around the wrong await or forgot to await it at all.

The third and most corrosive is state leakage from a mis-scoped fixture. A worker-scoped fixture that returns mutable state will carry one test's writes into the next test in that worker, producing failures that only appear at certain worker counts or in a certain order. The tell is a test that passes alone and in a single-worker run but fails under --workers=4; the fix is to move the mutation behind a test-scoped fixture or to give each test its own partition of the shared resource. When you cannot tell which fixture built what, open the run in the Playwright Trace Viewer, where fixture setup and teardown appear as named spans in the timeline, and cross-reference with Detecting and Fixing Flaky Playwright Tests when the failure is intermittent.

CI/CD considerations

The config you run locally and the config CI runs should be one file, differentiated only by environment variables. Pin workers and retries for CI through process.env.CI so a runner does not silently change parallelism, and set forbidOnly: true so a stray test.only fails the pipeline instead of quietly skipping the rest of the suite. Retries deserve a deliberate value rather than a reflexive one: one or two retries mask genuine flake long enough to ship while a trace is captured on the retry, but a high retry count hides real regressions. The reasoning behind those numbers lives in Configuring Retries and Timeouts for Stable CI.

The artifact settings established here — trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' — are exactly what the pipeline uploads on a failed sharded run, and they keep CI storage small by capturing forensic data only when something breaks.

import { defineConfig } from '@playwright/test';

const isCI = !!process.env.CI;

export default defineConfig({
  forbidOnly: isCI,                       // a stray test.only fails CI
  retries: isCI ? 2 : 0,                  // retry only in CI, capture trace on retry
  workers: isCI ? 4 : undefined,          // pin CI concurrency; auto locally
  use: {
    trace: 'on-first-retry',              // DOM snapshots + network log on retry
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

Open a captured trace with npx playwright show-trace trace.zip. Sharding this configured suite across runners, and caching the browser binaries so each shard starts fast, are covered in Running Playwright Tests in GitHub Actions with Sharding and Caching Playwright Browsers in CI. The full pipeline wiring lives in CI/CD Integration.

Deep dives beneath this guide

Two focused walkthroughs extend the material here. Worker-Scoped Fixtures for Expensive Setup shows the exact { scope: 'worker' } syntax and how to share a costly resource without leaking mutable state between tests. Setting Up Global Fixtures for Parallel Tests covers shard-safe global setup and worker-index-aware data partitioning so parallel workers never collide.

Frequently Asked Questions

When should a fixture be worker-scoped instead of test-scoped?

Make a fixture worker-scoped when its setup is expensive and safe to share across the files one worker runs — database seeding, token minting, cache warming. Keep it test-scoped when each test needs a fresh, isolated state, which is the default for browser contexts. The deciding question is mutability: if the test will mutate what the fixture returns, keep it test-scoped, because sharing mutable state is how order-dependent flakiness enters a suite.

What is the difference between globalSetup and a worker fixture?

globalSetup runs exactly once for the entire run, before any worker starts, making it right for infrastructure provisioning. A worker fixture runs once per worker process, so with multiple workers it runs multiple times. Use globalSetup for truly run-wide state and worker fixtures for per-worker resources.

How do I make one fixture depend on another?

Name the dependency in the fixture's destructured arguments. The runner resolves dependencies in order, builds them before the dependent fixture, and tears them down in reverse, so you can guarantee that seed data or an API client exists before the page that uses it loads.

What does an automatic fixture do differently?

An automatic fixture declared with { auto: true } runs for every test in its scope even when no test destructures it, which makes it the place for cross-cutting policy like console-error capture or per-test metadata. Its setup brackets the test body before use() and its teardown runs after, so it enforces an invariant without any per-test boilerplate.

Which option wins when the same value is set in three places?

The narrowest scope wins. A value set with test.use() in a spec file overrides the same value in a project's use block, which in turn overrides the top-level use. The same layering governs timeouts, where the whole-test timeout, the per-assertion expect.timeout, and the per-action actionTimeout each cap a different unit of work.

Why does my suite pass locally but fail under more workers?

That pattern almost always points to a worker-scoped fixture returning mutable state, so one test's writes surface in the next test the same worker runs. Move the mutation behind a test-scoped fixture, or give each worker its own partition of the shared resource, and the failure disappears once no two tests share writable state.

Back to overview