Setting Up Global Fixtures for Parallel Tests
Parallel execution accelerates CI pipelines, but shared state introduces non-deterministic failures. Setting up global fixtures for parallel tests requires strict process boundaries and deterministic resource allocation. This guide eliminates race conditions by enforcing worker-scoped memoization, explicit async initialization, and guaranteed teardown sequences.
Worker Isolation and Global State Boundaries
Playwright spawns independent Node.js processes for each parallel worker. This architecture prevents memory leaks and ensures complete test isolation. Understanding the execution boundaries between globalSetup, worker-scoped fixtures, and test-scoped fixtures is critical for stable automation.
Global setup runs once before any worker starts. It handles one-time infrastructure provisioning. Worker-scoped fixtures execute once per process. They manage expensive resources like database connection pools or compiled browser contexts. Test-scoped fixtures run per test() block, managing ephemeral UI interactions.
Aligning resource allocation with these boundaries prevents cross-process interference. For foundational lifecycle management patterns, review Playwright Setup & Core Architecture before scaling worker counts.
Avoiding Cross-Worker Contamination
Synchronous global variables and unscoped asynchronous calls corrupt state when --workers exceeds one. Shared in-memory caches or singleton HTTP clients trigger flaky assertions across concurrent workers.
The solution is test.extend() with scope: 'worker'. This configuration enforces per-worker memoization. Each process receives an isolated instance of the resource. The fixture lifecycle hooks guarantee deterministic initialization order.
Mapping these hooks correctly prevents premature teardown. Consult Playwright Config & Fixtures to align fixture scopes with your execution strategy. Proper scoping ensures resources are allocated exactly once per worker and released only when that process terminates.
Step-by-Step Async Initialization Workflow
Bootstrapping external dependencies requires a strict sequential checklist. First, allocate the resource asynchronously. Second, validate connectivity before handing control to the test runner. Third, inject the resource into the fixture context.
Enforce strict async/await for every allocation and teardown step. Never rely on implicit promise resolution or synchronous file reads for runtime configuration. Explicit waits guarantee the DOM or backend is ready before assertions execute.
Replace deprecated polling methods with deterministic state checks. Use await page.waitForSelector() for element readiness. Rely on await expect(locator).toBeVisible() for assertion stability. Navigate to target routes and call await page.waitForLoadState('networkidle') to ensure all background requests resolve before interacting with the UI.
Minimal Reproducible Configuration
The following implementation demonstrates a production-ready worker-scoped fixture. It initializes a database connection pool, handles graceful failures, and guarantees cleanup execution.
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
type DbFixture = {
dbPool: Pool;
query: (text: string, params?: any[]) => Promise<any>;
};
export const test = base.extend<DbFixture>({
dbPool: [async ({}, use) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
});
try {
await pool.query('SELECT NOW()');
} catch (error) {
await pool.end();
throw new Error(`Database initialization failed: ${error.message}`);
}
await use(pool);
await pool.end();
}, { scope: 'worker' }],
query: async ({ dbPool }, use) => {
await use(async (text: string, params?: any[]) => {
return dbPool.query(text, params);
});
}
});
Authentication state requires similar isolation. Programmatic login avoids UI overhead and scales efficiently across parallel contexts. Each worker receives unique credentials to prevent session collisions.
import { test as base } from '@playwright/test';
import path from 'path';
type AuthFixture = {
storageStatePath: string;
};
export const test = base.extend<AuthFixture>({
storageStatePath: async ({ browser, request }, use, testInfo) => {
const workerIndex = testInfo.workerIndex;
const username = `test_user_${workerIndex}`;
const password = `secure_pass_${workerIndex}`;
const response = await request.post('/api/auth/login', {
data: { username, password },
});
if (!response.ok()) {
throw new Error(`Auth failed for worker ${workerIndex}: ${response.statusText()}`);
}
const context = await browser.newContext();
await context.addCookies(await response.json().cookies);
const statePath = path.join(testInfo.outputDir, `auth-state-${workerIndex}.json`);
await context.storageState({ path: statePath });
await context.close();
await use(statePath);
}, { scope: 'worker' }
});
Inject the serialized state directly into your playwright.config.ts via use: { storageState: '...' } or dynamically within a test file using test.use({ storageState: authFixture.storageStatePath }).
Troubleshooting Race Conditions & Flaky Teardown
Missing await statements in fixture teardown are the primary cause of resource leaks and port conflicts. When the use() callback resolves, Playwright immediately proceeds to the next step. If cleanup runs asynchronously without blocking, subsequent workers may attempt to bind to occupied sockets or access closed database connections.
Always wrap resource management in the explicit use pattern. The sequence await setup(); await use(resource); await cleanup(); guarantees strict execution order. The test runner will not terminate the worker until the final await cleanup() resolves.
Validate worker isolation by logging process.env.TEST_WORKER_INDEX during initialization. Generate deterministic seeds based on this index to prevent overlapping test data. Monitor CI logs for out-of-order teardown messages or unhandled promise rejections. These indicators almost always trace back to unscoped async operations or missing explicit waits.