T-CREATOR

Vitest 大規模 CI の最適化技術:テストシャーディング × キャッシュヒット最大化

Vitest 大規模 CI の最適化技術:テストシャーディング × キャッシュヒット最大化

大規模なプロジェクトでは、テストの実行時間が開発サイクルのボトルネックになることがよくあります。数千のテストケースが CI で実行されると、完了まで 10 分、20 分、場合によっては 1 時間以上かかることも珍しくありません。

この記事では、Vitest を使った CI 環境での実行時間を劇的に短縮する 2 つの強力な手法「テストシャーディング」と「キャッシュヒット最大化」を詳しく解説します。これらを組み合わせることで、大規模プロジェクトでも数分でテストを完了させられるようになるでしょう。

背景

大規模プロジェクトにおけるテスト実行の現状

近年のフロントエンド開発では、TypeScript や React などのフレームワークを使った複雑な UI コンポーネントが増え、それに伴ってテストケースの数も急増しています。

数百人規模の開発チームでは、1 日に数十回から数百回の PR がマージされ、その都度すべてのテストを実行する必要があります。テスト実行時間が長いと、開発者はフィードバックを待つ時間が増え、生産性が大きく低下してしまうのです。

CI パイプラインにおけるテストの重要性

CI(Continuous Integration)では、コードの品質を保つためにテストが欠かせません。しかし、テスト実行が遅いと以下のような問題が発生します。

  • デプロイまでの時間が長くなり、ビジネス機会を逃す
  • 開発者がテスト完了を待つ間、別の作業に切り替えてコンテキストスイッチが発生する
  • CI キューが詰まり、他の開発者の作業がブロックされる

以下の図は、従来の単一実行環境でのテストフローを示しています。

mermaidflowchart TD
  pr["PR作成"] --> ci_start["CI開始"]
  ci_start --> install["依存関係インストール"]
  install --> test_all["全テスト実行<br/>(単一プロセス)"]
  test_all --> result["結果レポート"]
  result --> merge{テスト成功?}
  merge -->|成功| done["マージ完了"]
  merge -->|失敗| fix["修正"]
  fix --> pr

  style test_all fill:#ffcccc
  style done fill:#ccffcc

このフローでは、すべてのテストが順番に実行されるため、テスト数が増えるほど時間がかかってしまいます。

課題

テスト実行時間の増大がもたらす問題

大規模プロジェクトでのテスト実行には、以下のような具体的な課題があります。

#課題影響発生頻度
1テスト実行に 20 分以上かかる開発者の待ち時間増加★★★
2CI ランナーのリソース不足キューの詰まり★★★
3同じ依存関係を毎回インストール無駄な時間消費★★★
4変更していないコードも再テスト重複実行による時間浪費★★☆
5テスト失敗時の原因特定に時間がかかるデバッグ効率の低下★★☆

従来の最適化手法の限界

単純な並列実行だけでは、以下の理由で十分な効果が得られません。

一つ目は、単一マシンでの並列実行には CPU コア数の制限があることです。たとえば 8 コアのマシンでは、8 プロセス以上の並列実行は効果が薄くなります。

二つ目は、メモリ使用量の増加により、並列数を増やすとシステムが不安定になる可能性があることです。特に Node.js プロセスは各自がメモリを消費するため、同時実行数には限界があるでしょう。

三つ目は、依存関係のインストールやビルド処理が毎回実行され、その時間が無視できないことです。

以下の図は、従来の最適化手法とその限界を示しています。

mermaidflowchart LR
  subgraph traditional["従来の並列実行"]
    direction TB
    single["単一マシン"] --> cores["8コア制限"]
    cores --> mem["メモリ不足"]
    mem --> slow["実行時間: 15分"]
  end

  subgraph issues["主な問題"]
    direction TB
    i1["スケール限界"]
    i2["リソース競合"]
    i3["キャッシュ未活用"]
  end

  traditional --> issues

  style slow fill:#ffcccc
  style issues fill:#ffffcc

これらの課題を解決するには、複数マシンでの並列実行(シャーディング)と、賢いキャッシュ戦略が必要です。

解決策

