ミューテーションテストスイートの実行に4時間かかるなら、おめでとう。誰もが疑っていたことを証明したことになる:テストスイートに穴が空いている。

そんなものを毎回のプッシュでCIで実行するつもりはないだろう。そんなチームは存在しない。問題は1コミットあたり4時間を許容できるかどうかではない。テストは通っているのに実際には何も検証していないコードをリリースすることを許容できるかどうかだ。

100%コードカバレッジは虚栄のメトリックだ

コードカバレッジは、テスト中にどの行が実行されたかを測定する。しかし、その行が正しくテストされたかどうかは測定しない。

テストはある行を実行し、意味のあるアサーションを行わなくても、カバレッジとしてカウントされる。ミューテーションテストはこれを修正する。コードに小さな変更を加え、テストを実行し、それが失敗するかを確認する。意図的に壊したコードに対してテストが通過するなら、そのテストには価値がない。

問題はスケールだ。1万行のコードと500個のテストを持つ中規模のJavaScriptプロジェクトは、8000個のミューテーションを生成する可能性がある。それぞれのミューテーションに対してフルのテストスイートを実行するのは計算コストが高い。典型的なCIランナー上では、そこで4時間が消えていく。

毎コミットでフルスイートを実行するのは論外だ。しかし、それがミューテーションテストを完全にスキップすべきという意味ではない。

インクリメンタルなミューテーションテストが唯一実用的なアプローチだ

最新のミューテーションテストツールはインクリメンタル解析をサポートしている。コードベース全体をミューテーションするのではなく、現在のプルリクエストで変更されたコードのみをミューテーションする。

200行の変更を含む典型的なPRでは、ツールは40〜80個のミューテーションを生成する可能性がある。関連するテストのサブセットをそれらのミューテーションに対して実行するのは数分で済み、数時間ではない。これこそがチームが実際にCIでミューテーションテストを使う方法だ。

StrykerJSは最も広く使われているJavaScriptのミューテーションテストフレームワークの一つで、そのincrementalオプションを通じてインクリメンタルモードをサポートしている。ミューテーション結果をincremental.jsonファイルに保存し、変更されたファイルのみを再解析する。

以下は、インクリメンタルなCI実行用に設定された最小限のstryker.conf.jsonだ:

{
  "packageManager": "npm",
  "reporters": ["html", "clear-text", "json"],
  "testRunner": "jest",
  "coverageAnalysis": "perTest",
  "incremental": true,
  "incrementalFile": "reports/stryker-incremental.json",
  "mutate": [
    "src/**/*.js",
    "!src/**/*.test.js",
    "!src/**/__tests__/**"
  ],
  "thresholds": {
    "high": 80,
    "low": 60,
    "break": 50
  }
}

coverageAnalysis: perTestの設定は重要だ。これはStrykerに対し、フルのスイートではなく、各ミューテーションされたファイルをカバーするテストのみを実行するよう指示する。これだけで実行時間を1桁短縮できる。

thresholdsブロックはビルドが失敗する条件を定義する。この例では、ミューテーションスコアが50%未満だとCIパイプラインが破壊される。50%〜60%の間は警告が出る。80%以上はグリーンだ。

実際に機能する3つのCIパターン

ミューテーションテストを成功裏に使うチームは、それをユニットテストのように実行しようとしない。彼らは3つのパターンのいずれかを使う。

メインブランチでの夜間フル実行。 完全なミューテーションスイートは1日1回、通常は夜間に実行される。結果はダッシュボードに公開され、経時的に追跡される。これにより、日々の開発を妨げることなく、体系的なテスト品質の問題を捉えることができる。チームは個別のスコアではなく、トレンドを確認する。

プルリクエストでのインクリメンタル実行。 変更されたファイルのみがミューテーションされる。CIジョブはPRパイプラインに3〜8分を追加する。変更されたコードのミューテーションスコアが閾値を下回ると、PRはブロックされる。ここがミューテーションテストが価値を発揮する場所だ:新しいコードがコードベースに入る瞬間に。

主要なデプロイ前のリリースゲート。 一部のチームは、本番環境への出荷前や新バージョンのリリース前に、フルのミューテーション解析を実行する。これはセキュリティ監査やパフォーマンス回帰テストと同様の品質チェックポイントとして扱われる。すべてのリリースではなく、重要なリリースでだ。

