Mutation Testing サーベイ — Go, Rust, Python, TypeScript の主要ツール比較
TL;DR
- Mutation testingはコードに小さな変異(mutant)を注入し、テストがそれを検出できるかを検証する手法。コードカバレッジでは測れない「テストの質」を定量化できる
- 各言語の推奨ツール: Python → mutmut, TypeScript → StrykerJS, Rust → cargo-mutants, Go → Gremlins
- すべてのツールが差分ベースの実行量の削減に対応している(またはワークアラウンドがある)。CIに組み込む際は差分ベースの実行が実質必須
- 言語の型システムの強さがmutation testingの効率に直結する。Rustのような強い型システムでは無効なmutantが型チェックで自動排除され、実行時間が短くなる
ツール一覧と差分実行・並列実行の対応状況
| ツール | 言語 | 差分実行 | 並列実行 |
|---|---|---|---|
| Python | ○ 組み込みインクリメンタル + --paths-to-mutateで対象指定 | × 逐次実行のみ | |
| TypeScript | ○ --incrementalでJSON差分管理 | ○ | |
| Rust | ○ --in-diffでdiffファイル指定 | ○ -jフラグ | |
| Go | △ git diffとの組み合わせでワークアラウンド | ○ |
Mutation Testing とは
コードカバレッジは「テストがどの行を実行したか」を示す指標ですが、テストが実際にバグを検出できるかは別問題です。カバレッジ100%でも、assertionが1つもなければバグは見つかりません。
Mutation testingはこの問題に対するアプローチで、以下のように動作します。
- ソースコードに小さな変異(mutation)を加えたmutantを生成する
- 各mutantに対してテストスイートを実行する
- テストが失敗すればkilled(検出成功)、全テストが通ればsurvived(検出失敗)
最終的に、killed mutantの割合であるMutation Scoreがテストスイートの品質指標となります。
代表的なMutation Operator
| 種類 | 変異前 | 変異後 |
|---|---|---|
| 算術演算子 | a + b | a - b |
| 比較演算子 | a > b | a >= b, a < b |
| 論理演算子 | a && b | a || b |
| 戻り値 | return x | return 0, return "" |
| 条件式 | if (cond) | if (true), if (false) |
| 文の削除 | doSomething() | (削除) |
Equivalent Mutant問題
Mutation testingの根本的な課題として、equivalent mutantがあります。これは変異を加えてもプログラムの外部から観察可能な動作が変わらないmutantのことで、原理的にテストでは検出できません。例えば x = x * 1 を x = x * 0 に変えた場合はkillできますが、 x = x + 0 を x = x - 0 に変えた場合は動作が同一のため検出できません。この問題は決定不能であることが知られており、各ツールはヒューリスティクスで対処しています。
Python: mutmut
はPythonにおける最も広く使われているmutation testingツールです。使いやすさを重視した設計で、pytestとの統合がスムーズに行えます。
セットアップと実行例
pyproject.tomlで設定を記述します。
pyproject.toml
対象となるコードとテストの例を示します。
src/calculator.py
tests/test_calculator.py
mutation testingを実行します。
結果を確認します。
survivedしたmutantがあれば、以下のようにdiffを確認できます。
例えば b == 0 が b == 1 に変異したmutantがsurvivedしていた場合、b=1のケースに対するテストが不足していることがわかります。
テストフレームワーク連携
mutmutはデフォルトでpytestを使用します。unittestや他のランナーを使う場合はrunner設定で指定できます。
pytestとの連携では、カバレッジ情報を活用した最適化が可能です。mutate_only_covered_linesをtrueにすると、テストでカバーされている行のみを変異対象にできます。
pyproject.toml
また、mypyやpyreflyといった型チェッカーとの連携もサポートしています。x: str = 'foo'をx: str = Noneに変異させた場合、型チェッカーが検出できるため無駄な実行を省けます。
差分ベースの実行量の削減
mutmutはインクリメンタル実行を組み込みでサポートしています。
- 実行を途中で停止しても、次回は中断した箇所から再開する
- ソースコードが変更された関数のみを再テストする
- テストスイートが変更された場合、survivedしたmutantのみを再テストする
CIでの利用では、--paths-to-mutateにgit diffで取得した変更ファイルを渡すことで、変更箇所のみを対象にできます。
TypeScript: StrykerJS
はJavaScript/TypeScriptエコシステムにおけるデファクトのmutation testingツールです。活発にメンテナンスされており、v7.0ではVitest対応が追加されました。
セットアップと実行例
設定ファイルを作成します。
stryker.config.json
対象コードとテストの例を示します。
src/calculator.ts
src/calculator.test.ts
実行します。
StrykerはHTMLレポートを生成し、各mutantのstatus(killed/survived/no coverage/compile error)を視覚的に確認できます。
テストフレームワーク連携
StrykerJSは豊富なテストランナープラグインを提供しています。
| ランナー | パッケージ | 備考 |
|---|---|---|
| Vitest | @stryker-mutator/vitest-runner | v7.0で追加。推奨 |
| Jest | @stryker-mutator/jest-runner | React/Next.js向け |
| Mocha | @stryker-mutator/mocha-runner | Node.js向け |
| Karma | @stryker-mutator/karma-runner | Angular向け |
| Node Tap | @stryker-mutator/tap-runner | v7.0で追加 |
TypeScript Checkerプラグインは型エラーとなるmutantを自動的に除外します。例えばnumber型の戻り値を""(空文字列)に変異させた場合、コンパイルエラーとなるため実行がスキップされます。v6.4でパフォーマンスが最大50%改善されました。
差分ベースの実行量の削減(Incremental Mode)
StrykerJSのincremental modeは、mutation testingの実行量を削減する仕組みとして最も洗練されたものの1つです。
動作の流れは以下の通りです。
- 初回は通常通り全mutantをテストし、結果を
reports/stryker-incremental.jsonに保存する - 2回目以降は保存済みのJSONを読み込み、ソースコードとテストファイルのdiffを算出する
- 変更されたコードに関連するmutantのみ再実行し、それ以外は前回の結果を再利用する
- 部分実行でも、全mutantの結果を含むレポートが生成される
CIでの推奨パターンは、incremental JSONファイルをCIアーティファクトとして管理する方法です。
特定のファイルのみを再テストしたい場合は--forceと--mutateを組み合わせます。
テスト変更の検出レベル
incrementalモードにおけるテスト変更検出の精度はランナーによって異なります。
- Jest/Mocha/Cucumberではテストの正確な位置まで追跡でき、変更されたテストのみ再実行できる
- Vitestではテストファイル単位での検出となり、ファイル内の変更があれば全テストが再実行される
- Karmaではテスト名のみの追跡となり、追加・削除のみ検出可能
Rust: cargo-mutants
はRust向けのmutation testingツールで、ゼロコンフィグで動作する点が特徴です。cargo testをそのまま利用するため、既存のテスト環境に追加の設定なしで導入できます。
セットアップと実行例
対象コードの例を示します。
src/lib.rs
実行します。
出力例を示します。
テストフレームワーク連携
cargo-mutantsはcargo testをそのまま実行するため、標準のテストフレームワークはもちろん、#[tokio::test]、#[sqlx::test]などのカスタムテスト属性も自動的に認識します。テスト関数自体は変異対象から除外されます。
nextestを使用している場合は--test-tool=nextestで切り替えが可能です。
Rustの強い型システムはmutation testingにおいて大きな利点となります。例えばOption<T>を返す関数に対して0を返すmutantを生成しても型エラーでコンパイルが通らないため、自動的にスキップされます。これにより、実行が必要なmutant数が他の言語と比較して少なくなる傾向があります。
差分ベースの実行量の削減(--in-diff)
cargo-mutantsは--in-diffオプションでdiffファイルを受け取り、変更された箇所に関連するmutantのみを実行できます。
GitHub Actionsでの利用例を示します。
.github/workflows/mutants.yml
内部的には、cargo-mutantsはソースツリーをスクラッチディレクトリにコピーし、同じディレクトリを全mutantで再利用することでインクリメンタルビルドの恩恵を受けています。
Go: Gremlins
Go向けのmutation testingツールは複数存在しますが、現在最も活発にメンテナンスされているのはです。(およびそのAvito fork)も選択肢ですが、Gremlinsの方が機能面で充実しています。
セットアップと実行例
対象コードの例を示します。
pkg/math/math.go
pkg/math/math_test.go
実行します。
テストフレームワーク連携
Gremlinsはgo testを直接利用します。Goにはサードパーティのテストフレームワーク(testifyなど)がありますが、いずれもgo test経由で実行されるため、追加の設定は不要です。
Gremlinsの特徴として、カバレッジ情報を事前に収集し、テストでカバーされているコードのみを変異対象とします。カバーされていないコードはそもそもテストが存在しないため、mutation testingを行っても意味がないという合理的なアプローチです。
差分ベースの実行量の削減
Gremlins自体にはStrykerJSのようなincrementalモードや、cargo-mutantsのような--in-diffオプションは現時点で組み込まれていません。
v0.6.0で追加された--exclude-filesフラグを活用し、git diffと組み合わせることでワークアラウンドが可能です。
go-mutestingの方はターゲットを引数で柔軟に指定でき、ファイル単位、ディレクトリ単位、パッケージ単位での実行が可能です。
どのプロジェクトで導入すべきか
向いているケース
- ビジネスロジックが集中する小〜中規模モジュール。関数の入出力が明確で、mutation operatorが効果的に働く
- 既にテストカバレッジが高いプロジェクト。カバレッジは高いがテストの質に自信がない場合、mutation testingが具体的な改善箇所を示してくれる
- CIで品質ゲートを設けたい場合。差分ベースの実行でPR単位のチェックが実用的になっている
向かないケース
- テストカバレッジが低いプロジェクト。まずカバレッジを上げる方が費用対効果が高い
- 巨大なモノリス。mutant数が爆発し、実行時間が非現実的になる。モジュール分割してから導入すべき
- UI/E2Eテスト中心のプロジェクト。mutation testingはユニットテスト・インテグレーションテストとの相性が良い
導入時の注意点
- 全mutantのテストは時間がかかる。まずは差分ベースの実行から始め、mainブランチでの定期的なフル実行と組み合わせるのが現実的
- Mutation Scoreは100%を目指す必要はない。equivalent mutantの存在もあり、80%程度を目安にするのが妥当
- 最初からプロジェクト全体に適用せず、重要なモジュールから段階的に始める
まとめ
Mutation testingは「テストの質をテストする」手法であり、4言語ともツールが存在します。ツールの成熟度にはばらつきがあり、StrykerJS(TypeScript)とmutmut(Python)がエコシステムとして最も充実しています。cargo-mutants(Rust)はRustの型システムとの親和性が高く、ゼロコンフィグで始められる手軽さがあります。Go向けのGremlinsはまだ0.x系ですが、カバレッジベースのフィルタリングなど実用的な機能を備えています。
CIへの組み込みにおいては、差分ベースの実行(StrykerJSの--incremental、cargo-mutantsの--in-diff、mutmutの--paths-to-mutate)が実質的に必須です。フル実行はmainブランチでの定期実行に留め、PRでは変更箇所のみを対象にするのが実用的なアプローチです。