T-CREATOR

Jest の並列実行はなぜ速い?実行キューとワーカーの舞台裏を読み解く

Jest の並列実行はなぜ速い?実行キューとワーカーの舞台裏を読み解く

Jest でテストを実行すると、あっという間に数百のテストケースが完了しますよね。この驚異的な速度の秘密は「並列実行」にあります。

本記事では、Jest がどのように並列実行を実現しているのか、実行キューとワーカーの仕組みを深掘りしていきましょう。Jest の内部アーキテクチャを理解することで、テストパフォーマンスの最適化やカスタマイズがより効果的に行えるようになります。

背景

Jest の並列実行とは

Jest は、デフォルトで複数のテストファイルを並列に実行する機能を持っています。これにより、テストスイート全体の実行時間を大幅に短縮できるのです。

従来のテストランナーでは、テストファイルを順番に 1 つずつ実行していました。100 個のテストファイルがあり、それぞれが 1 秒かかる場合、全体で 100 秒必要になります。

一方、Jest は複数のワーカープロセスを起動し、テストファイルを分散して実行するため、マルチコアの CPU を効率的に活用できます。4 コアの CPU であれば、理論上は実行時間を約 4 分の 1 に短縮できるでしょう。

Jest のプロセス構造

Jest は以下の 2 種類のプロセスで構成されます。

  1. 親プロセス(メインプロセス)

    • テストファイルの収集と分析
    • ワーカープロセスの管理
    • 実行キューの制御
    • テスト結果の集約とレポート出力
  2. ワーカープロセス(子プロセス)

    • 実際のテストファイルの実行
    • テスト環境のセットアップ
    • テスト結果の親プロセスへの送信

次の図は、Jest の基本的なプロセス構造を示しています。

mermaidflowchart TB
    main["親プロセス<br/>(jest-cli)"]
    queue["実行キュー<br/>(TestQueue)"]
    scheduler["スケジューラ<br/>(TestScheduler)"]

    worker1["ワーカー1<br/>(jest-worker)"]
    worker2["ワーカー2<br/>(jest-worker)"]
    worker3["ワーカー3<br/>(jest-worker)"]
    worker4["ワーカー4<br/>(jest-worker)"]

    main -->|"テストファイル<br/>リスト作成"| queue
    queue -->|"タスク割り当て"| scheduler
    scheduler -->|"テスト実行<br/>指示"| worker1
    scheduler -->|"テスト実行<br/>指示"| worker2
    scheduler -->|"テスト実行<br/>指示"| worker3
    scheduler -->|"テスト実行<br/>指示"| worker4

    worker1 -->|"結果送信"| main
    worker2 -->|"結果送信"| main
    worker3 -->|"結果送信"| main
    worker4 -->|"結果送信"| main

この図から、親プロセスが複数のワーカーを統括し、効率的にタスクを分散していることが分かります。親プロセスはテストファイルをキューに積み、各ワーカーが空き次第、次のテストファイルを割り当てていくのです。

なぜ並列実行が必要なのか

現代の Web アプリケーション開発では、テストケースの数が膨大になりがちです。React コンポーネント、API エンドポイント、ビジネスロジック、ユーティリティ関数など、様々なレイヤーでテストを書くことが推奨されていますね。

テストケースが増えれば増えるほど、実行時間も比例して増加します。CI/CD パイプラインでテストに 10 分も 20 分もかかってしまうと、開発フィードバックのサイクルが遅くなり、生産性が低下してしまうでしょう。

並列実行は、この課題に対する最も効果的な解決策の 1 つです。ハードウェアのリソースを最大限活用することで、開発者の待ち時間を最小化できます。

課題

テスト実行の主な課題

並列実行を実現するには、いくつかの技術的な課題をクリアする必要があります。

課題 1:プロセス間通信

親プロセスとワーカープロセスは別々のプロセスとして動作するため、通信方法が必要です。どのテストファイルを実行すべきか、実行結果はどうだったか、といった情報を効率的にやり取りする仕組みが求められます。

課題 2:タスクの公平な分散