最も価値を得ているチームは、最初の2つのパターンを組み合わせている。夜間実行はコードベース全体の健全性を追跡する。PRでのインクリメンタル実行は新しいコードの品質を強制する。

ミューテーションスコアは目標ではない

ここがミューテーションテストが政治的に危険になる場所だ。チーム全体のミューテーションスコアを公開し、パフォーマンスレビューと結びつけると、エンジニアはそのメトリックを最適化するようになる。

彼らは実際の動作をテストせずにミューテーションをキルするテストを書くだろう。元のコードと意味的に同一の等価ミュータントをスコアリングから除外すべきだと主張するだろう。有用なテストを書く代わりに、閾値を微調整する時間を費やすだろう。

ミューテーションテストは診断ツールであり、リーダーボードではない。スコアは調査すべきシグナルであり、達成すべき目標ではない。

より有用なアプローチは、ミューテーションスコアのトレンドを経時的に追跡し、新しいコードでの低いスコアを会話のきっかけとして扱うことだ。「このPRは12個のミューテーションを導入しているのに、キルされたのは4個だけだ。何が欠けているか確認しよう。」これは、リポジトリ全体で73%を示すダッシュボードよりも無限に価値がある。

実際に動作するGitHub Actionsワークフロー

以下は、プルリクエストでインクリメンタルなミューテーションテストを実行し、実行間でインクリメンタル状態を保存する、本番環境で使えるGitHub Actionsワークフローだ。

name: Mutation Testing

on:
  pull_request:
    branches: [main]

jobs:
  stryker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Download previous incremental report
        uses: actions/download-artifact@v4
        with:
          name: stryker-incremental
          path: reports/
        continue-on-error: true

      - name: Run Stryker (incremental)
        run: npx stryker run

      - name: Upload incremental report for next run
        uses: actions/upload-artifact@v4
        with:
          name: stryker-incremental
          path: reports/stryker-incremental.json
        if: always()

重要な詳細はfetch-depth: 0だ。Strykerは、PRブランチとターゲットブランチの間でどのファイルが変更されたかを判断するために、完全なGit履歴が必要だ。これがないと、インクリメンタルモードはフル実行にフォールバックする。

ワークフローは実行前に前回のstryker-incremental.jsonアーティファクトをダウンロードする。アーティファクトが存在しない場合、最初の実行は実質的にフル解析となる。その後の実行はキャッシュされた結果を使用する。

アップロードステップのif: always()は、ミューテーションテストジョブが閾値違反で失敗した場合でも、インクリメンタル状態が保存されることを保証する。これがないと、次のPRは最初からやり直しになる。

等価ミュータントは未だに問題だ

どのミューテーションテストツールも、等価ミュータントを確実に検出することはできない。これらはコードの構文を変えるが、その意味論は変えないミューテーションだ。古典的な例は、可換演算でa = b + ca = c + bに置き換えることだ。技術的には異なるミューテーションだが、動作は同一だ。

等価ミュータントはCI時間を無駄にし、エンジニアを辟易させる。現在の最先端は、ツール固有の設定による手動の除外だ。Strykerは特定のミューテータやファイルを無視できる。Java用のPITはexcludedMethodsexcludedClassesをサポートしている。

完璧な解決策はない。ミューテーションテストを使うチームは、ベースラインとなるレベルのノイズを受け入れ、定期的に除外リストを見直す。

あなたのチームも導入すべきか?

ミューテーションテストは無料ではない。CIの計算リソース、ツールの設定、そして閾値と除外の継続的なメンテナンスが必要だ。プロトタイプや2人のエンジニアしかいないプロジェクトには過剰だ。

監視なしにテスト品質が低下するほど大きなコードベースを持ち、誰もがすべてのPRを詳細にレビューできないほど大きなチームを持つとき、それは努力に見合う価値が出てくる。もし本番環境で、テストに捉えられていたはずのバグを見つけたことがあり、そのテストは存在するのに実際には何もアサートしていないなら、ミューテーションテストはそれを捉えていただろう。

最も重要なサービスのPRでインクリメンタル実行から始めよう。1か月間トレンドを追跡する。もし数字が有用なことを教えてくれるなら、拡張すればいい。そうでなければ、失うのは数分のCI時間で、4時間ではない。

始めたばかりのチーム向けに、Stryker handbookにはJavaScript、C#、Scala向けのプラットフォーム固有ガイドがある。JVMプロジェクトでは、PITが依然として標準だ。両方とも初期設定でインクリメンタル解析をサポートしている。