テストシャーディングによる分散実行

テストシャーディングとは、テストスイート全体を複数のグループ(シャード)に分割し、それぞれを別々の CI ランナーで並列実行する手法です。

Vitest は --shard オプションを使って、簡単にシャーディングを実現できます。以下のコマンドは、テストを 4 つのシャードに分割し、その 1 番目を実行する例です。

bash# 4つのシャードに分割し、1番目を実行
vitest --shard=1/4

このオプションを使うことで、テストファイルがハッシュ値に基づいて均等に分配されます。各シャードは独立して実行されるため、シャード数を増やすほど全体の実行時間が短縮されるのです。

シャーディングの仕組み

Vitest は内部で以下のようなアルゴリズムでテストを分配します。

typescript// シャーディングアルゴリズムの概念コード
function getShardForFile(
  filePath: string,
  shardIndex: number,
  totalShards: number
): boolean {
  // ファイルパスからハッシュ値を生成
  const hash = hashCode(filePath);

  // ハッシュ値を総シャード数で割った余りでシャードを決定
  const assignedShard = (hash % totalShards) + 1;

  // 現在のシャードインデックスと一致するか判定
  return assignedShard === shardIndex;
}

この方法により、テストファイルが各シャードにほぼ均等に分配され、効率的な並列実行が可能になります。

以下の図は、テストシャーディングの仕組みを示しています。

mermaidflowchart TB
  tests["テストスイート<br/>(1000ファイル)"] --> shard1["シャード 1/4<br/>(250ファイル)"]
  tests --> shard2["シャード 2/4<br/>(250ファイル)"]
  tests --> shard3["シャード 3/4<br/>(250ファイル)"]
  tests --> shard4["シャード 4/4<br/>(250ファイル)"]

  shard1 --> runner1["CI Runner 1"]
  shard2 --> runner2["CI Runner 2"]
  shard3 --> runner3["CI Runner 3"]
  shard4 --> runner4["CI Runner 4"]

  runner1 --> result1["結果: 3分"]
  runner2 --> result2["結果: 3分"]
  runner3 --> result3["結果: 3分"]
  runner4 --> result4["結果: 3分"]

  result1 --> aggregate["結果集約"]
  result2 --> aggregate
  result3 --> aggregate
  result4 --> aggregate

  style aggregate fill:#ccffcc

キャッシュヒット最大化戦略

CI でのテスト実行を高速化するもう一つの鍵は、キャッシュの効果的な活用です。キャッシュヒット率を最大化することで、依存関係のインストールやビルド処理を大幅にスキップできます。

依存関係キャッシュの設定

まず、node_modules や Yarn のキャッシュを保存することで、パッケージインストール時間を短縮できます。

GitHub Actions での設定例を見てみましょう。

yaml# GitHub Actions でのキャッシュ設定例
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Node.js のセットアップとキャッシュ
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'

このように cache: 'yarn' を指定するだけで、yarn.lock に基づいた自動キャッシュが有効になります。

ビルド成果物のキャッシュ

次に、Vitest 自体のキャッシュディレクトリをキャッシュすることで、さらなる高速化が可能です。

yaml# Vitest キャッシュディレクトリの保存
- name: Cache Vitest
  uses: actions/cache@v4
  with:
    path: |
      node_modules/.vitest
      node_modules/.vite
    key: vitest-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
    restore-keys: |
      vitest-${{ runner.os }}-

node_modules​/​.vitest には、Vitest が生成した変換済みモジュールや依存関係グラフがキャッシュされています。これを再利用することで、初回実行時の解析処理をスキップできるのです。

キャッシュキーの最適化

キャッシュの効果を最大化するには、適切なキャッシュキーの設計が重要です。

yaml# 段階的なキャッシュキー設定
- uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
    restore-keys: |
      deps-${{ runner.os }}-

restore-keys を指定することで、完全一致するキャッシュがない場合でも、部分一致するキャッシュから復元できます。これにより、依存関係が少し変更された場合でも、大部分を再利用できるでしょう。

2 つの手法の組み合わせ

テストシャーディングとキャッシュヒット最大化を組み合わせることで、相乗効果が生まれます。