テストファイルごとに実行時間が異なります。1 秒で終わるものもあれば、10 秒かかるものもあるでしょう。単純に均等に分割すると、あるワーカーは早々に終わってしまい、別のワーカーはまだ重いテストを実行中、という状況が発生します。

課題 3:リソース管理

ワーカープロセスを無制限に起動すると、メモリ不足やコンテキストスイッチのオーバーヘッドで逆に遅くなってしまいます。適切なワーカー数を決定し、リソースを効率的に管理する必要があります。

課題 4:テスト間の独立性

並列実行では、複数のテストが同時に実行されます。あるテストが他のテストに影響を与えてしまうと(例:共有されたグローバル変数の変更、データベースの状態変更など)、テスト結果が不安定になってしまうでしょう。

次の図は、並列実行における主な課題を示しています。

mermaidflowchart TD
    start["並列実行の課題"]

    challenge1["プロセス間<br/>通信"]
    challenge2["タスクの<br/>公平な分散"]
    challenge3["リソース<br/>管理"]
    challenge4["テスト間の<br/>独立性"]

    detail1["IPC の実装<br/>メッセージの<br/>シリアライズ"]
    detail2["実行時間の<br/>予測不可能性<br/>負荷の偏り"]
    detail3["メモリ消費<br/>CPU 使用率<br/>最適なワーカー数"]
    detail4["グローバル状態<br/>ファイルシステム<br/>データベース"]

    start --> challenge1
    start --> challenge2
    start --> challenge3
    start --> challenge4

    challenge1 --> detail1
    challenge2 --> detail2
    challenge3 --> detail3
    challenge4 --> detail4

これらの課題に対して、Jest は独自のアーキテクチャで解決策を提供しています。特に実行キューとワーカーの設計が、並列実行の効率性と安定性を支えているのです。

解決策

Jest の並列実行アーキテクチャ

Jest は、jest-runnerjest-worker という 2 つの主要パッケージを使って並列実行を実現しています。これらがどのように連携して、先ほどの課題を解決しているのか見ていきましょう。

1. jest-worker による Worker Pool パターン

Jest は jest-worker パッケージで Worker Pool パターンを実装しています。これは、あらかじめ一定数のワーカープロセスを起動しておき、タスクが来たら空いているワーカーに割り当てるという仕組みです。

ワーカー数の決定

Jest は、デフォルトで以下のロジックでワーカー数を決定します。

typescript// Jest のワーカー数決定ロジック(簡略版)
import * as os from 'os';

function getMaxWorkers(userConfig?: number): number {
  // ユーザーが明示的に指定した場合はそれを使用
  if (userConfig !== undefined) {
    return userConfig;
  }

  // CPU コア数を取得
  const cpuCount = os.cpus().length;
typescript  // CI 環境の検出
  const isCI = process.env.CI === 'true';

  if (isCI) {
    // CI 環境では CPU コア数 - 1(最低 2)
    return Math.max(cpuCount - 1, 2);
  } else {
    // ローカル環境では CPU コア数の 50%(最低 1)
    return Math.max(Math.floor(cpuCount / 2), 1);
  }
}

このコードでは、まずユーザー設定を確認し、指定がなければ環境に応じて自動計算しています。CI 環境ではより多くのワーカーを使い、ローカル開発環境では控えめにすることで、他のアプリケーションへの影響を最小限に抑えているのです。

Worker Pool の初期化

jest-worker は以下のように Worker Pool を初期化します。

typescript// jest-worker の基本的な使い方
import { Worker } from 'jest-worker';

// ワーカープールの作成
const worker = new Worker(
  require.resolve('./worker.js'), // ワーカーが実行するスクリプト
  {
    numWorkers: 4, // ワーカー数
    enableWorkerThreads: false, // プロセスモードを使用
    forkOptions: {
      stdio: 'pipe', // 標準入出力の設定
      serialization: 'json', // メッセージのシリアライズ形式
    },
  }
);

このコードで、4 つのワーカープロセスが起動されます。enableWorkerThreadsfalse にすることで、Node.js の child_process.fork() を使ったプロセスベースの並列実行が行われるのです。

