Read OSS

测试运行器:从配置到结果

高级

前置知识

  • 第 1-2 篇:了解架构与协议有助于理解 IPC 模式
  • 基本测试框架概念(describe、it、fixtures)
  • 了解 Node.js 子进程与 IPC 通信

测试运行器:从配置到结果

Playwright 的测试运行器(@playwright/test)绝不只是自动化 API 的简单封装——它是一套复杂的多进程调度系统,包含感知依赖关系的 fixture 引擎、分阶段的任务流水线,以及多路复用的报告系统。本文将深入剖析测试文件是如何一步步变成正在运行的测试与结构化报告的。

多进程架构

测试运行器采用协调者-工作进程模型。协调者(主进程)负责配置解析、测试发现、任务调度与报告生成;工作进程(子进程)则在隔离环境中实际执行测试。

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"]

测试运行器的 Dispatcher(注意:这与第 2 篇介绍的协议层 Dispatcher 不同)位于 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>();

Dispatcher 维护着一个工作进程"槽位"池和一个测试组队列,并遵循通过配置中 project.workers 设置的单项目并发上限。packages/playwright/src/runner/dispatcher.ts#L66-L78 中的调度逻辑会在满足并发限制的前提下,始终优先选取队列中第一个可运行的任务:

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;
}

任务流水线

测试执行遵循一套有序的任务流水线,定义于 packages/playwright/src/runner/tasks.tsTestRun 类是贯穿每个任务的共享状态对象:

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();
}

packages/playwright/src/runner/testRunner.ts#L34-L47 中的 TestRunner 通过 createLoadTaskcreateRunTestsTaskscreateGlobalSetupTasks 等工厂函数将这些任务组装在一起。

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"]

每个任务都有 run()teardown() 方法。TaskRunner 按顺序执行这些任务;若某个任务失败,则按相反顺序逐一执行已完成任务的 teardown。这与下文即将介绍的 fixture 生命周期模式如出一辙。

提示: --reporter=list 的输出会展示任务执行的各个阶段。如果测试启动很慢,通常是"Load"或"Global Setup"阶段耗时较长。可以结合 DEBUG=pw:test* 使用 --reporter=list,获取详细的时序信息。

WorkerMain 与测试执行

每个工作进程运行 WorkerMain,定义于 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[]>();

工作进程启动后会依次执行以下步骤:

  1. 通过 IPC 反序列化配置
  2. 为当前项目构建 fixture 池
  3. 等待协调者分配测试组
  4. 加载所需的测试文件
  5. 使用 FixtureRunner 运行每一个测试

协调者与工作进程之间通过 Node.js IPC 传递类型化消息。工作进程发送 testBegintestEndstepBeginstepEndattachmentdone 等事件;协调者的 JobDispatcher(位于 Dispatcher 内部)接收这些事件并转发给各个 reporter。

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 系统深度解析

fixture 系统可以说是 Playwright Test 最具特色的功能。它分布在两个文件中:FixturePool 负责注册与依赖解析,FixtureRunner 负责执行。

FixturePool:注册与 DAG 解析

packages/playwright/src/common/fixtures.ts#L75-L80 中的 FixturePool 管理所有 fixture 的注册信息:

export class FixturePool {
  readonly digest: string;
  private readonly _registrations: Map<string, FixtureRegistration>;

packages/playwright/src/common/fixtures.ts#L30-L56 中的每条 FixtureRegistration 记录了以下信息:

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
};

deps 字段从 fixture 函数的参数名称中提取。当你这样写时:

test.extend({
  myFixture: async ({ page, context }, use) => { ... }
});

系统会解析 ({ page, context }, use),从而得知 myFixture 依赖于 pagecontext。这形成了一个依赖 DAG(有向无环图),由 fixture 池在测试运行时负责解析。

FixtureRunner:带生命周期的执行

packages/playwright/src/worker/fixtureRunner.ts 中的 FixtureRunner 负责按正确顺序执行 fixture 的 setup 与 teardown:

class Fixture {
  runner: FixtureRunner;
  registration: FixtureRegistration;
  value: any;
  _deps = new Set<Fixture>();
  _usages = new Set<Fixture>();

每个 Fixture 实例跟踪自身的依赖(_deps)以及依赖它的对象(_usages)。Setup 阶段从 DAG 的底部向上遍历,teardown 阶段则从顶部向下遍历。use() 回调模式使得清理逻辑得以实现:

// 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
});

Fixture 分为两种作用域:

  • Test 作用域:每个测试都会独立执行 setup 和 teardown
  • 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 与 test API

@playwright/test 中导入的 test 对象,由 packages/playwright/src/common/testType.ts#L32-L73 中的 TestTypeImpl 构建而成:

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));
    // ...
  }

每个方法都被 wrapFunctionWithLocation 包装,以捕获调用处的源文件位置信息。Playwright 正是通过这种方式得知 test('my test', ...) 声明于哪一行——这些信息会出现在错误信息和各类 reporter 的输出中。

_extend() 方法会携带额外的 fixture 创建一个新的 TestTypeImpl,并返回一个全新的 test 对象。这是实现可组合性的核心机制:每次调用 test.extend() 都会创建一个新的 fixture 层,可以覆盖或扩展父层的 fixture。

根 test 类型定义于 packages/playwright/src/index.ts#L40

export const _baseTest: TestType<{}, {}> = rootTestType.test;

同一文件 packages/playwright/src/index.ts#L71-L86 中定义的内置 fixture,包括 worker 作用域的 playwrightbrowserNameheadless,以及 test 作用域的 pagecontextrequest

提示: test.extend() 的调用是累加且可组合的。你可以构建多个 fixture 层:一层用于身份认证,另一层用于特定页面的初始化,然后将它们组合使用。每一层的 fixture 都可以依赖父层中已定义的 fixture。

Reporter 多路复用器

Playwright 支持同时运行多个 reporter——你可能希望终端显示 list 输出,同时生成可浏览的 html 报告,以及供 CI 系统消费的 json 文件。reporter 系统采用多路复用模式:单个 InternalReporter 接收所有事件,再将其分发给每个已注册的 reporter。

内置 reporter 一览:

Reporter 格式 适用场景
list 终端文本 本地开发
dot 每个测试一个点 CI 精简输出
line 单行滚动更新 支持 ANSI 的 CI 环境
html 交互式 Web 应用 运行后分析
json JSON 文件 CI 集成
junit JUnit XML Jenkins 等 CI 系统
blob 二进制文件 分片合并
github GitHub 注解 GitHub Actions
markdown Markdown 摘要 PR 评论

每个 reporter 实现 ReporterV2 接口,接收 onBeginonTestBeginonTestEndonStepBeginonStepEndonEnd 等事件。

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"]

下一篇

在本系列的最后一篇文章中,我们将探索 Playwright 的开发者工具生态:包括支持多语言输出的代码生成系统、recorder 架构、用于事后调试的 trace 系统,以及让 AI agent 能够使用 Playwright 的 MCP server。