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, global setup, and parallel-safe isolation. It builds on Playwright Setup & Core Architecture.
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.
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.
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.
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.
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.
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);
},
});
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.
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.
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.
Trace integration and debugging
Reliable suites pair good assertions with actionable diagnostics. Configure trace: 'on-first-retry', screenshot: 'only-on-failure', and video: 'retain-on-failure' to capture forensic data only when a test fails, keeping CI artifacts small while preserving everything you need to reproduce a failure. Extend expect with domain-specific matchers for complex UI or API states.
import { defineConfig } from '@playwright/test';
export default defineConfig({
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. The full failure-analysis workflow — reading the timeline, network tab, and snapshots — lives in Debugging & Test Observability. When you wire this config into a pipeline, the artifact settings above are exactly what CI/CD Integration uploads on a failed sharded run.
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. Choosing per-test scope for expensive shared work is a common performance mistake.
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.