2. 実行キューによるタスク管理

Jest は、テストファイルを実行キューで管理します。この仕組みにより、タスクの公平な分散を実現しているのです。

TestScheduler の役割

jest-runnerTestScheduler クラスが、実行キューの中核を担っています。

typescript// TestScheduler の基本構造(簡略版)
class TestScheduler {
  private _testQueue: Array<TestEntry>;
  private _worker: Worker;

  constructor(
    globalConfig: GlobalConfig,
    context: TestContext
  ) {
    this._testQueue = [];
    this._worker = new Worker(
      require.resolve('./testWorker'),
      {
        numWorkers: globalConfig.maxWorkers,
      }
    );
  }
typescript  // テストファイルをキューに追加
  addToQueue(test: TestEntry): void {
    this._testQueue.push(test);
  }

  // キューからテストを取り出してワーカーに割り当て
  async scheduleTests(): Promise<AggregatedResult> {
    const results: Array<TestResult> = [];
typescript    // 各テストファイルを順次実行
    for (const test of this._testQueue) {
      try {
        // ワーカーにテスト実行を依頼
        const result = await this._worker.runTest(test);
        results.push(result);
      } catch (error) {
        // エラーハンドリング
        results.push(createFailedTestResult(test, error));
      }
    }

    return aggregateResults(results);
  }
}

このコードでは、テストファイルをキューに積み、ワーカーに順次割り当てています。await this._worker.runTest(test) の部分で、空いているワーカーが自動的に選ばれ、実行されるのです。

動的タスク割り当て

Jest の実行キューは「動的タスク割り当て」を採用しています。これは、事前にどのワーカーがどのテストを実行するか決めるのではなく、ワーカーが空き次第、次のタスクを割り当てる方式です。

次の図は、動的タスク割り当ての流れを示しています。

mermaidsequenceDiagram
    participant Queue as 実行キュー
    participant Scheduler as スケジューラ
    participant W1 as ワーカー1
    participant W2 as ワーカー2

    Scheduler->>Queue: テストファイル登録<br/>(test1.js, test2.js, test3.js)

    Scheduler->>W1: test1.js を実行
    Scheduler->>W2: test2.js を実行

    Note over W1,W2: 並列実行中

    W1->>Scheduler: test1.js 完了<br/>(実行時間: 2秒)
    Scheduler->>W1: test3.js を実行

    W2->>Scheduler: test2.js 完了<br/>(実行時間: 5秒)
    Note over W2: アイドル状態

    W1->>Scheduler: test3.js 完了<br/>(実行時間: 1秒)

    Scheduler->>Scheduler: 全テスト完了<br/>結果を集約

この図から、実行時間が異なるテストでも、空いたワーカーに次のタスクを即座に割り当てることで、効率的に実行できることが分かります。test1.js が早く終わったワーカー 1 は、すぐに test3.js の実行に移れるのです。

3. プロセス間通信の実装

Jest は Node.js の child_process モジュールを使って、親プロセスとワーカープロセス間の通信を行っています。

メッセージの送受信

親プロセスからワーカーへのメッセージ送信は以下のように実装されます。

typescript// 親プロセス側:ワーカーにテスト実行を依頼
import { fork, ChildProcess } from 'child_process';

class WorkerProcess {
  private _child: ChildProcess;
  private _callbacks: Map<number, Function>;
  private _requestId: number;

  constructor(workerPath: string) {
    this._child = fork(workerPath, [], {
      stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
    });
    this._callbacks = new Map();
    this._requestId = 0;

    // ワーカーからのメッセージを受信
    this._child.on('message', this._onMessage.bind(this));
  }
typescript  // テスト実行リクエストの送信
  async runTest(testPath: string): Promise<TestResult> {
    return new Promise((resolve, reject) => {
      const requestId = this._requestId++;

      // コールバックを登録
      this._callbacks.set(requestId, { resolve, reject });

      // ワーカーにメッセージを送信
      this._child.send({
        type: 'run-test',
        requestId,
        testPath,
      });
    });
  }
typescript  // ワーカーからのメッセージを処理
  private _onMessage(message: any): void {
    const { requestId, type, result, error } = message;
    const callback = this._callbacks.get(requestId);

    if (!callback) {
      return;
    }

    this._callbacks.delete(requestId);

    if (type === 'test-success') {
      callback.resolve(result);
    } else if (type === 'test-failure') {
      callback.reject(error);
    }
  }
}

