Read OSS

テストランナー:設定から結果まで

上級

前提知識

  • 記事1〜2:アーキテクチャとプロトコルの理解がIPCパターンの把握に役立ちます
  • テストフレームワークの基本概念(describe、it、フィクスチャ)
  • Node.jsの子プロセスとIPCの理解

テストランナー:設定から結果まで

Playwrightのテストランナー(@playwright/test)は、自動化APIの薄いラッパーではありません。依存関係を考慮したフィクスチャエンジン、フェーズ駆動のタスクパイプライン、そして多重化されたレポーターシステムを備えた、高度なマルチプロセスオーケストレーションシステムです。この記事では、テストファイルが実際のテスト実行と構造化されたレポートへと変わるまでの流れを詳しく見ていきます。

マルチプロセスアーキテクチャ

テストランナーは、コーディネーター・ワーカー型のプロセスモデルを採用しています。コーディネーター(メインプロセス)が設定、テストの検出、スケジューリング、レポートを担当し、ワーカー(子プロセス)が実際のテストを隔離された環境で実行します。

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.ts で定義された順序付きのタスクパイプラインに従います。TestRun クラスは、各タスクを通じて共有される状態を保持します。

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-L47TestRunner がこれらのタスクを組み立てます。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 はそれらを順番に実行し、いずれかのタスクが失敗した場合は逆順でティアダウンを実行します。これは次に説明するフィクスチャのライフサイクルパターンと同じ考え方です。

ヒント: --reporter=list の出力にはタスクの実行フェーズが表示されます。テスト開始が遅い場合は、たいてい「Load」か「Global Setup」フェーズが原因です。詳細なタイミングを確認するには、--reporter=listDEBUG=pw:test* を組み合わせてみましょう。

WorkerMain とテスト実行

各ワーカープロセスは、packages/playwright/src/worker/workerMain.ts#L40-L80 で定義された WorkerMain を実行します。

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. プロジェクト用のフィクスチャプールを構築する
  3. コーディネーターからのテストグループ割り当てを待機する
  4. 必要なテストファイルを読み込む
  5. FixtureRunner を使って各テストを実行する

コーディネーターとワーカーの通信には、型付きメッセージを使ったNode.js IPCを採用しています。ワーカーは testBegintestEndstepBeginstepEndattachmentdone といったイベントを送信します。Dispatcher 内の JobDispatcher がこれらのイベントを受け取り、レポーターへ転送します。

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

フィクスチャシステムの詳細

フィクスチャシステムは、Playwright Test の最も際立った特徴と言えるでしょう。登録と依存関係の解決を担う FixturePool と、実行を担う FixtureRunner の2つのファイルに実装されています。

FixturePool:登録とDAG解決

packages/playwright/src/common/fixtures.ts#L75-L80FixturePool はフィクスチャの登録を管理します。

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 フィールドはフィクスチャ関数のパラメーター名から抽出されます。たとえば次のように書くと:

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

システムは ({ page, context }, use) を解析し、myFixturepagecontext に依存していることを特定します。これによって依存関係のDAG(有向非巡回グラフ)が構築され、テスト実行時にプールが解決します。

FixtureRunner:ライフサイクルを伴う実行

packages/playwright/src/worker/fixtureRunner.tsFixtureRunner は、セットアップとティアダウンを適切に行いながらフィクスチャを実行します。

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

Fixture インスタンスは、自身が依存するフィクスチャ(_deps)と自身に依存するフィクスチャ(_usages)を追跡します。セットアップは依存関係のDAGをボトムアップに辿り、ティアダウンはトップダウンに辿ります。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
});

フィクスチャには2つのスコープがあります。

  • テストスコープ:各テストの前後でセットアップとティアダウンが行われます
  • ワーカースコープ:ワーカーごとに一度だけセットアップされ、そのワーカー内のすべてのテストで共有されます
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-L73TestTypeImpl によって構築されます。

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 でラップされており、呼び出し元のソースファイルの位置情報をキャプチャします。エラーメッセージやレポーターに test('my test', ...) が何行目で宣言されたかが表示されるのは、この仕組みのおかげです。

_extend() メソッドは追加のフィクスチャを持つ新しい TestTypeImpl を生成し、新しい test オブジェクトを返します。これが composability(構成可能性)の仕組みです。test.extend() を呼び出すたびに新しいフィクスチャレイヤーが生成され、親のフィクスチャをオーバーライドしたり、新しいフィクスチャを追加したりできます。

ルートのテスト型は packages/playwright/src/index.ts#L40 で定義されています。

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

同じファイルの packages/playwright/src/index.ts#L71-L86 にフィクスチャが組み込まれています。playwrightbrowserNameheadless などのワーカースコープと、pagecontextrequest などのテストスコープがあります。

ヒント: test.extend() の呼び出しは加算的で、自由に組み合わせることができます。認証用のベースレイヤー、特定のページ設定用のレイヤーといった具合に複数のフィクスチャレイヤーを作成し、それらを組み合わせて使うことが可能です。各レイヤーのフィクスチャは親レイヤーのフィクスチャに依存させることもできます。

レポーターマルチプレクサー

Playwrightは複数のレポーターを同時に使用できます。ターミナルには list 形式で表示しながら、ブラウザで閲覧できる html レポートを生成し、さらにCI連携用の json ファイルも出力する、といった使い方が可能です。レポーターシステムはマルチプレクサーパターンを採用しており、単一の InternalReporter がすべてのイベントを受け取り、登録されている各レポーターへファンアウトします。

組み込みレポーターには以下のものがあります。

レポーター フォーマット 用途
list ターミナルテキスト ローカル開発
dot テストごとにドット表示 CIの最小出力
line 1行更新 ANSIサポートのあるCI
html インタラクティブなWebアプリ 実行後の分析
json JSONファイル CI連携
junit JUnit XML Jenkins/CIシステム
blob バイナリblobファイル シャーディングのマージ
github GitHubアノテーション GitHub Actions
markdown Markdownサマリー PRコメント

各レポーターは 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の開発者ツールエコシステムを掘り下げます。言語ごとのエミッターを持つコード生成システム、レコーダーアーキテクチャ、事後デバッグのためのトレースシステム、そしてPlaywrightをAIエージェントから利用可能にするMCPサーバーについて解説します。