テストスイートがすべてグリーンで、行カバレッジが 100% に達していても、バグを見逃すことはあります。カバレッジが測るのは、テスト中にどの行が実行されたかであって、アサーションが実際に欠陥を捉えたかどうかではありません。関数を呼び出すだけで戻り値を一切チェックしないテストでも、カバレッジの数値には加算されます。そのテストが防げたはずのバグは、そのまま出荷されてしまいます。
このギャップを埋めるのがミューテーションテストです。この手法では、ミューテーションと呼ばれる小さな変更を意図的にソースコードに加え、変更を加えたバージョンごとにテストスイートを実行し、検出されなかった変更を報告します。すべてのミューテーションを捉えるテストスイートは、アサーションがきちんと役割を果たしています。ミューテーションを見逃すスイートにはギャップがあり、ツールは生き残ったミューテーションを 1 つずつ特定し、それを生んだ行を指し示します。
この記事では、ミューテーションテストとは何か、どのように機能するのか、そして CI/CD パイプラインにどう組み込むのかを解説します。
ミューテーションテストの定義
ミューテーションテストとは、ソースコードに小さな変更を意図的に加え、そのうちどの変更をテストが検出できるかを報告することで、テストスイートの品質を測るソフトウェアテストの手法です。
ミューテーションテストツールはソースファイルを読み込み、小さな変換のセット (> を >= に置き換える、true を false に反転するなど) を適用して一連の変更版を作り、それぞれに対して既存のテストスイートを実行します。このチェックはユニットテストのレベルで行われ、開発者がすでに書いているテストと並行して実行されます。
前提はシンプルです。コードが観測可能な振る舞いを変えるような形で壊れたなら、少なくとも 1 つのテストは失敗するはずです。テストを失敗させたミューテーションは「キルされた」ミュータントで、スイートがそれを捉えたことを意味します。テストが通ったままのミューテーションは「生き残った」ミュータントです。どこかで、その回帰を捉えるべきだったアサーションが捉えられなかったということであり、ツールはその場所を正確に報告します。
この考え方は、思われているよりも古いものです。Richard Lipton が 1971 年の学生論文で提唱し、Timothy Budd の 1980 年のイェール大学の博士論文が最初の実装を生み出しました。
何十年もの間、この手法はコストが高すぎて実用的ではないと考えられていました。生成したミューテーションごとにテストスイート全体を実行するやり方は、遅いハードウェアではスケールしません。現代のマシン、明らかに等価なミューテーションを検出してスキップする賢いツール、並列実行、そして変更されたファイルだけをミューテーションの対象にする機能によって、通常の CI パイプラインでも手の届くものになりました。
ミューテーションテストの仕組み
プロセスは 6 つのステップで進み、すべてミューテーションテストツールによって自動化されています。
-
変更を加えていないコードに対してテストスイートを実行し、すべてのテストが通ることを確認します。 ミューテーションテストはグリーンのベースラインを前提としています。失敗しているスイートに対してミュータントを実行しても、得られるのはシグナルではなくノイズです。
-
ソースコード全体にミューテーション演算子を適用して、ミュータントを生成します。 各演算子は、比較の反転やリテラルの置き換えなど、明確に定義された小さな変更を 1 つ加えます。ツールは、適用可能な箇所ごとに、適用した演算子 1 つにつき 1 つのミュータントを生成します。
-
各ミュータントに対して既存のテストスイートを実行します。 スイートがカバーすべき振る舞いを持つミュータントであれば、どれに対しても少なくとも 1 つのテストが失敗することが期待されます。
-
各ミュータントを「キル」か「生存」に分類します。 キルされたミュータントはテストを失敗させ、生き残ったミュータントはすべてのアサーションをすり抜けました。ツールはさらに、テストが一度も到達しなかったミュータントや、ハングしたミュータントにもフラグを立てます。
-
ミューテーションスコア、つまり生成したミュータントのうちキルされたものの割合を算出します。 スコアが 100% なら、すべてのミューテーションが少なくとも 1 つのテストで捉えられたことを意味します。スコアが低ければ、スイートの具体的なギャップがわかります。
-
ミュータントを生き残らせたテストを強化します。 生き残ったミュータントはそれぞれ、どのアサーションも気づかなかったコードの場所と変換を示しています。そこに、足りないテストケースがあります。
ミューテーションテストツールは、各ミュータントの結果をいくつかの標準的な状態に分類します。
- Killed (キル): ミュータントの実行時にテストが失敗し、ミューテーションが検出されました。
- Survived (生存): ミューテーションがあるにもかかわらずすべてのテストが通り、ミューテーションは検出されませんでした。ツールはその場所を報告します。
- No coverage (カバレッジなし): ミューテーションを加えた行を実行するテストがありません。アサーションの品質にかかわらず、このミューテーションはキルできませんでした。
- Timeout (タイムアウト): ミュータントによってテストスイートがハングしました (よくあるのは、オフバイワンのミューテーションによって、終了するはずのループが無限ループになるケースです)。ハングそのものが変更を明らかにしているため、ほとんどのツールはタイムアウトをキルとして扱います。
- Equivalent (等価): ミューテーションが元のコードと同じ観測可能な振る舞いを生みます。どのテストも 2 つを区別できないため、このミュータントはキルできず、スコアに不利に算入すべきではありません。
コードで見るミューテーションテストの例
関連リポジトリ CIRCLECI-GWP/mutation-testing-demo は、カバレッジとミューテーションテストのギャップを具体的に示す、関数 1 つだけのプロジェクトです。テスト対象の関数は src/age.js にあります。
export function isAdult(age) {
return age >= 18;
}
test/age.test.js にある 1 つのテストがこの関数を実行します。
import { isAdult } from '../src/age.js';
import { expect } from 'chai';
describe('isAdult', () => {
it('returns true for age 25', () => {
expect(isAdult(25)).to.equal(true);
});
});
テストは通ります。テスト中に isAdult のすべての行が実行されるため、行カバレッジは 100% になります。カバレッジだけで測れば、この関数は完全にテストされています。しかし実際はそうではありません。
ミューテーションテストを使うと、このギャップが見えるようになります。このプロジェクトでは、ミューテーションテストツールとして Stryker を Mocha と組み合わせて使っています。package.json の関連するスクリプトは次のとおりです。
"scripts": {
"test": "mocha",
"mutation": "stryker run"
}
依存関係をインストールしてから、ミューテーションジョブを実行します。
npm ci
npm run mutation
同じコマンドはリポジトリの CircleCI パイプライン (.circleci/config.yml) でも実行され、HTML のミューテーションレポートがビルドのアーティファクトとしてアップロードされます。独自のパイプラインをセットアップしたい場合は、リポジトリを接続して最初のパイプラインを実行するまでの手順を解説した CircleCI のクイックスタートガイドをご覧ください。
このリポジトリを CircleCI に接続してパイプラインを実行すると、install ジョブと unit-tests ジョブは成功します。Mocha のテスト 1 つは通り、関数の行カバレッジは 100% です。続いて mutation-tests が Stryker を実行し、src/age.js のミューテーション版を 5 つ生成して、それぞれに対してテストを再実行します。
ミュータントが 2 つ生き残り、スコアは設定されたしきい値 break: 70 を下回る 60% となって、ジョブは赤で終了します。この赤いジョブこそがポイントです。カバレッジが 100% で通るテストスイートにも本当のギャップが残りうること、そしてそれがどこにあるのかを、失敗したビルドに添付されたミューテーションレポートが正確に示しています。
| Mutator | ミューテーション |
|---|---|
ConditionalExpression |
return age >= 18 → return true |
EqualityOperator |
return age >= 18 → return age > 18 |
生き残ったミュータントはそれぞれ、足りないテストケースを示しています。
return true: 本体を無条件のtrueに置き換えても生き残るのは、唯一のアサーションがisAdult(25) === trueをチェックしているだけだからです。どの入力に対してもtrueを返す関数も、このアサーションを通過します。age > 18:>=を>に反転しても検出されないのは、テストが境界値を一度も確かめていないからです。ミューテーション後の関数はちょうど 18 歳の人に対してfalseを返しますが、そのケースをカバーするアサーションはありません。
reports/mutation/index.html の HTML レポートでは、生き残った 2 つのミュータントが、それぞれ加えられた行でハイライトされます。このレポートは、CircleCI パイプラインのアーティファクトとして生成されます。
この問題を解決するには、アサーションを 2 つ追加するだけで十分です。テストに追加してみましょう。
// kills `return true` by exercising the negative case
expect(isAdult(17)).to.equal(false);
// kills `age > 18` by exercising the boundary
expect(isAdult(18)).to.equal(true);
isAdult(17) は false を返さなければならなくなり、無条件で true を返すミュータントはこれを満たせません。isAdult(18) は true を返さなければならなくなり、>= の代わりに > を使うミュータントはこれを満たせません。生き残っていた 2 つのミューテーションは、どちらも捉えられるようになりました。
npm run mutation を再実行すると、ミューテーションスコアは 100% になります。行カバレッジの数値は変わっていません。変わったのは、関数が実際に持つすべての振る舞いを、アサーションが区別できるようになったことです。
CircleCI でパイプラインを再実行すると、すべてがグリーンになっていることがわかります。最初に実行したパイプラインでは、ミューテーションテストによって、テストがカバーしていなかったアサーションの不足を見つけました。それらのアサーションを追加したことで、Stryker のテストはミューテーションスコア 100 を返し、CircleCI パイプラインのビルドはグリーンになりました。
ミューテーションテストとコードカバレッジの違い
コードカバレッジとミューテーションテストは、異なる問いに答えます。カバレッジが測るのは、テストによって各行や各分岐が実行されたかどうかです。ミューテーションテストが測るのは、その行のコードが違う振る舞いをしていたら、テストがそれに気づいたかどうかです。同じテストでも、2 つの指標が逆方向に動くことがあります。
最もわかりやすいのは、アサーションのないテストです。関数を呼び出して結果に何もしなければ、その関数のすべての行がカバー済みとして報告されます。同じテストのミューテーションスコアは 0% です。失敗しうるアサーションがないため、すべてのミュータントが生き残ります。前のセクションの isAdult の例は、同じ問題の軽いバージョンです。アサーションは存在しますが、カバーしているのは 1 つの入力だけなので、関数へのミューテーションのほとんどは通ってしまいます。
ミューテーションテストが真価を発揮する場面は次のとおりです。
| コードカバレッジ | ミューテーションテスト | |
|---|---|---|
| 測るもの | どの行や分岐が実行されたか | アサーションが欠陥を検出できるか |
| 一般的なコスト | ごくわずか (テストと一緒に実行) | 高い (ミュータントごとにスイート全体を実行) |
| 空のテストやアサーションのないテストを検出 | できない | できる |
| エッジケースの不足を検出 | できない | できる |
| 適した用途 | コードベース全体を対象にした素早い一次チェック | 重要なロジックのテストを磨き上げる |
両者は補完し合うものであり、代わりになるものではありません。カバレッジはコミットごとに強制できるほど安価で、コードベースの大部分がそもそも実行されているかどうかを素早く把握できます。ミューテーションテストはコストが高いため対象を絞ることが重要で、その価値のほとんどは、ビジネスロジックやセキュリティ上重要なパスなど、正しさが実際の影響を持つコードにあります。
妥当なパターンは、すべてのプルリクエストでカバレッジのベースライン (たとえば行カバレッジ 80%) をゲートにし、ミューテーションテストは厳選したコードベースの一部を対象にスケジュールで実行することです。
ミューテーションの種類と一般的なミューテーション演算子
ミューテーション演算子は、大きく 3 つのカテゴリーに分けられます。
- ステートメントミューテーションは、ステートメント全体を変更または削除します。最も一般的な例はステートメントの削除で、ソースから 1 行を単純に取り除きます。
- 値ミューテーションは、定数やリテラルを変更します。
0を1に、文字列"admin"を""に置き換えるのが典型的な例です。 - 判定ミューテーションは、演算子を変更する (
>を>=に) か、式全体をtrueまたはfalseに短絡させることで、分岐条件の真偽値を変えます。isAdultの例で生き残った 2 つのミュータントは、どちらも判定ミューテーションでした。
実際には、ツールはより具体的な演算子を扱います。以下は、主要なツール (Stryker、PIT、mutmut など) の多くに共通する演算子です。
- 算術演算子の置き換え:
a + bをa - bに、*を/に、といった具合に置き換えます。 - 関係演算子の置き換え:
a > bをa >= bに、==を!=に置き換えます。 - 条件の境界: 厳密な不等号を非厳密なものに反転します (
<を<=に、>を>=に)。境界でのオフバイワンエラーを捉えるためのものです。 - 真偽値リテラルの置き換え:
trueをfalseに、またはその逆に置き換えます。 - 否定:
!conditionをconditionに、またはconditionを!conditionに変えます。 - ステートメントの削除: void 関数の呼び出しや return など、ステートメントを 1 つ削除します。
ツールによってサポートする演算子のセットは異なり、特定の演算子を有効化・無効化できます。上記のカテゴリーを知っていれば、ほとんどのミューテーションレポートを戸惑わずに読めます。
ミューテーションテストのツール
ミューテーションテストのツールは言語ごとに異なります。各ツールは、対象言語のソースコードを解析し、ミューテーションを加え、再コンパイル (または再解釈) しなければならないからです。以下は、主要な言語エコシステムでメンテナンスされている主なツールです。
- JavaScript / TypeScript: Stryker が事実上の標準です。この記事の前半の例でも使っており、Mocha、Jest、Vitest、Karma などと統合できます。
- C# / .NET: 同じプロジェクトの Stryker.NET は、標準的な .NET テストランナーをサポートしています。
- Java / JVM: PIT (Pitest とも呼ばれます) は、長く定番の選択肢です。ソースではなく JVM バイトコードにミューテーションを加えるため、大規模なコードベースでも高速です。Maven、Gradle、Ant と統合できます。
- Python: mutmut と Cosmic Ray は、どちらも活発にメンテナンスされています。mutmut は 2 つのうちシンプルなほうで、最初に試されることが多いツールです。Cosmic Ray のプラグインベースの分散モデルは、作業を複数のワーカーに分散させるのに便利です。
- Scala: こちらも Stryker プロジェクトの Stryker4s があります。
- Ruby: mutant が定番の選択肢で、RSpec や Minitest と統合できます。
- Go: go-mutesting (活発にメンテナンスされているのは avito-tech のフォークです) と、より新しい Gremlins は、どちらも検討する価値があります。
- Swift: muter は
xcodebuildを使って、macOS、iOS、tvOS、watchOS の Swift コードベースに対応します。
ツールを選ぶうえで実際に重要なのは、テストランナーとの統合 (ツールが余計な手間なくプロジェクトの既存のテストスイートを動かせること)、変更されたファイルだけにミューテーションを絞る機能 (プルリクエストのたびにコードベース全体で何時間もかかる実行が走らないように)、コアやマシンをまたいだ並列実行、そして読みやすい HTML レポートです。上記の成熟したツールはこの 4 つをすべて満たしています。違いは通常、使い勝手と、プロジェクトの既存のビルド構成との相性に帰着します。
CI/CD パイプラインでのミューテーションテスト
コミットのたびにミューテーションテストを実行しても、コストに見合うことはまずありません。生成したミュータントごとにスイート全体を実行する必要があるため、テストスイートが 30 秒でミュータントが 500 個のプロジェクトなら、ミューテーションジョブには 4 時間かかります。CI/CD の中でミューテーションテストを扱いやすくするパターンは 4 つあります。
- ミューテーションテストはコミットごとではなく、スケジュールで実行します。 main ブランチに対して毎晩または毎週実行すれば、機能開発のスピードを落とすことなく、テスト品質の低下を捉えられます。失敗は翌朝にトリアージします。デプロイをブロックするよりも、数時間分のコンピュートのほうが安く済みます。
- プルリクエストでは、変更されたファイルだけにミューテーションを絞ります。 ほとんどのミューテーションテストツールには、現在のブランチで変更されたファイルだけにミューテーションを加える「since」モードや「diff」モードがあります。そうすれば、30 行のパッチのプルリクエストで発生するミューテーション作業は、数時間ではなく数分で済みます。Stryker には
--sinceが、PIT にはscmMutationCoverageがあります。 - パイプライン全体で並列化します。 ミューテーションテストは完全に並列化できる処理です。各ミュータントは独立してテストスイートを実行するため、作業を複数のワーカーに分ければ、所要時間はワーカー数にほぼ比例して短くなります。CircleCI のような CI/CD プラットフォームでは、複数の並列ジョブを実行して結果をまとめることで、これを簡単に実現できます。ほとんどのミューテーションツールには、1 つのジョブの中でマルチコアマシンを活用するための並列処理機能も組み込まれています。
- しきい値を設定し、それを下回ったらビルドを失敗させます。 Stryker には
--break-at(およびしきい値設定のbreak値) が、mutmut には--CIモードがあります。どちらも、ミューテーションスコアが設定した下限を下回ると、ゼロ以外の終了コードで終了します。前述のデモリポジトリでは Stryker のbreakしきい値を 70% に設定しています。そのため、初期状態でミューテーションテストを実行すると、スコアが 60.00% となってビルドが赤になります。
実際にうまく機能しやすいのは、すべてのプルリクエストで対象を絞った実行 (レビュー中のコードについて素早く有用なフィードバックが得られる) と、main ブランチに対するスケジュールでの全体実行 (プルリクエストごとの実行で見逃したギャップを捉える) の組み合わせです。しきい値の設定によって、どちらをハードなゲートにするか、診断用にとどめるかが決まります。
制約とトレードオフ
遅い
ミューテーションテストは、生成したミュータントごとにテストスイート全体を実行するため、合計の実行時間はおおよそ (ミュータントの数) × (スイートの実行時間) になります。スイートが 60 秒でミュータントが 1,000 個のプロジェクトなら、16 時間分のコンピュートが必要です。だからこそ、上記の CI/CD パターンがあるのです。並列化や対象の絞り込みを行っても、ミューテーションテストは、一般的に使われている自動テスト分析の中で最もコストの高い部類に入ります。
等価ミュータントは避けられない
等価ミュータントとは、構文は異なるものの観測可能な振る舞いがまったく同じソースコードを生むミューテーションのことで、どのテストでもキルできません。典型的な例は、for (let i = 0; i < arr.length; i++) を for (let i = 0; i != arr.length; i++) に変えるケースです。i は常に 1 ずつ増え、ちょうど arr.length に達するため、どんな入力に対しても 2 つのループは同じように振る舞います。もともとキルできなかったミュータントのせいで、スコアが下がってしまいます。一部を検出できるツールもありますが、残りは手作業でマークするか、ノイズとして受け入れるしかありません。
すべてのコードベースに効果があるわけではない
大部分がグルーコードや生成されたコード (protobuf のクラスや ORM のモデルなど)、薄い I/O ラッパーであるコードでは、ミューテーションテストが活躍する余地はあまりありません。そうしたコードへのミューテーションは、簡単に捉えられるか、そもそも意味を持たないかのどちらかです。この手法が効果を発揮するのは、分岐、算術演算、比較、明示的な状態変化を伴う振る舞いを持つコード、おおよそ人が「ビジネスロジック」と呼ぶ種類のコードです。
ミューテーションスコア 100% は正しさを意味しない
ミューテーションスコア 100% が意味するのは、ツールが生成方法を知っていたすべてのミューテーションが、少なくとも 1 つのテストでキルされたということです。演算子のセットではたまたま表現できない形のバグが、コードに残っている可能性はあります。たとえば、ツールが正しい値に変えなかった誤った定数や、1 つのミューテーションとして表現できないエッジケースの不足です。ミューテーションテストはテストスイートを強化しますが、スイートが何を検証すべきかについての人間の判断に取って代わるものではありません。
チームはいつミューテーションテストを始めるべきか?
診断モードで、コードベースの一部分から小さく始めましょう。ミューテーションテストは、段階的に導入したときに効果を発揮します。コストが実際に恩恵を受けるコードにかかり、チームがレポートの意味を直感的に読み取れるようになる時間を持てるからです。
- 重要なモジュール 1 つから始めます。 リスクの高いモジュール (決済や認可がよく選ばれる出発点です) を選び、そこだけでミューテーションテストを実行します。200 行のモジュールなら通常は数百のミュータントが生成され、スケジュールを組まなくてもローカルで実行できる程度の規模です。
- 生き残ったミュータントを、プルリクエストのレビューのきっかけとして扱います。 選んだモジュールにプルリクエストが手を加えたら、生き残ったミュータントのリストが「このコードに足りないテスト」のチェックリストになります。レビュアーは「テストをもっと増やして」と漠然と言うのではなく、1 つずつ具体的に質問できます。
- まず診断、ゲートは後から。 最初の数週間は、しきい値なしでミューテーションテストを実行します。チームが数値の読み方を覚え、明らかなギャップが埋まるまでは、数値にはノイズが多いものです。
--break-at(または同等の設定) を追加するのは、スコアが安定し、何を回帰とみなすかについてチームの合意ができてからにしましょう。 - カバレッジも引き続き実行します。 ミューテーションテストはカバレッジを補うものであり、置き換えるものではありません。カバレッジは、コミットごとの素早い一次チェックとして引き続き役立ちます。より難しい問いには、ミューテーションジョブがゆっくりとしたサイクルで答えます。
- 100% を追い求めないでください。 最後の数パーセントは通常、等価ミュータントか、実際のビジネスリスクを伴わないコードへのミューテーションなので、スコア 100% は労力に見合わないことがほとんどです。リスクの高いモジュールで 60〜80% を下限とするのが現実的な目標です。
妥当な展開には、数日ではなく数週間かかります。選んだモジュールのミューテーションスコアが安定し、チームが生き残ったミュータントに対応することに慣れたら、同じ特徴を持つ次のモジュールへと広げていきましょう。
AI コーディングエージェントでギャップを埋める
かつてミューテーションテストが本番運用のチームにとって実用的でなかったのは、ミュータントごとにスイート全体を実行するコンピュートのコストが非常に大きかったからです。その部分は、現代のハードウェアと CI/CD の並列処理が解決しました。もう 1 つのコスト、つまりレポートが特定した足りないテストを実際に書く作業は、これまで人間に任されてきました。
AI コーディングエージェントは、この 2 つ目のコストも減らします。コードの場所と変換が付いた生き残りミュータントのリストを渡せば、エージェントは足りないアサーションの案を作り、ミュータントがキルされるまで反復できます。
CircleCI の自律型 CI/CD エージェントである Chunk は、ミューテーションジョブと同じレイヤーで動作し、プロジェクトのビルド履歴とテスト結果にアクセスできます。Chunk の Extend Test Coverage プリセットは、テストされていないコードを特定し、既存のスイートで検証した新しいテストを追加するプルリクエストを作成します。
同じワークフロー (分析、生成、検証、プルリクエストの作成) は、生き残ったミュータントにもそのまま当てはまります。生き残ったミュータントは行カバレッジが見逃したカバレッジのギャップであり、それこそが CI/CD に組み込まれたエージェントが対応できるだけのコンテキストを持っている対象です。
CircleCI でミューテーションテストのパイプラインを実行すると、Chunk が分析して修正するための詳細がすべて保存されます。つまり、ミューテーションテストで見つかった問題への対応は、プロンプト 1 つで済むのです。
まとめ
ミューテーションテストは、カバレッジでは答えられない問い、つまりテストスイートのアサーションが実際に欠陥を捉えられるかどうかに答えます。生き残ったミュータントは、コードの場所と変換を添えてギャップを特定するため、カバレッジの数値だけよりも役に立ちます。この手法はコストが高いものの、適切な CI/CD パイプラインがあれば、そのコストは手の届く範囲に収まります。
チームの規模でミューテーションテストを実用的にするには、パイプラインが数百のミュータントの実行を並列化し、プルリクエストごとのミューテーションジョブを変更されたファイルに絞り、全体のジョブを別のスケジュールで実行し、スコアが設定した下限を下回ったらビルドを失敗させられる必要があります。CircleCI は、すでに動くテストスイートを持つあらゆるプロジェクトに、並列処理、スケジュール実行、しきい値によるゲートを標準で備えたパイプライン構造を提供します。
まずは CircleCI の無料アカウントに登録して、プロジェクトの既存のパイプラインにミューテーションジョブを追加しましょう。