このコードでは、requestId を使ってリクエストとレスポンスを紐付けています。非同期で複数のテストを実行しても、結果を正しく対応させられるのです。

ワーカー側の実装は以下のようになります。

typescript// ワーカープロセス側:テストを実行して結果を返す
import { runTest } from '@jest/core';

// 親プロセスからのメッセージを受信
process.on('message', async (message: any) => {
  const { type, requestId, testPath } = message;

  if (type !== 'run-test') {
    return;
  }

  try {
    // テストを実行
    const result = await runTest(testPath);
typescript    // 成功結果を親プロセスに送信
    process.send({
      type: 'test-success',
      requestId,
      result,
    });
  } catch (error) {
    // エラーを親プロセスに送信
    process.send({
      type: 'test-failure',
      requestId,
      error: serializeError(error),
    });
  }
});

ワーカーは受け取ったテストパスに基づいてテストを実行し、結果を親プロセスに返します。エラーが発生した場合は、シリアライズしてから送信することで、プロセス間でも正しく情報を伝達できるのです。

4. テストの独立性確保

Jest は、各テストファイルを独立したプロセスで実行することで、テスト間の独立性を保証しています。

プロセス分離によるメリット

typescript// 各ワーカープロセスは独立した環境を持つ
// worker1.js
global.testCounter = 0; // ワーカー1 のグローバル変数

// worker2.js
global.testCounter = 0; // ワーカー2 のグローバル変数(独立)

プロセスが分離されているため、あるテストでグローバル変数を変更しても、他のテストには影響しません。各ワーカーは独自のメモリ空間を持っているからです。

テスト環境のリセット

Jest は各テストファイルの実行前に、テスト環境をリセットします。

typescript// テスト環境のセットアップとクリーンアップ
import { JestEnvironment } from '@jest/environment';

class TestRunner {
  async runTestFile(testPath: string): Promise<TestResult> {
    // 新しいテスト環境を作成
    const environment = new JestEnvironment(this._config);

    try {
      // テスト環境をセットアップ
      await environment.setup();
typescript      // テストファイルを実行
      const result = await this._executeTest(testPath, environment);

      return result;
    } finally {
      // テスト環境をクリーンアップ
      await environment.teardown();
    }
  }
}

このコードでは、各テストファイルごとに新しい環境を作成し、実行後は必ずクリーンアップしています。finally ブロックを使うことで、エラーが発生してもクリーンアップが確実に実行されるのです。

5. 実行順序の最適化

Jest は、テストファイルの実行順序も最適化しています。過去の実行時間データを活用して、重いテストを優先的に実行することで、全体の実行時間を短縮するのです。

テスト実行時間のキャッシング

typescript// テスト実行時間をキャッシュする仕組み
import * as fs from 'fs';
import * as path from 'path';

class TestTimingCache {
  private _cacheFile: string;
  private _timings: Map<string, number>;

  constructor(cacheDir: string) {
    this._cacheFile = path.join(cacheDir, 'test-timings.json');
    this._timings = this._loadCache();
  }
typescript  // キャッシュからテスト実行時間を読み込む
  private _loadCache(): Map<string, number> {
    try {
      if (fs.existsSync(this._cacheFile)) {
        const data = fs.readFileSync(this._cacheFile, 'utf-8');
        const json = JSON.parse(data);
        return new Map(Object.entries(json));
      }
    } catch (error) {
      // キャッシュの読み込みに失敗しても続行
      console.warn('Failed to load test timing cache:', error);
    }
    return new Map();
  }
typescript  // テスト実行時間を記録
  recordTiming(testPath: string, duration: number): void {
    this._timings.set(testPath, duration);
  }

