テストスイートがすべて緑で表示され、行カバレッジ100%を達成していても、バグを見逃している可能性があります。カバレッジはテスト実行中にどの行が実行されたかを測るものであり、アサーションが実際に欠陥を検出したかどうかを測るものではありません。関数を呼び出しても戻り値を確認しないテストでも、カバレッジ数にはカウントされます。防げたはずのバグがそのままリリースされてしまうのです。
ミューテーションテストはこのギャップを埋めます。この手法は、ミューテーションと呼ばれる小さな変更をソースコードに意図的に導入し、変更された各バージョンに対してテストスイートを実行し、どの変更が検出されなかったかを報告します。あらゆるミューテーションを検出できるテストスイートは、アサーションが十分に機能していることを示します。ミューテーションを見逃すスイートにはギャップがあり、ツールは生き残ったミューテーションの名前と、それが生成された行を指し示します。
この記事では、ミューテーションテストとは何か、どのように動作するか、そしてCI/CDパイプラインへの組み込み方について解説します。
ミューテーションテストの定義
ミューテーションテストとは、ソースコードに小さな意図的な変更を加え、それらの変更をテストが検出したかどうかを報告することで、テストスイートの品質を測定するソフトウェアテスト手法です。
ミューテーションテストツールはソースファイルを読み込み、一連の小さな変換(>を>=に置き換える、trueをfalseに反転させるなど)を適用して一連の変更版を生成し、それぞれに対して既存のテストスイートを実行します。チェックはユニットテストレベルで行われ、開発者がすでに記述しているテストと並行して実行されます。
前提は単純です。コードが壊れて観測可能な動作が変わる場合、少なくとも1つのテストが失敗するはずです。テストを失敗させるミューテーションは「killed」ミューテーションです。スイートがそれを検出しました。テストがパスしたままになるミューテーションは「surviving」ミューテーションです。どこかでその回帰を検出するはずのアサーションが機能しておらず、ツールはその正確な場所を報告します。
このアイデアは思ったよりも古く、1971年にRichard Liptonが学生論文で提案し、Timothy Buddの1980年のYale大学での学位論文で初めて動作する実装が作成されました。
長年、この手法は実用上高コストすぎると考えられてきました。生成されたミューテーションごとに完全なテストスイートを実行することは、処理速度の遅いハードウェアではスケールしませんでした。現代のマシン、明らかに同等なミューテーションを検出してスキップするスマートなツール、並列実行、変更されたファイルのみをミューテートする機能により、通常のCIパイプラインでも実用可能になりました。
ミューテーションテストの仕組み
このプロセスはミューテーションテストツールによって自動化され、6つのステップで実行されます。
-
変更されていないコードに対してテストスイートを実行し、すべてのテストがパスすることを確認する。 ミューテーションテストは緑のベースラインを前提とします。失敗しているスイートに対してミューテーションを実行しても、ノイズが生じるだけで意味のある結果は得られません。
-
ミューテーションオペレーターをソースコードに適用してミューテーションを生成する。 各オペレーターは1つの小さな明確な変更(比較演算子の反転やリテラルの置き換えなど)を行い、ツールは適用可能な各場所に適用されたオペレーターごとに1つのミューテーションを生成します。
-
生成された各ミューテーションに対して既存のテストスイートを実行する。 期待されるのは、スイートがカバーすべきミューテーションごとに少なくとも1つのテストが失敗することです。
-
各ミューテーションをkilledまたはsurvivedに分類する。 killedミューテーションはテストの失敗を引き起こしたもので、survivedミューテーションはすべてのアサーションをすり抜けました。ツールはテストが到達しなかったミューテーションやハングしたミューテーションもフラグ付けします。
-
ミューテーションスコアを計算する。これは生成されたミューテーションのうちkilledされたものの割合です。 スコア100%は、すべてのミューテーションが少なくとも1つのテストによって検出されたことを意味します。スコアが低い場合は、スイートに具体的なギャップがあることを示します。
-
survivedしたミューテーションを検出できなかったテストを強化する。 各survivedミューテーションは、どのアサーションも気づかなかったコードの場所と変換を特定します。それが欠けているテストケースが存在する場所です。
ミューテーションテストツールは、各ミューテーションの結果をいくつかの標準的な状態のいずれかに分類します。
- Killed: ミューテーション実行時にテストが失敗したため、ミューテーションが検出されました。
- Survived: ミューテーションがあったにもかかわらずすべてのテストがパスしたため、ミューテーションが検出されませんでした。ツールはその場所を報告します。
- No coverage: ミューテーションされた行をテストが実行していません。アサーションの品質に関係なく、ミューテーションをkilledすることはできませんでした。
- Timeout: ミューテーションによりテストスイートがハングしました(よくあるケースは、オフバイワンのミューテーションにより終了ループが無限ループになった場合です)。ほとんどのツールはタイムアウトをkilledとしてカウントします。ハング自体が変更を明らかにするためです。
- Equivalent: ミューテーションは元のコードと同じ観測可能な動作を生成します。どのテストも2つを区別できないため、ミューテーションをkilledすることはできず、スコアにカウントされるべきではありません。
コードでのミューテーションテストの例
付属のリポジトリCIRCLECI-GWP/mutation-testing-demoは、単一関数のプロジェクトで、カバレッジとミューテーションテストのギャップを具体的に示します。テスト対象の関数はsrc/age.jsにあります。
export function isAdult(age) {
return age >= 18;
}
単一のテストがtest/age.test.jsでそれを実行します。
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%と表示されます。カバレッジのみの指標では、この関数は完全にテストされていることになります。しかし実際にはそうではありません。
ミューテーションテストはこのギャップを可視化します。このプロジェクトはMochaと連携したミューテーションテストツールとしてStrykerを使用しています。package.jsonの関連スクリプトは次のとおりです。
"scripts": {
"test": "mocha",
"mutation": "stryker run"
}
依存関係をインストールし、ミューテーションジョブを実行します。
npm ci
npm run mutation
同じコマンドはリポジトリのCircleCIパイプライン(.circleci/config.yml)内でも実行され、HTMLミューテーションレポートをビルドアーティファクトとしてアップロードします。独自のパイプラインを設定したい場合は、リポジトリの接続方法と最初のパイプラインの実行方法を説明するCircleCIクイックスタートガイドを参照してください。
このリポジトリをCircleCIに接続してパイプラインを実行すると、installおよびunit-testsジョブはパスします。単一のMochaテストは保持され、関数は100%の行カバレッジを持ちます。その後、mutation-testsがStrykerを実行し、src/age.jsの5つの変更版を生成して、それぞれに対してテストを再実行します。
2つのミューテーションがsurviveし、スコアは60%となり、設定されたbreak: 70の閾値を下回ったため、ジョブは赤で終了します。この赤いジョブがポイントです。カバレッジが100%のテストスイートがパスしていても、実際のギャップが残る可能性があり、失敗したビルドに添付されたミューテーションレポートは、その正確な場所を示します。
| Mutator | Mutation |
|---|---|
ConditionalExpression |
return age >= 18 → return true |
EqualityOperator |
return age >= 18 → return age > 18 |
各survivedミューテーションは、欠けているテストケースを特定します。
return true: 本体を無条件のtrueに置き換えてもsurviveするのは、唯一のアサーションがisAdult(25) === trueをチェックしているためです。すべての入力に対してtrueを返す関数も、そのアサーションをパスします。age > 18:>=を>に反転させても検出されないのは、テストが境界を調べていないためです。変更された関数はちょうど18歳の人に対してfalseを返しますが、そのケースをカバーするアサーションはありません。
reports/mutation/index.htmlのHTMLレポートは、導入された行で両方のsurvivedミューテーションをハイライト表示します。このレポートは、ミューテーションテストパイプラインから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を返す必要があり、>=ではなく>を使用するミューテーションでは満たせません。両方のsurvivedミューテーションが検出されるようになりました。
npm run mutationを再実行すると、ミューテーションスコアは100%になります。行カバレッジの数値は変わっていません。変わったのは、アサーションが関数の実際のすべての動作を区別するようになったことです。
CircleCIでパイプラインを再実行すると、すべてが緑で返ってくることがわかります。最初に実行したパイプラインは、ミューテーションテストを活用してテストがカバーしていなかった欠けているアサーションを見つけました。それらのアサーションを配置することで、Strykerのテストはミューテーションスコア100で返り、CircleCIパイプラインは緑でビルドされます。
ミューテーションテストとコードカバレッジの比較
コードカバレッジとミューテーションテストは異なる質問に答えます。カバレッジはテストが各行または分岐を実行させたかどうかを測定します。ミューテーションテストは、それらの行の下のコードが異なる動作をした場合にテストがそれを検出したかどうかを測定します。2つの指標は同じテストで逆方向に動くことがあります。
アサーションのないテストが最も明確な例です。関数を呼び出し、結果に対して何も行わず、その関数のすべての行がカバーされていると報告されます。同じテストはミューテーションスコア0%を生成します。どのミューテーションもsurviveします。アサーションが失敗できないためです。前のセクションのisAdultの例は、同じ問題のより穏やかなバージョンです。アサーションは存在しますが、単一の入力をカバーしているため、関数のほとんどのミューテーションは依然としてパスします。
ミューテーションテストが役立つ場面:
| Code coverage | Mutation testing | |
|---|---|---|
| What it measures | Which lines or branches ran | Whether assertions would detect a defect |
| Typical cost | Negligible (runs alongside tests) | High (runs the full suite per mutant) |
| Detects empty / assertion-free tests | No | Yes |
| Detects missing edge cases | No | Yes |
| Good for | A fast first pass over a whole codebase | Sharpening tests on logic that matters |
2つは補完関係にあり、代替ではありません。カバレッジはすべてのコミットで強制するのに十分安価で、コードベースの大部分が実行されているかどうかを素早く把握できます。ミューテーションテストは高コストであるため、スコープを定めることが重要であり、その価値のほとんどは、正しさの影響が大きいコード(ビジネスロジックやセキュリティクリティカルなパスなど)にあります。
妥当なパターンは、すべてのプルリクエストでカバレッジのベースライン(例:80%の行カバレッジ)をゲートし、コードベースの厳選されたサブセットに対してミューテーションテストをスケジュールで実行することです。
ミューテーションの種類と一般的なミューテーションオペレーター
ミューテーションオペレーターは3つの大まかなカテゴリに分類されます。
- Statement mutationsはステートメント全体を変更または削除します。最も一般的な例はステートメント削除で、ソースから1行を単に削除します。
- Value mutationsは定数またはリテラルを変更します。
0を1に置き換える、文字列"admin"を""に置き換えるなどが典型的な例です。 - Decision mutationsは分岐条件の真偽値を変更します。演算子を変更する(
>を>=に)か、式全体をtrueまたはfalseにショートサーキットします。isAdultの例でsurviveした2つのミューテーションはいずれもdecision mutationsでした。
実際には、ツールはより具体的なオペレーターで動作します。以下のリストは、主流のツール(Stryker、PIT、mutmutなど)のほとんどに存在するオペレーターをカバーしています。
- Arithmetic operator replacement:
a + bがa - bになり、*が/になるなど。 - Relational operator replacement:
a > bがa >= bになり、==が!=になる。 - Conditional boundary: 厳密な不等号を非厳密なものに反転させる(
<を<=に、>を>=に)。境界でのオフバイワンエラーを検出するために設計されています。 - Boolean literal replacement:
trueがfalseになり、その逆も同様。 - Negation:
!conditionがconditionになり、conditionが!conditionになる。 - Statement deletion: 単一のステートメントを削除します。void関数呼び出しやreturnがよく削除されます。
異なるツールは異なるオペレーターセットをサポートし、チームが特定のオペレーターを有効または無効にできます。上記のカテゴリを知っていれば、ほとんどのミューテーションレポートを驚くことなく読むことができます。
ミューテーションテストのツール
ミューテーションテストツールは言語固有です。各ツールは対象言語のソースコードを解析、ミューテート、再コンパイル(または再解釈)する必要があるためです。以下のリストは、主要な言語エコシステムにわたる主なメンテナンスされているツールをカバーしています。
- 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はmacOS、iOS、tvOS、watchOS上のSwiftコードベースを
xcodebuildを使用して扱います。
ツールを選択する際に実際に関わるのは、テストランナー統合(ツールはプロジェクトの既存のテストスイートを余分な手間なく駆動する必要がある)、変更されたファイルへのミューテーションのスコープ設定のサポート(プルリクエストがコードベース全体で数時間の実行をトリガーしないようにする)、コアまたはマシン間での並列実行、および読みやすいHTMLレポートです。上記の成熟したツールは4つすべてをカバーしており、違いは通常、人間工学とプロジェクトの既存のビルドセットアップへの適合度にあります。
CI/CDパイプラインでのミューテーションテスト
すべてのコミットでミューテーションテストを実行することは、コストに見合うことはほとんどありません。生成されたミューテーションごとに完全なスイートを実行する必要があるため、30秒のテストスイートと500のミューテーションを持つプロジェクトでは、4時間のミューテーションジョブになります。4つのパターンがCI/CD内でのミューテーションテストを現実的にします。
- スケジュールでミューテーションテストを実行し、すべてのコミットでは実行しない。 メインブランチに対して夜間または週次の実行は、機能作業を遅らせることなくテスト品質の回帰を検出します。失敗は翌朝トリアージされ、数時間のコンピュートはデプロイをブロックするより安価です。
- プルリクエストで変更されたファイルにミューテーションをスコープする。 ほとんどのミューテーションテストツールは、現在のブランチでタッチされたファイルのみをミューテートする「since」または「diff」モードを公開しています。30行のパッチを持つプルリクエストは、数時間ではなく数分のミューテーション作業をトリガーします。Strykerには
--sinceがあり、PITにはscmMutationCoverageがあります。 - パイプライン全体で並列化する。 ミューテーションテストはembarrassingly parallelです。各ミューテーションはテストスイートを独立して実行するため、複数のワーカーに作業を分割すると、壁時計時間はワーカー数にほぼ比例して短縮されます。CircleCIのようなCI/CDプラットフォームは、複数の並列ジョブを実行して結果を組み合わせることで、これを簡単に行えます。ほとんどのミューテーションツールは、単一のジョブ内でマルチコアマシンを活用するための組み込み並列性も備えています。
- 閾値を設定し、それを下回るとビルドを失敗させる。 Strykerは
--break-at(および閾値設定のbreak値)を公開し、mutmutには--CIモードがあります。どちらもミューテーションスコアが設定された下限を下回ると非ゼロで終了します。前のデモリポジトリはStrykerのbreak閾値70%を使用しており、初期状態でミューテーションテストを実行すると赤いビルドになる理由です。スコアは60.00%になりました。
実際によく機能する組み合わせは、すべてのプルリクエストでスコープされた実行(レビュー中のコードに対する高速で有用なフィードバック)と、メインブランチに対してスケジュールで完全な非スコープ実行(PRごとの実行で見逃されたギャップを検出)です。閾値設定は、どちらもハードゲートにするか、診断のままにしておくかを決定します。
制限とトレードオフ
遅い
ミューテーションテストは生成されたミューテーションごとに完全なテストスイートを実行するため、総実行時間はほぼ(ミューテーション数)×(スイート実行時間)に比例してスケールします。60秒のスイートと1,000のミューテーションを持つプロジェクトでは、16時間のコンピュートが必要になり、上記のCI/CDパターンが存在する理由です。並列化とスコープ設定を行っても、ミューテーションテストは一般的に使用されている自動テスト分析の中で最もコストのかかる形式の1つです。
Equivalentミューテーションは避けられない
Equivalentミューテーションとは、構文は異なるが観測可能な動作は同一のソースコードを生成するミューテーションで、どのテストもそれをkilledできません。標準的な例はfor (let i = 0; i < arr.length; i++)をfor (let i = 0; i != arr.length; i++)に変更する場合です。iは常に1ずつ増加し、arr.lengthに正確に到達するため、2つのループはどの入力に対しても同じ動作をします。スコアはkilled不可能だったミューテーションのために低下します。一部のツールはサブセットを検出しますが、残りは手動でマークするか、ノイズとして受け入れる必要があります。
すべてのコードベースが恩恵を受けるわけではない
主にグルーコード、生成コード(protobufクラス、ORMモデル)、または薄いI/Oラッパーで構成されるコードは、ミューテーションテストにあまり働くものがありません。その種のコードに対するミューテーションは、些細に検出されるか、そもそも意味がありません。この手法は、動作に分岐、算術、比較、明示的な状態変更が関わるコード、つまり人間が「ビジネスロジック」と表現する種類のコードで効果を発揮します。
100%のミューテーションスコアは正しさを意味しない
100%のミューテーションスコアは、ツールが生成方法を知っていたすべてのミューテーションが少なくとも1つのテストによってkilledされたことを意味します。コードには、オペレーターセットがたまたま表現しない形状のバグがまだ存在する可能性があります。ツールが正しい値にミューテートしなかった間違った定数、または単一のミューテーションとして表現できない欠けているエッジケースです。ミューテーションテストはテストスイートを強化します。スイートが検証する必要があることについての人間の判断に取って代わるものではありません。
チームがミューテーションテストを使い始めるべきタイミング
コードベースの単一の部分で、診断モードで小さく始めましょう。ミューテーションテストは、コストが実際に恩恵を受けるコードにかかり、チームがレポートが何を伝えようとしているのかについての直感を養う時間がある場合に、徐々に導入されると効果を発揮します。
- 1つの重要なモジュールから始める。 ハイステークスのモジュール(支払いと認可が一般的な出発点)を選択し、そこでのみミューテーションテストを実行します。200行のモジュールは通常数百のミューテーションを生成し、スケジューリングなしでローカルで実行できる程度に小さくなります。
- survivedミューテーションをPRレビューのプロンプトとして扱う。 プルリクエストが選択したモジュールに触れる場合、survivedミューテーションのリストは「このコードに欠けているテスト」のチェックリストになります。レビュアーは「より多くのテストが必要」と漠然と言うのではなく、それぞれについて具体的に尋ねることができます。
- 最初は診断、後でゲート。 最初の数週間は閾値なしでミューテーションテストを実行します。チームが数値を読み、明らかなギャップを閉じるまで、数値はノイズが多いです。
--break-at(または同等物)を追加するのは、スコアが安定し、チームが何を回帰とみなすかに合意した後でのみ意味があります。 - カバレッジを並行して実行し続ける。 ミューテーションテストはカバレッジを補完するものであり、置き換えるものではありません。カバレッジはすべてのコミットで高速なファーストパスのチェックとして有用であり続けます。ミューテーションジョブは、より遅い周期でより難しい質問に答えます。
- 100%を追いかけない。 100%のスコアは努力に見合うことはほとんどありません。最後の数パーセントは通常、equivalentミューテーションまたは実際のビジネスリスクを伴わないコードに対するミューテーションだからです。ハイリスクモジュールでの60–80%の下限が現実的な目標です。
妥当なロールアウトには数日ではなく数週間かかります。選択したモジュールが安定したミューテーションカバレッジを持ち、チームがsurvivedミューテーションに対応することに慣れたら、同じプロファイルに適合する次のモジュールに拡大します。
AIコーディングエージェントによるギャップの解消
ミューテーションテストは、ミューテーションごとに完全なスイートを実行するコンピュートコストが法外であったため、以前は本番チームにとって非現実的でした。現代のハードウェアとCI/CD並列化がその部分を解決しました。2つ目のコスト、レポートが欠けているテストに名前を付けた後に実際にそれを書くことは、歴史的に人間に委ねられてきました。
AIコーディングエージェントはこの2つ目のコストも削減します。コードの場所と変換が添付されたsurvivedミューテーションのリストが与えられると、エージェントは欠けているアサーションの下書きを作成し、ミューテーションがkilledされるまで反復できます。
ChunkはCircleCIの自律型CI/CDエージェントで、ミューテーションジョブと同じレイヤーで実行され、プロジェクトのビルド履歴とテスト結果にアクセスできます。ChunkのExtend Test Coverageプリセットは、未テストのコードを特定し、既存のスイートに対して検証された新しいテストを追加するプルリクエストを開きます。
同じワークフロー(分析、生成、検証、PRを開く)はsurvivedミューテーションにもきれいに適用できます。それらは行カバレッジが見逃したカバレッジギャップであり、CI/CDに埋め込まれたエージェントがコンテキストを持って対応できるものです。
CircleCIでミューテーションテストパイプラインを実行すると、Chunkが分析して修正するためのすべての詳細が保存されます。つまり、ミューテーションテストの結果を実際に実装するのは、プロンプト1つで済むということです。
結論
ミューテーションテストは、カバレッジでは答えられない質問に答えます。テストスイート内のアサーションが実際に欠陥を検出するかどうかです。survivedミューテーションは、コードの場所と変換が添付されたギャップに名前を付け、カバレッジ数値だけよりも有用にします。この手法は高コストですが、適切なCI/CDパイプラインによりコストを実用的な範囲に収められます。
ミューテーションテストがチーム規模で実用的になるのは、パイプラインが数百のミューテーション実行を並列化し、各プルリクエストのミューテーションジョブを変更されたファイルにスコープし、完全なジョブを別スケジュールで実行し、スコアが設定された下限を下回ったときにビルドを失敗させることができる場合です。CircleCIは、すでに動作するテストスイートを持つ任意のプロジェクトに、そのパイプライン構造を提供し、並列性、スケジューリング、閾値ゲーティングを組み込んでいます。
構築を開始するには、無料のCircleCIアカウントにサインアップし、プロジェクトの既存のパイプラインにミューテーションジョブを追加してください。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.