以下の図は、最適化後のフローを示しています。

mermaidflowchart TD
  pr["PR作成"] --> ci_start["CI開始"]
  ci_start --> cache_check["キャッシュ確認"]
  cache_check -->|ヒット| restore["依存関係復元<br/>(10秒)"]
  cache_check -->|ミス| install_new["インストール<br/>(60秒)"]

  restore --> shard_split["シャード分割"]
  install_new --> shard_split

  shard_split --> p1["シャード1 実行"]
  shard_split --> p2["シャード2 実行"]
  shard_split --> p3["シャード3 実行"]
  shard_split --> p4["シャード4 実行"]

  p1 --> merge_results["結果集約"]
  p2 --> merge_results
  p3 --> merge_results
  p4 --> merge_results

  merge_results --> final{成功?}
  final -->|成功| done["マージ<br/>(合計3分)"]
  final -->|失敗| fix["修正"]

  style done fill:#ccffcc
  style restore fill:#ccffcc

この組み合わせにより、従来 20 分かかっていたテストが 3 分程度に短縮されるケースも珍しくありません。

具体例

GitHub Actions での完全な設定例

実際のプロジェクトで使える GitHub Actions の設定を見てみましょう。

ワークフローファイルの基本構造

まず、.github​/​workflows​/​test.yml ファイルを作成します。

yaml# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

# 同時実行制御
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

concurrency 設定により、同じブランチでの複数実行がキャンセルされ、リソースの無駄遣いを防げます。

シャーディング用のマトリックス戦略

次に、複数のシャードを並列実行するためのマトリックス戦略を定義します。

yamljobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
        total_shards: [4]

    name: Test (Shard ${{ matrix.shard }}/${{ matrix.total_shards }})

fail-fast: false により、一つのシャードが失敗しても他のシャードは実行を続けます。これにより、すべての失敗を一度に検出できるのです。

依存関係とキャッシュのセットアップ

依存関係のインストールとキャッシュ設定を行います。

yamlsteps:
  - name: Checkout
    uses: actions/checkout@v4

  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'yarn'

  - name: Install dependencies
    run: yarn install --frozen-lockfile

--frozen-lockfile オプションにより、yarn.lock に記載されたバージョンで確実にインストールされます。

Vitest キャッシュの設定

Vitest の内部キャッシュを保存・復元する設定を追加します。

yaml- name: Cache Vitest
  uses: actions/cache@v4
  with:
    path: |
      node_modules/.vitest
      node_modules/.vite
    key: vitest-cache-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('vitest.config.ts') }}
    restore-keys: |
      vitest-cache-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-
      vitest-cache-${{ runner.os }}-

vitest.config.ts もキーに含めることで、設定変更時に確実にキャッシュが更新されます。

シャーディングテストの実行

実際にシャーディングを使ってテストを実行します。

yaml- name: Run tests
  run: |
    yarn vitest run \
      --shard=${{ matrix.shard }}/${{ matrix.total_shards }} \
      --coverage \
      --reporter=json \
      --reporter=verbose
  env:
    CI: true

--reporter=json により、後でカバレッジレポートを統合しやすくなります。

テスト結果の集約とアップロード

各シャードのテスト結果をアーティファクトとしてアップロードします。

yaml- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-results-${{ matrix.shard }}
    path: |
      coverage/
      test-results.json
    retention-days: 7

if: always() により、テストが失敗した場合でも結果がアップロードされます。

カバレッジレポートの統合

複数のシャードから生成されたカバレッジレポートを統合するジョブを追加しましょう。

yamlmerge-coverage:
  runs-on: ubuntu-latest
  needs: test
  if: always()

  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4
      with:
        node-version: '20'

カバレッジファイルのダウンロードと統合

各シャードの結果をダウンロードし、統合します。

yaml- name: Download all artifacts
  uses: actions/download-artifact@v4
  with:
    path: ./coverage-artifacts

- name: Install dependencies
  run: yarn install --frozen-lockfile

- name: Merge coverage reports
  run: |
    yarn vitest --merge-reports ./coverage-artifacts