  // テスト実行時間を取得(デフォルトは 0)
  getTiming(testPath: string): number {
    return this._timings.get(testPath) || 0;
  }

  // キャッシュをファイルに保存
  save(): void {
    const json = Object.fromEntries(this._timings);
    fs.writeFileSync(
      this._cacheFile,
      JSON.stringify(json, null, 2)
    );
  }
}

このコードでは、各テストファイルの実行時間を JSON ファイルにキャッシュしています。次回のテスト実行時に、この情報を使って最適な順序を決定できるのです。

実行順序のソート

キャッシュされた実行時間を使って、テストファイルをソートします。

typescript// テストファイルを実行時間の降順にソート
class TestScheduler {
  private _cache: TestTimingCache;

  sortTestsByTiming(tests: Array<Test>): Array<Test> {
    return tests.sort((a, b) => {
      const timingA = this._cache.getTiming(a.path);
      const timingB = this._cache.getTiming(b.path);

      // 実行時間が長いテストを優先
      return timingB - timingA;
    });
  }
}

実行時間が長いテストを優先的に実行することで、ワーカーが最後に短いテストだけを残して待機する状況を避けられます。これにより、全体の実行時間を最小化できるのです。

次の図は、実行順序の最適化の効果を示しています。

mermaidflowchart LR
    subgraph before["最適化前(8秒)"]
        direction TB
        w1a["W1: 1秒+1秒+1秒"]
        w2a["W2: 5秒"]
    end

    subgraph after["最適化後(6秒)"]
        direction TB
        w1b["W1: 5秒+1秒"]
        w2b["W2: 1秒+1秒"]
    end

    before -.->|"実行順序<br/>を最適化"| after

最適化前は、5 秒かかるテストがワーカー 2 に割り当てられたため、ワーカー 1 が先に終わっても待機時間が発生していました。最適化後は、重いテストを優先的に実行することで、両方のワーカーがほぼ同時に完了し、全体の実行時間が短縮されているのです。

具体例

実際の Jest 設定での並列実行制御

ここでは、実際のプロジェクトで Jest の並列実行をどのように制御・最適化するか、具体的な例を見ていきましょう。

例 1:基本的なワーカー数の設定

Jest の設定ファイルで、ワーカー数を明示的に指定できます。

typescript// jest.config.js
module.exports = {
  // ワーカー数を 4 に固定
  maxWorkers: 4,

  // または CPU コア数の 50% を使用
  maxWorkers: '50%',

  // テストタイムアウトの設定
  testTimeout: 30000, // 30 秒
};

このように、maxWorkers オプションで数値または割合を指定できます。CI 環境では固定値、ローカル開発では割合を使うのが一般的です。

例 2:コマンドラインでのワーカー数指定

テスト実行時に、コマンドラインからワーカー数を指定することもできます。

bash# ワーカー数を 2 に制限して実行
yarn jest --maxWorkers=2

# シングルスレッドで実行(デバッグ時に便利)
yarn jest --runInBand

--runInBand オプションを使うと、並列実行を無効化して、すべてのテストを親プロセスで順次実行します。デバッガーを使いたい場合や、テストの失敗原因を特定したい場合に有用です。

例 3:カスタム Test Runner の実装

Jest の並列実行の仕組みを理解すると、カスタム Test Runner を実装できます。

typescript// custom-runner.ts
import { Test, TestResult } from '@jest/test-result';
import { Config } from '@jest/types';
import { Worker } from 'jest-worker';

class CustomTestRunner {
  private _globalConfig: Config.GlobalConfig;
  private _worker: Worker;

  constructor(globalConfig: Config.GlobalConfig) {
    this._globalConfig = globalConfig;
typescript    // ワーカープールを作成
    this._worker = new Worker(
      require.resolve('./worker'),
      {
        numWorkers: globalConfig.maxWorkers,
        exposedMethods: ['runTest'],
      }
    );
  }

