The Test Runner: From Config to Results
Prerequisites
- ›Articles 1-2: Architecture and Protocol understanding helps with IPC patterns
- ›Basic test framework concepts (describe, it, fixtures)
- ›Understanding of Node.js child processes and IPC
The Test Runner: From Config to Results
Playwright's test runner (@playwright/test) isn't just a thin wrapper around the automation API — it's a sophisticated multi-process orchestration system with a dependency-aware fixture engine, a phased task pipeline, and a multiplexed reporter system. This article dissects how your test files become running tests and structured reports.
Multi-Process Architecture
The test runner uses a coordinator-worker process model. The coordinator (main process) handles configuration, test discovery, scheduling, and reporting. Workers (child processes) execute the actual tests in isolation.
flowchart TD
subgraph "Coordinator Process"
TR["TestRunner"]
D["Dispatcher"]
R["Reporter Multiplexer"]
end
subgraph "Worker Pool"
W1["Worker 1<br/>WorkerMain"]
W2["Worker 2<br/>WorkerMain"]
W3["Worker 3<br/>WorkerMain"]
end
TR --> D
D -->|"IPC: run test group"| W1
D -->|"IPC: run test group"| W2
D -->|"IPC: run test group"| W3
W1 -->|"IPC: test results"| D
W2 -->|"IPC: test results"| D
W3 -->|"IPC: test results"| D
D --> R
R --> L["List Reporter"]
R --> H["HTML Reporter"]
R --> J["JSON Reporter"]
The test runner Dispatcher (not to be confused with the protocol Dispatcher from Article 2) lives at packages/playwright/src/runner/dispatcher.ts#L40-L64:
export class Dispatcher {
private _workerSlots: { worker?: WorkerHost, jobDispatcher?: JobDispatcher }[] = [];
private _queue: TestGroup[] = [];
private _workerLimitPerProjectId = new Map<string, number>();
The Dispatcher manages a pool of worker "slots" and a queue of test groups. It respects per-project worker limits — configured via project.workers in your config. The scheduling logic at packages/playwright/src/runner/dispatcher.ts#L66-L78 always picks the first job that can run while respecting these limits:
private _findFirstJobToRun() {
for (let index = 0; index < this._queue.length; index++) {
const job = this._queue[index];
const projectIdWorkerLimit = this._workerLimitPerProjectId.get(job.projectId);
if (!projectIdWorkerLimit)
return index;
const runningWorkersWithSameProjectId = this._workerSlots
.filter(w => w.jobDispatcher?.job.projectId === job.projectId).length;
if (runningWorkersWithSameProjectId < projectIdWorkerLimit)
return index;
}
return -1;
}
Task Pipeline
The test execution follows an ordered pipeline of tasks defined in packages/playwright/src/runner/tasks.ts. The TestRun class is the shared state that flows through each task:
export class TestRun {
readonly config: FullConfigInternal;
readonly reporter: InternalReporter;
readonly failureTracker: FailureTracker;
rootSuite: Suite | undefined = undefined;
readonly phases: Phase[] = [];
projectFiles: Map<FullProjectInternal, string[]> = new Map();
projectSuites: Map<FullProjectInternal, Suite[]> = new Map();
}
The TestRunner at packages/playwright/src/runner/testRunner.ts#L34-L47 assembles these tasks from factory functions like createLoadTask, createRunTestsTasks, createGlobalSetupTasks, etc.
flowchart LR
A["Load Config"] --> B["Collect Files"]
B --> C["Load Test Files"]
C --> D["Build Suites"]
D --> E["Global Setup"]
E --> F["Plugin Setup"]
F --> G["Run Tests<br/>(per phase)"]
G --> H["Report Results"]
H --> I["Global Teardown"]
I --> J["Apply Rebaselines"]
Each task has a run() method and a teardown() method. The TaskRunner executes them in order, and if any task fails, it runs teardowns in reverse order. This is similar to the fixture lifecycle pattern we'll see next.
Tip: The
--reporter=listoutput shows task execution phases. If your tests are slow to start, it's usually the "Load" or "Global Setup" phases. Use--reporter=listwithDEBUG=pw:test*for detailed timing.
WorkerMain and Test Execution
Each worker process runs WorkerMain, defined at packages/playwright/src/worker/workerMain.ts#L40-L80:
export class WorkerMain extends ProcessRunner {
private _params: ipc.WorkerInitParams;
private _config!: FullConfigInternal;
private _project!: FullProjectInternal;
private _fixtureRunner: FixtureRunner;
private _skipRemainingTestsInSuite: Suite | undefined;
private _activeSuites = new Map<Suite, TestAnnotation[]>();
When a worker starts, it:
- Deserializes the config from IPC
- Builds the fixture pool for its project
- Waits for test group assignments from the coordinator
- Loads the required test files
- Runs each test using the
FixtureRunner
Communication between coordinator and worker uses Node.js IPC with typed messages. The worker sends events like testBegin, testEnd, stepBegin, stepEnd, attachment, and done. The coordinator's JobDispatcher (inside the Dispatcher) processes these events and forwards them to reporters.
sequenceDiagram
participant C as Coordinator
participant W as WorkerMain
C->>W: WorkerInitParams (config)
W->>W: Deserialize config
W->>W: Build fixture pool
C->>W: RunPayload (test group)
W->>W: Load test files
loop For each test
W->>C: testBegin
W->>W: Setup fixtures
W->>W: Run test function
W->>W: Teardown fixtures
W->>C: testEnd (result)
end
W->>C: done
Fixture System Deep Dive
The fixture system is arguably Playwright Test's most distinctive feature. It lives in two files: FixturePool for registration and dependency resolution, and FixtureRunner for execution.
FixturePool: Registration and DAG Resolution
FixturePool at packages/playwright/src/common/fixtures.ts#L75-L80 manages fixture registrations:
export class FixturePool {
readonly digest: string;
private readonly _registrations: Map<string, FixtureRegistration>;
Each FixtureRegistration at packages/playwright/src/common/fixtures.ts#L30-L56 captures:
export type FixtureRegistration = {
name: string;
scope: FixtureScope; // 'test' or 'worker'
fn: Function | any; // The fixture function or value
auto: FixtureAuto; // Auto-fixtures always run
option: boolean; // Configurable from playwright.config
deps: string[]; // Dependencies from parameter names
id: string; // Unique identifier
super?: FixtureRegistration; // Override chain
timeout?: number; // Per-fixture timeout
box?: boolean | 'self'; // Hide from step output
};
The deps field is extracted from the fixture function's parameter names. When you write:
test.extend({
myFixture: async ({ page, context }, use) => { ... }
});
The system parses ({ page, context }, use) to determine that myFixture depends on page and context. This creates a dependency DAG (directed acyclic graph) that the pool resolves at test time.
FixtureRunner: Execution with Lifecycle
FixtureRunner at packages/playwright/src/worker/fixtureRunner.ts executes fixtures with proper setup/teardown:
class Fixture {
runner: FixtureRunner;
registration: FixtureRegistration;
value: any;
_deps = new Set<Fixture>();
_usages = new Set<Fixture>();
Each Fixture instance tracks its dependencies (_deps) and what depends on it (_usages). Setup walks the dependency DAG bottom-up, and teardown walks it top-down. The use() callback pattern enables cleanup:
// The fixture's fn gets called like:
await fn(dependencies, async (value) => {
fixture.value = value;
// Test runs here (inside the use callback)
// After test completes, we return and the fixture cleans up
});
Fixtures come in two scopes:
- Test-scoped: Set up and torn down for each test
- Worker-scoped: Set up once per worker, shared across all tests in that worker
flowchart TD
subgraph "Worker Scope (once per worker)"
PW["playwright fixture"]
BR["browser fixture"]
PW --> BR
end
subgraph "Test Scope (per test)"
CTX["context fixture"]
PG["page fixture"]
BR --> CTX
CTX --> PG
end
PG --> T["Test Function"]
T --> TD["Teardown page"]
TD --> TDC["Teardown context"]
TestTypeImpl and the test API
The test object you import from @playwright/test is built by TestTypeImpl at packages/playwright/src/common/testType.ts#L32-L73:
export class TestTypeImpl {
readonly fixtures: FixturesWithLocation[];
readonly test: TestType<any, any>;
constructor(fixtures: FixturesWithLocation[]) {
this.fixtures = fixtures;
const test: any = wrapFunctionWithLocation(this._createTest.bind(this, 'default'));
test.expect = expect;
test.only = wrapFunctionWithLocation(this._createTest.bind(this, 'only'));
test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default'));
test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only'));
test.describe.parallel = wrapFunctionWithLocation(this._describe.bind(this, 'parallel'));
test.describe.serial = wrapFunctionWithLocation(this._describe.bind(this, 'serial'));
test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach'));
test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach'));
test.extend = wrapFunctionWithLocation(this._extend.bind(this));
test.use = wrapFunctionWithLocation(this._use.bind(this));
// ...
}
Every method is wrapped with wrapFunctionWithLocation, which captures the source file location at the call site. This is how Playwright knows which line test('my test', ...) was declared on — information that appears in error messages and reporters.
The _extend() method creates a new TestTypeImpl with additional fixtures, returning a fresh test object. This is the composability mechanism: each test.extend() call creates a new fixture layer that can override or add fixtures from the parent.
The root test type is defined in packages/playwright/src/index.ts#L40:
export const _baseTest: TestType<{}, {}> = rootTestType.test;
The built-in fixtures defined in that same file at packages/playwright/src/index.ts#L71-L86 include worker-scoped fixtures like playwright, browserName, headless, and test-scoped fixtures like page, context, and request.
Tip:
test.extend()calls are additive and composable. You can create multiple fixture layers: a base layer for authentication, another for specific page setups, and compose them. Each layer's fixtures can depend on fixtures from parent layers.
Reporter Multiplexer
Playwright supports multiple simultaneous reporters — you might want list output on the terminal, html for a browseable report, and json for CI integration. The reporter system uses a multiplexer pattern: a single InternalReporter receives all events and fans them out to each registered reporter.
Built-in reporters include:
| Reporter | Format | Use Case |
|---|---|---|
list |
Terminal text | Local development |
dot |
Dots per test | CI minimal output |
line |
Single-line updates | CI with ANSI support |
html |
Interactive web app | Post-run analysis |
json |
JSON file | CI integration |
junit |
JUnit XML | Jenkins/CI systems |
blob |
Binary blob | Sharding merge |
github |
GitHub annotations | GitHub Actions |
markdown |
Markdown summary | PR comments |
Each reporter implements the ReporterV2 interface, receiving events like onBegin, onTestBegin, onTestEnd, onStepBegin, onStepEnd, and onEnd.
flowchart LR
D["Dispatcher"] --> M["InternalReporter<br/>(multiplexer)"]
M --> R1["List Reporter<br/>(terminal)"]
M --> R2["HTML Reporter<br/>(web app)"]
M --> R3["JSON Reporter<br/>(file)"]
M --> R4["Custom Reporter"]
What's Next
In the final article, we'll explore Playwright's developer tooling ecosystem: the code generation system with language-specific emitters, the recorder architecture, the trace system for post-mortem debugging, and the MCP server that makes Playwright available to AI agents.