--merge-reports オプションにより、複数のカバレッジレポートを統合できます。

カバレッジレポートのアップロード

統合されたカバレッジレポートを外部サービスにアップロードします。

yaml- name: Upload to Codecov
  uses: codecov/codecov-action@v4
  with:
    files: ./coverage/coverage-final.json
    flags: vitest
    fail_ci_if_error: true

これにより、PR 上でカバレッジの変化を視覚的に確認できるようになります。

パフォーマンスチューニング

さらなる最適化のために、Vitest の設定ファイルを調整しましょう。

vitest.config.ts の最適化設定

typescript// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // シャーディング時の並列実行設定
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
        // シャード内でも並列実行
        minThreads: 1,
        maxThreads: 4,
      },
    },
  },
});

pool: 'threads' により、ワーカースレッドを使った高速な並列実行が可能になります。

ファイルシステムキャッシュの活用

typescriptexport default defineConfig({
  test: {
    // キャッシュディレクトリの明示的指定
    cache: {
      dir: 'node_modules/.vitest',
    },

    // 依存関係の解析をキャッシュ
    deps: {
      optimizer: {
        web: {
          enabled: true,
        },
      },
    },
  },
});

依存関係のオプティマイザーを有効にすることで、モジュール解析が大幅に高速化されます。

グローバルセットアップの最適化

typescriptexport default defineConfig({
  test: {
    // グローバルセットアップは1回だけ実行
    globalSetup: './tests/setup.ts',

    // 環境の再利用
    isolate: false,
  },
});

isolate: false により、テスト間で環境が再利用され、セットアップコストが削減されます。ただし、テスト間の独立性が重要な場合は true のままにしましょう。

実行時間の比較

最適化前後での実行時間を比較してみましょう。

#構成テスト数実行時間CI ランナーコスト
1最適化前(単一実行)100020 分1 台基準
2並列実行のみ100012 分1 台基準
3シャーディング(2 分割)10007 分2 台1.4 倍
4シャーディング(4 分割)10004 分4 台1.6 倍
5シャーディング+キャッシュ10003 分4 台2.4 倍

シャーディングとキャッシュを組み合わせることで、実行時間が約 85% 削減され、開発者の待ち時間が劇的に短縮されます。

GitLab CI での設定例

GitHub Actions 以外の CI サービスでも同様の最適化が可能です。GitLab CI の例を見てみましょう。

yaml# .gitlab-ci.yml
variables:
  YARN_CACHE_FOLDER: .yarn-cache

# キャッシュ設定
cache:
  key:
    files:
      - yarn.lock
  paths:
    - node_modules/
    - .yarn-cache/
    - node_modules/.vitest/

test:
  stage: test
  parallel: 4
  script:
    - yarn install --frozen-lockfile
    - yarn vitest run --shard=${CI_NODE_INDEX}/${CI_NODE_TOTAL}
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

GitLab CI では parallel: 4 を指定するだけで、自動的に 4 つのジョブが並列実行されます。CI_NODE_INDEXCI_NODE_TOTAL 変数を使うことで、シャード番号が自動的に割り当てられるのです。

まとめ

大規模プロジェクトでの Vitest 実行を高速化するには、テストシャーディングとキャッシュヒット最大化の 2 つの手法が効果的です。

テストシャーディングは、テストスイートを複数のグループに分割し、それぞれを別々の CI ランナーで並列実行する仕組みでした。Vitest の --shard オプションを使うことで、簡単に実装できます。

キャッシュヒット最大化では、依存関係や Vitest の内部キャッシュを保存・復元することで、セットアップ時間を大幅に短縮できました。適切なキャッシュキーの設計により、ヒット率を最大化することが重要です。

これら 2 つを組み合わせることで、従来 20 分かかっていたテストが 3 分程度に短縮され、開発者の生産性が大きく向上します。CI コストは若干増加しますが、開発者の時間コストを考えると十分に価値のある投資といえるでしょう。

ぜひ、あなたのプロジェクトでもこれらの最適化技術を試してみてください。テスト実行の待ち時間から解放され、より快適な開発体験が得られるはずです。

関連リンク