  async runTests(
    tests: Array<Test>,
    watcher: any,
    onStart: any,
    onResult: any,
    onFailure: any
  ): Promise<void> {
typescript    // テストを実行時間でソート
    const sortedTests = this._sortTestsByTiming(tests);

    // 各テストを順次実行(ワーカーが自動的に割り当てられる)
    for (const test of sortedTests) {
      // テスト開始を通知
      if (onStart) {
        await onStart(test);
      }

      try {
        // ワーカーでテストを実行
        const result = await this._worker.runTest(test.path);
typescript        // 結果を記録
        if (onResult) {
          await onResult(test, result);
        }
      } catch (error) {
        // エラーを処理
        if (onFailure) {
          await onFailure(test, error);
        }
      }
    }
  }

  private _sortTestsByTiming(tests: Array<Test>): Array<Test> {
    // 過去の実行時間データを使ってソート
    // 実装は前述の TestTimingCache を参照
    return tests;
  }
}

module.exports = CustomTestRunner;

このカスタムランナーでは、テストの開始・結果・失敗時にコールバックを呼び出せるようにしています。これにより、独自のレポーターやモニタリングツールと統合できるのです。

例 4:Worker ファイルの実装

カスタムランナーで使用するワーカーファイルも実装してみましょう。

typescript// worker.ts
import { runCLI } from '@jest/core';
import type { Config } from '@jest/types';

interface WorkerOptions {
  config: Config.ProjectConfig;
  globalConfig: Config.GlobalConfig;
}

export async function runTest(
  testPath: string
): Promise<any> {
  // Jest の CLI を使ってテストを実行
  const result = await runCLI(
    {
      _: [testPath],
      config: JSON.stringify(this.config),
    } as Config.Argv,
    [this.config.rootDir]
  );
typescript  // 結果を整形して返す
  return {
    testFilePath: testPath,
    numPassingTests: result.results.numPassedTests,
    numFailingTests: result.results.numFailedTests,
    testResults: result.results.testResults,
    duration: result.results.testResults[0]?.perfStats?.runtime || 0,
  };
}

// ワーカープロセス初期化時の設定を保持
let workerConfig: WorkerOptions;

export function setup(options: WorkerOptions): void {
  workerConfig = options;
}

このワーカーは、setup メソッドで初期化され、runTest メソッドでテストを実行します。jest-worker は、これらのメソッドを自動的に呼び出してくれるのです。

例 5:並列実行のパフォーマンス測定

並列実行の効果を測定するために、実行時間を記録する仕組みを追加できます。

typescript// performance-reporter.ts
import type { Reporter, TestResult } from '@jest/test-result';
import type { Config } from '@jest/types';
import * as fs from 'fs';

class PerformanceReporter implements Reporter {
  private _startTime: number;
  private _testTimings: Map<string, number>;
  private _workerUtilization: Map<number, number>;

  constructor(
    globalConfig: Config.GlobalConfig,
    options: any
  ) {
    this._testTimings = new Map();
    this._workerUtilization = new Map();
  }
typescript  // テストスイート開始時
  onRunStart(): void {
    this._startTime = Date.now();
    console.log('テスト実行を開始します...');
  }

  // 各テスト完了時
  onTestResult(
    test: any,
    testResult: TestResult,
    results: any
  ): void {
    const duration = testResult.perfStats.runtime;
    this._testTimings.set(testResult.testFilePath, duration);

    // ワーカー ID を記録(実際の実装では worker から取得)
    const workerId = testResult.workerId || 0;
    const current = this._workerUtilization.get(workerId) || 0;
    this._workerUtilization.set(workerId, current + duration);
  }
typescript  // テストスイート完了時
  onRunComplete(): void {
    const totalTime = Date.now() - this._startTime;

    // 統計情報を計算
    const stats = this._calculateStats();

    console.log('\n=== パフォーマンスレポート ===');
    console.log(`総実行時間: ${totalTime}ms`);
    console.log(`平均テスト時間: ${stats.averageTestTime}ms`);
    console.log(`最長テスト時間: ${stats.maxTestTime}ms`);
    console.log(`最短テスト時間: ${stats.minTestTime}ms`);
    console.log('\n--- ワーカー使用率 ---');
typescript    // ワーカーごとの使用率を表示
    for (const [workerId, time] of this._workerUtilization) {
      const utilization = (time / totalTime) * 100;
      console.log(`ワーカー ${workerId}: ${utilization.toFixed(1)}%`);
    }

    // レポートをファイルに保存
    this._saveReport({
      totalTime,
      stats,
      workerUtilization: Array.from(this._workerUtilization.entries()),
    });
  }
typescript  private _calculateStats() {
    const timings = Array.from(this._testTimings.values());

    return {
      averageTestTime: timings.reduce((a, b) => a + b, 0) / timings.length,
      maxTestTime: Math.max(...timings),
      minTestTime: Math.min(...timings),
      totalTests: timings.length,
    };
  }

  private _saveReport(data: any): void {
    fs.writeFileSync(
      'jest-performance-report.json',
      JSON.stringify(data, null, 2)
    );
  }
}

module.exports = PerformanceReporter;

このレポーターを使うと、各テストの実行時間やワーカーの使用率を可視化できます。パフォーマンスのボトルネックを特定するのに役立つでしょう。

Jest 設定ファイルに追加します。

typescript// jest.config.js
module.exports = {
  maxWorkers: 4,
  reporters: ['default', './performance-reporter.js'],
};

例 6:CI 環境での最適化

CI 環境では、リソースの制約や実行時間の要件に応じて、並列実行を最適化する必要があります。

typescript// jest.config.ci.js
const os = require('os');

// CI 環境を検出
const isCI = process.env.CI === 'true';
const cpuCount = os.cpus().length;

module.exports = {
  // CI では CPU コア数 - 1 を使用
  maxWorkers: isCI ? Math.max(cpuCount - 1, 2) : '50%',

  // CI ではキャッシュを有効化
  cache: true,
  cacheDirectory: '.jest-cache',

  // テストタイムアウトを短く設定
  testTimeout: isCI ? 10000 : 30000,
typescript  // CI では詳細なレポートを出力
  verbose: isCI,

  // カバレッジ収集(CI のみ)
  collectCoverage: isCI,
  coverageReporters: isCI ? ['json', 'lcov', 'text'] : ['text'],

  // 失敗時は即座に停止(CI のみ)
  bail: isCI ? 1 : 0,
};

このように、CI 環境とローカル環境で設定を使い分けることで、それぞれの環境に最適化されたテスト実行が可能になります。

次の表は、環境ごとの推奨設定をまとめたものです。

#設定項目ローカル環境CI 環境理由
1maxWorkers50%CPU - 1ローカルでは他のアプリへの影響を最小化、CI では最大限活用
2testTimeout30000ms10000msCI では迅速なフィードバックを優先
3bail0(無効)1(有効)CI では失敗時に即座に停止してリソースを節約
4cache任意有効CI ではビルド間でキャッシュを共有して高速化
5verbosefalsetrueCI では詳細なログを出力してデバッグを容易に

この表から、環境ごとに最適な設定が異なることが分かります。ローカル環境では開発者の体験を優先し、CI 環境では速度とリソース効率を重視するのです。

図で理解する並列実行の全体フロー

最後に、Jest の並列実行の全体像を図でまとめてみましょう。

mermaidsequenceDiagram
    participant CLI as Jest CLI
    participant Scheduler as TestScheduler
    participant Cache as TimingCache
    participant Queue as TestQueue
    participant W1 as ワーカー1
    participant W2 as ワーカー2
    participant Reporter as Reporter

    CLI->>CLI: 設定を読み込む
    CLI->>Scheduler: テスト実行を開始
    Scheduler->>Cache: 過去の実行時間を取得
    Cache-->>Scheduler: 実行時間データ

    Scheduler->>Queue: テストをソートして<br/>キューに追加

    Scheduler->>W1: test1.js 実行依頼
    Scheduler->>W2: test2.js 実行依頼

    Note over W1,W2: 並列実行中

    W1->>W1: test1.js を実行
    W2->>W2: test2.js を実行

    W1-->>Scheduler: test1.js 結果<br/>(成功、2秒)
    Scheduler->>Reporter: test1.js の結果を報告
    Scheduler->>Cache: test1.js の実行時間を記録
    Scheduler->>W1: test3.js 実行依頼

    W2-->>Scheduler: test2.js 結果<br/>(成功、5秒)
    Scheduler->>Reporter: test2.js の結果を報告
    Scheduler->>Cache: test2.js の実行時間を記録

    W1-->>Scheduler: test3.js 結果<br/>(成功、1秒)
    Scheduler->>Reporter: test3.js の結果を報告
    Scheduler->>Cache: test3.js の実行時間を記録

    Scheduler->>Cache: キャッシュを保存
    Scheduler->>Reporter: 全テスト完了
    Reporter->>CLI: レポートを出力

この図は、Jest の並列実行の全体フローを時系列で示しています。設定の読み込みから、テストのソート、ワーカーへの割り当て、結果の集約まで、一連の流れが一目で理解できるでしょう。

特に注目すべきは、実行時間のキャッシングです。各テストの実行時間を記録し、次回のテスト実行時に最適な順序を決定することで、継続的にパフォーマンスを改善しているのです。

まとめ

Jest の並列実行は、Worker Pool パターンと動的タスク割り当てを組み合わせることで、高速かつ効率的なテスト実行を実現しています。

本記事で解説した主要なポイントを振り返りましょう。

Jest 並列実行の仕組み

  1. Worker Pool パターン

    • あらかじめワーカープロセスを起動しておき、タスクを効率的に割り当てる
    • デフォルトでは CPU コア数に応じて最適なワーカー数を自動決定
  2. 動的タスク割り当て

    • 空いたワーカーに次のテストを即座に割り当てる
    • テスト実行時間のばらつきがあっても、全体の実行時間を最小化
  3. プロセス間通信

    • Node.js の child_process モジュールを使った効率的な IPC
    • リクエスト ID による非同期通信の管理
  4. テストの独立性

    • プロセス分離によるグローバル状態の隔離
    • 各テストファイルごとの環境セットアップとクリーンアップ
  5. 実行順序の最適化

    • 過去の実行時間データをキャッシュ
    • 重いテストを優先的に実行することで待機時間を削減

パフォーマンスチューニングのポイント

Jest の並列実行を最大限活用するには、以下のポイントに注意しましょう。

#項目推奨事項理由
1ワーカー数環境に応じて調整ローカルは 50%、CI は CPU - 1 が目安
2テストの粒度ファイル単位で適切に分割並列実行の恩恵を最大化
3実行順序重いテストを先に実行待機時間を削減
4キャッシュ実行時間データを活用継続的なパフォーマンス改善
5テストの独立性共有状態を避ける並列実行の安定性を確保

実践的な活用方法

Jest の並列実行の仕組みを理解することで、以下のような実践的なメリットが得られます。

開発効率の向上

  • テスト実行時間の大幅な短縮により、開発フィードバックサイクルが高速化します
  • TDD(テスト駆動開発)がより実践しやすくなるでしょう

CI/CD パイプラインの最適化

  • リソースを最大限活用した効率的なテスト実行が可能になります
  • ビルド時間の短縮により、デプロイまでの時間を削減できるのです

カスタマイズの可能性

  • 並列実行の仕組みを理解することで、独自のランナーやレポーターを実装できます
  • プロジェクト固有の要件に応じた最適化が可能になるでしょう

さらなる学習のために

Jest の並列実行についてさらに深く学びたい方は、以下のトピックも探求してみてください。

  • jest-worker の内部実装: Worker Pool パターンの詳細な実装を学べます
  • テストのシャーディング: 複数の CI マシン間でテストを分散実行する方法です
  • カスタム Test Sequencer: テスト実行順序を完全にカスタマイズする方法でしょう
  • メモリ管理: 大規模なテストスイートでのメモリリークの検出と対策について理解できます

Jest の並列実行は、現代の Web 開発において不可欠な機能です。その仕組みを理解し、適切に活用することで、開発チーム全体の生産性を大きく向上させられるでしょう。

関連リンク