はじめに
テストは全部通っている。コードカバレッジも90%を超えている。なのに、本番環境でバグが見つかった。
私が実際に経験したことだ。原因を調べると、テストコードにassert(検証)が書かれていなかった。テストは「コードを実行しただけ」で、結果が正しいかどうかを確認していなかったのだ。正直、恥ずかしかった。テストを書いている気になっていただけで、何も守っていなかった。
こういう経験はないだろうか。あるいは、レビューで「このテスト、意味ありますか」と指摘されたことは。
この記事では、こうした「見せかけのテスト」を発見するミューテーションテストという手法と、Rust向けのツールcargo-mutantsを紹介します。
公式ドキュメントを参照する場合は、以下のリンクからどうぞ。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。
ミューテーションテストとは
ミューテーションテストは、「テストをテストする」手法です。具体的なコードで説明しましょう。
例:割引価格を計算する関数
以下のような、商品価格から10%割引した金額を返す関数があるとします。
/// 価格から10%割引した金額を返す fn apply_discount(price: u32) -> u32 { price - (price / 10) }
この関数に対して、以下のテストを書きました。
#[test] fn test_apply_discount() { let result = apply_discount(1000); // 1000円の10%引きは900円のはず... // でも、assertを書き忘れた! }
このテストには問題があります。apply_discount(1000)を呼び出していますが、結果が900であることを検証していません。コードカバレッジは100%ですが、このテストは何も守っていないのです。
ミュータント(突然変異体)の生成
ミューテーションテストでは、コードに「わざとバグを入れた」バージョンを作ります。これをミュータント(突然変異体)と呼びます。
apply_discount関数に対して、以下のようなミュータントが生成されます。
// ミュータント1: 引き算を足し算に変える fn apply_discount(price: u32) -> u32 { price + (price / 10) // - を + に変更 } // ミュータント2: 常に0を返す fn apply_discount(price: u32) -> u32 { 0 // 関数の本体を0に置き換え } // ミュータント3: 入力をそのまま返す fn apply_discount(price: u32) -> u32 { price // 割引計算を削除 }
テストがミュータントを検出できるか
各ミュータントに対してテストを実行します。
| ミュータント | 変更内容 | テスト結果 | 判定 |
|---|---|---|---|
| ミュータント1 | - → + |
✅ 成功(テストが通る) | ❌ missed |
| ミュータント2 | 常に0を返す | ✅ 成功(テストが通る) | ❌ missed |
| ミュータント3 | 割引なし | ✅ 成功(テストが通る) | ❌ missed |
すべてのミュータントがテストを通過してしまいました。これはテストが何も検証していないことの証拠です。
テストを修正する
テストにassert_eq!を追加して、結果を検証するようにします。
#[test] fn test_apply_discount() { let result = apply_discount(1000); assert_eq!(result, 900); // 結果が900であることを検証 }
修正後、再度ミュータントをテストします。
| ミュータント | 変更内容 | テスト結果 | 判定 |
|---|---|---|---|
| ミュータント1 | - → + |
❌ 失敗(1100 ≠ 900) | ✅ caught |
| ミュータント2 | 常に0を返す | ❌ 失敗(0 ≠ 900) | ✅ caught |
| ミュータント3 | 割引なし | ❌ 失敗(1000 ≠ 900) | ✅ caught |
すべてのミュータントが検出されました。これで「テストが正しく機能している」ことが確認できました。
ミューテーションテストの核心
ここまでの例で分かるように、ミューテーションテストは以下の逆説に基づいています。
テストの成功が、失敗の証拠になる。
コードを壊したのにテストが通るなら、そのテストは壊れたコードを見逃している——つまり、テストとして機能していません。
cargo-mutantsとは
cargo-mutantsは、Rust向けのミューテーションテストツールです。上記のような「ミュータントの生成」「テストの実行」「結果の集計」を自動で行います。
Rustを使っている開発者なら、cargo install cargo-mutants && cargo mutantsの2コマンドで即座に試せます。ソースコードの変更は一切不要です。Rustを使っていない方も、「テストの品質をどう測るか」という観点でお読みいただければ、他の言語にも応用できる考え方が得られるはずです。
いつ導入すべきか
ミューテーションテストは誰でも試せますが、すべてのプロジェクトに必要なわけではありません。正直に言えば、導入コストは低くない。
特に有効なのは、カバレッジは80%以上あるのにバグが減らないケースです。金融計算のように正確性が重要なビジネスロジックや、チームにテストの質を意識させたい場面でも効果を発揮します。私自身、冒頭で触れた経験をした後、まずこのツールで「テストが本当に機能しているか」を確認するようになりました。
一方、まだカバレッジが50%未満のプロジェクトでは、まずカバレッジを上げる方が効果的です。プロトタイプ段階で変更が激しい場合や、テスト実行時間がすでに長すぎる場合も、ミューテーションテストの優先度は下がります。ツールが問題を解決してくれるわけではない。テストを書くのは人間です。
クイックスタート
インストール
# 推奨: cargoで直接インストール cargo install --locked cargo-mutants # 高速インストール(プリビルドバイナリ使用) cargo binstall cargo-mutants
基本的な使い方
# ミュータント一覧を確認(テストは実行しない) cargo mutants --list # ミューテーションテストを実行 cargo mutants # 詳細出力で実行 cargo mutants -v
実行例
実際にサンプルプロジェクトで実行した結果を示します。
$ cargo mutants --list | head -20 src/lib.rs:12:5: replace calculate_score -> i32 with 0 src/lib.rs:12:5: replace calculate_score -> i32 with 1 src/lib.rs:12:5: replace calculate_score -> i32 with -1 src/lib.rs:32:5: replace is_valid_email -> bool with true src/lib.rs:32:5: replace is_valid_email -> bool with false src/lib.rs:37:5: replace format_greeting -> String with String::new() src/lib.rs:37:5: replace format_greeting -> String with "xyzzy".into() src/lib.rs:42:5: replace find_first_even -> Option<i32> with None src/lib.rs:42:5: replace find_first_even -> Option<i32> with Some(0) src/lib.rs:47:5: replace parse_positive_number -> Result<u32, String> with Ok(0) src/lib.rs:57:5: replace get_even_numbers -> Vec<i32> with vec![] ...
実行すると、各ミュータントに対してテストが実行され、結果が表示されます。
$ cargo mutants -v Found 108 mutants to test ok Unmutated baseline in 1s build + 1s test caught src/lib.rs:12:5: replace calculate_score -> i32 with 0 in 0s build + 0s test caught src/lib.rs:12:5: replace calculate_score -> i32 with 1 in 0s build + 0s test MISSED src/lib.rs:155:9: delete match arm 1 in calculate_discount in 0s build + 1s test ... 108 mutants tested in 2m: 17 missed, 91 caught
出力結果の読み方
結果の4分類
| 結果 | 意味 | アクション |
|---|---|---|
| caught | テストがミュータントを検出した | 良好。テストが機能している |
| missed | テストがミュータントを検出できなかった | テストの追加・強化が必要 |
| unviable | ミュータントがコンパイルできなかった | 無視してOK |
| timeout | テストがタイムアウトした | 無限ループの可能性あり |
出力ディレクトリ(mutants.out/)
実行後に生成されるmutants.out/ディレクトリには、詳細な結果が保存されます。
mutants.out/ ├── caught.txt # 検出されたミュータント一覧 ├── missed.txt # 検出できなかったミュータント一覧 ├── timeout.txt # タイムアウトしたミュータント ├── unviable.txt # コンパイル不可だったミュータント ├── outcomes.json # 全結果のJSON形式 ├── log/ # 各ミュータントの詳細ログ └── diff/ # 適用されたパッチ
ミューテーションテストの仕組み
ミューテーションテストは1970年代に考案された手法ですが、計算コストの高さから長らく実用的ではありませんでした。近年のコンピュータ性能向上により、ようやく日常的に使えるようになってきました。
cargo-mutantsの動作フロー
cargo-mutantsは以下の手順で動作します。
- ソースファイルの特定: プロジェクト構成を読み取り、テスト対象のファイルを見つける
- コードの解析:
synというライブラリ(Rustでは「クレート」と呼びます)を使って、コードの構造を解析する - ミュータントの生成: 「足し算を引き算に変える」「戻り値を0に変える」といった変更パターンを列挙する
- テストの実行: 各ミュータントに対してテストを実行し、検出できたかどうかを記録する
具体例:検証していないテスト
コードカバレッジとミューテーションテストの違いを、具体例で見てみましょう。
// 2つの数を足し算する関数 fn add(a: i32, b: i32) -> i32 { a + b } // テストコード #[test] fn test_add() { add(1, 2); // 関数を呼んでいるだけ!結果を検証していない! }
このテストはadd関数を実行しているので、コードカバレッジは100%です。しかし、戻り値が正しいかどうかを確認していません。add(1, 2)の結果が3であることを検証していないのです。
正しいテストは以下のようになります。
#[test] fn test_add_correct() { let result = add(1, 2); assert_eq!(result, 3); // 結果が3であることを検証している }
assert_eq!は「左辺と右辺が等しいことを確認する」という意味です。等しくなければテストは失敗します。
cargo-mutantsは、最初の「検証していないテスト」の問題を発見できます。a + bをa - bに変更しても、最初のテストは成功してしまいます(結果を見ていないから)。これにより「このテストは意味がない」ということが明らかになります。
戻り値の型別ミューテーション
cargo-mutantsは、関数の戻り値の型に応じて異なるミューテーションを生成します。
「型」とは何でしょうか。プログラミングにおいて、データには種類があります。「整数」「文字列」「真偽値(はい/いいえ)」などです。Rustはこの種類を厳密に区別する言語で、「この関数は整数を返す」「この関数は文字列を返す」といった宣言が必要です。cargo-mutantsは、この「返す型」に応じて、適切なミュータントを生成します。
以下、Rustを知らない方にも理解できるよう、各型の意味と合わせて説明します。
bool型(真偽値)
bool型とは: true(真)かfalse(偽)のどちらかを表す型です。条件分岐の判定などに使われます。
/// メールアドレスが有効かどうかを判定する fn is_valid_email(email: &str) -> bool { email.contains('@') && email.contains('.') }
生成されるミューテーション:
replace is_valid_email -> bool with true- 常にtrueを返すreplace is_valid_email -> bool with false- 常にfalseを返す
テストで検出すべきこと: 有効なメールと無効なメールの両方をテストして、両方のケースが正しく判定されることを確認する必要があります。
i32型(符号付き整数)
i32型とは: -2,147,483,648から2,147,483,647までの整数を表す型です。負の数も扱えます。
/// スコアを計算する(1=合格、0=普通、-1=不合格) fn calculate_score(correct: u32, total: u32) -> i32 { let percentage = (correct * 100) / total; if percentage >= 80 { 1 } else if percentage >= 50 { 0 } else { -1 } }
生成されるミューテーション:
replace calculate_score -> i32 with 0- 常に0を返すreplace calculate_score -> i32 with 1- 常に1を返すreplace calculate_score -> i32 with -1- 常に-1を返す
テストで検出すべきこと: 各分岐(合格・普通・不合格)すべてのケースをテストする必要があります。
String型(文字列)
String型とは: 可変長のテキストデータを表す型です。ユーザー名やメッセージなどに使われます。
/// 挨拶文を生成する fn format_greeting(name: &str) -> String { format!("Hello, {}!", name) }
生成されるミューテーション:
replace format_greeting -> String with String::new()- 空文字列を返すreplace format_greeting -> String with "xyzzy".into()- 固定文字列「xyzzy」を返す(「xyzzy」はテスト用のダミー文字列としてよく使われる伝統的な文字列です)
テストで検出すべきこと: 戻り値の内容を検証することが重要です。単に「何か文字列が返ってくる」だけでなく、期待する内容かどうかを確認します。
Option\<T>型(値があるかないか)
Option型とは: 値が「ある」か「ない」かを表す型です。Some(値)で値があることを、Noneで値がないことを表します。
なぜこの表現を使うのか。多くの言語では「値がない」ことをnullで表しますが、null処理を忘れてエラーになることがよくあります。Rustでは「値がないかもしれない」ことを型で明示し、処理を強制します。これにより、nullに起因するバグを防ぎます。
検索結果が見つからない場合などによく使われます。
/// 最初の偶数を見つける fn find_first_even(numbers: &[i32]) -> Option<i32> { numbers.iter().find(|&&n| n % 2 == 0).copied() }
生成されるミューテーション:
replace find_first_even -> Option<i32> with None- 常に「見つからない」を返すreplace find_first_even -> Option<i32> with Some(0)- 常に「0が見つかった」を返すreplace find_first_even -> Option<i32> with Some(1)- 常に「1が見つかった」を返す
テストで検出すべきこと: 「見つかる場合」と「見つからない場合」の両方をテストし、見つかった場合は正しい値が返されていることを確認します。
Result\<T, E>型(成功か失敗か)
Result型とは: 処理が「成功」したか「失敗」したかを表す型です。Ok(値)で成功を、Err(エラー)で失敗を表します。ファイル操作やネットワーク通信など、失敗する可能性のある処理に使われます。
/// 正の数をパースする fn parse_positive_number(s: &str) -> Result<u32, String> { let n: i32 = s.parse().map_err(|_| "invalid number".to_string())?; if n > 0 { Ok(n as u32) } else { Err("number must be positive".to_string()) } }
生成されるミューテーション:
replace parse_positive_number -> Result<u32, String> with Ok(0)- 常に「成功(0)」を返すreplace parse_positive_number -> Result<u32, String> with Ok(1)- 常に「成功(1)」を返す
テストで検出すべきこと: 成功ケースと失敗ケースの両方をテストします。特にエラーハンドリングのテストを忘れがちなので注意が必要です。
Vec\<T>型(配列・リスト)
Vec型とは: 同じ型の値を複数格納できる可変長の配列です。リストやコレクションを扱う場合に使われます。
/// 偶数だけを抽出する fn get_even_numbers(numbers: &[i32]) -> Vec<i32> { numbers.iter().filter(|&&n| n % 2 == 0).copied().collect() }
生成されるミューテーション:
replace get_even_numbers -> Vec<i32> with vec![]- 空の配列を返すreplace get_even_numbers -> Vec<i32> with vec![0]- 要素1つの配列を返すreplace get_even_numbers -> Vec<i32> with vec![1]- 要素1つの配列を返す
テストで検出すべきこと: 返される配列の要素数と内容の両方を検証します。空配列が返されるケースもテストすることが重要です。
演算子のミューテーション
cargo-mutantsは、演算子を別の演算子に置き換えるミューテーションも生成します。
比較演算子
== ↔ != 等しい ↔ 等しくない < ↔ > 小さい ↔ 大きい <= ↔ >= 以下 ↔ 以上
論理演算子
&& ↔ || かつ ↔ または
算術演算子
+ ↔ - ↔ * 足し算 ↔ 引き算 ↔ 掛け算 / ↔ % 割り算 ↔ 余り
単項演算子
-a → a 符号反転を削除 !a → a 論理否定を削除
テスト不足の発見例
実際にサンプルプロジェクトで検出された「missed」(テストで検出できなかったミュータント)を見てみましょう。
MISSED src/lib.rs:155:9: delete match arm 1 in calculate_discount MISSED src/lib.rs:156:9: delete match arm 2 in calculate_discount MISSED src/lib.rs:155:20: replace - with + in calculate_discount ...
これは以下のコードに対するミューテーションです。
fn calculate_discount(price: u32, member_level: u32) -> u32 { match member_level { 0 => price, // 割引なし 1 => price - (price / 10), // 10% 割引 2 => price - (price / 5), // 20% 割引 _ => price - (price / 4), // 25% 割引 } } #[test] fn test_calculate_discount_weak() { // member_level 0 のみテスト → 他のケースの変異を検出できない! assert_eq!(calculate_discount(100, 0), 100); }
テストがmember_level = 0のケースしかカバーしていないため、他のケース(1, 2, _)のミューテーションは検出できませんでした。これを修正するには、すべてのケースをテストする必要があります。
#[test] fn test_calculate_discount_comprehensive() { assert_eq!(calculate_discount(100, 0), 100); // 割引なし assert_eq!(calculate_discount(100, 1), 90); // 10% 割引 assert_eq!(calculate_discount(100, 2), 80); // 20% 割引 assert_eq!(calculate_discount(100, 3), 75); // 25% 割引 }
設定とカスタマイズ
コマンドラインオプション
# ファイル指定 cargo mutants -f src/core.rs -f src/utils.rs # ファイル除外 cargo mutants -e src/generated/*.rs # 正規表現でフィルタ cargo mutants --re "impl Serialize" --exclude-re "impl Debug" # 並列実行(2-3から開始推奨) cargo mutants -j2 # nextestを使用 cargo mutants --test-tool=nextest # タイムアウト設定 cargo mutants --timeout 300 cargo mutants --timeout-multiplier 3
設定ファイル(.cargo/mutants.toml)
プロジェクト固有の設定を永続化できます。
# .cargo/mutants.toml test_tool = "nextest" jobs = 2 timeout_multiplier = 3.0 exclude_globs = ["src/generated/*.rs"] exclude_re = ["impl Debug", "impl Display"] additional_cargo_test_args = ["--all-targets"]
関数単位の除外(#[mutants::skip])
特定の関数をミューテーション対象から除外できます。
// Cargo.tomlに追加: mutants = "0.0.3" #[mutants::skip] // この関数はミューテーション対象外 fn should_stop() -> bool { true // falseに変異するとハングする }
自動除外される関数
以下は自動的にミューテーション対象から除外されます。
#[test]属性が付いた関数#[cfg(test)]内のコードnew関数とDefault実装
CI/CDパイプラインへの統合
GitHub Actions基本設定
name: Mutation Testing on: [push, pull_request] jobs: cargo-mutants: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: taiki-e/install-action@v2 with: tool: cargo-mutants - run: cargo mutants -vV --in-place - uses: actions/upload-artifact@v4 if: always() with: name: mutants-out path: mutants.out
プルリクエストでの増分テスト
変更されたコードのみをテストし、高速なフィードバックを実現します。
- name: Generate diff run: git diff origin/${{ github.base_ref }}.. | tee git.diff - run: cargo mutants --no-shuffle -vV --in-diff git.diff
シャーディングによる分散実行
大規模プロジェクトでは、複数のジョブに分割して並列実行できます。
strategy: matrix: shard: [0, 1, 2, 3, 4, 5, 6, 7] steps: - run: cargo mutants --shard ${{ matrix.shard }}/8 --baseline=skip --timeout 300
パフォーマンス最適化
ミューテーションテストは「ミュータント数 × テスト実行時間」のコストがかかります。100個のミュータントがあり、テストに1秒かかるなら、最低でも100秒かかる計算です。実際のプロジェクトでは数百〜数千のミュータントが生成されることもあり、実行時間が課題になります。
テストスイートが1分以内のプロジェクトなら、数百ミュータントでも10-20分で完了します。CIで毎回実行するのは現実的でないので、増分テスト(--in-diff)で変更されたコードのみをテストし、フルテストを週次やリリース前に限定するのが実践的です。
以下の最適化も効果的です。
高速リンカーの使用
「リンカー」とは、コンパイルされたコードを実行可能なプログラムにまとめるツールです。プログラムを作る最終段階で使われます。デフォルトのリンカーは汎用的ですが、高速化に特化したリンカーを使うとビルド時間を短縮できます。
Moldリンカーで約20%の改善、Wildリンカーでは半分以下の時間になる場合もあります。
専用Cargoプロファイル
[profile.mutants] inherits = "test" debug = "none"
並列実行の設定
-j2から開始して、リソース監視しながら調整します。高すぎる値はメモリ枯渇の原因になります。
RAMディスクの活用
TMPDIR=/ram cargo mutants
制限事項
副作用のあるコード
cargo-mutantsは機械生成された変更でコードをビルド・実行するため、ファイル操作や外部システムへ接続するテストでは予期しない動作を引き起こす可能性があります。
フレーキーテスト
「フレーキーテスト」とは、同じコードに対して実行するたびに結果が変わる不安定なテストのことです。たとえば、現在時刻に依存するテストや、外部サービスに依存するテストがこれに該当します。
ミューテーションテストは「テストが失敗したか」を判定基準にするため、フレーキーテストがあると正確な結果が得られません。まずはcargo testで確実にパスする安定したテストスイートを用意してから実行してください。
サポートされていないケース
| 制限事項 | 詳細 |
|---|---|
| Cargo専用 | Bazel等の他ビルドシステムは未対応 |
| 条件付きコンパイル | #[cfg(target_os = "linux")]を理解しない |
| マクロ生成コード | 生成されたコードは変異対象外 |
等価ミュータント
ミューテーションテストには理論的な限界があります。それが「等価ミュータント」です。
たとえば、x * 1をxに変えても動作は同じです。このミュータントは検出不可能ですが、missedとしてカウントされます。また、ログ出力やデバッグ用の関数を変更しても、テストが失敗しないのは正しい動作です。
だから、missed率0%は現実的な目標ではない。80-90%の検出率で十分です。残りをコードレビューや手動テストで補完します。検出できないミュータントを#[mutants::skip]で除外すれば、ノイズを減らせます。
まとめ
テストは通っていた。でも、何も守っていなかった。
冒頭で触れた私の失敗は「テストが結果を検証していない」ことが原因でした。cargo-mutantsは、こうした「見せかけのテスト」を発見するツールです。あの経験がなければ、この記事を書くこともなかったでしょう。
ミューテーションテストの価値
コードカバレッジは「テストがコードを実行したか」を測りますが、「テストが正しく検証しているか」は測れません。ミューテーションテストは「テストをテストする」手法です。わざとコードを壊して、テストがそれを検出できるかを確認します。
cargo-mutantsは、Rustのミューテーションテストを「誰でもすぐに試せる」ものにしたツールです。2コマンドで導入でき、ソースコードの変更は不要です。
特に有効なユースケース
- 高いコードカバレッジを達成した後の「テストは本当に機能しているか」確認
- CI(継続的インテグレーション)でのプルリクエストごとの増分ミューテーションテスト
- 重要なビジネスロジックのテストギャップ発見
導入のポイント
cargo mutants --listでミュータント数を確認--shard 1/100で試験実行(大規模プロジェクトでは一部だけ先に試す)#[mutants::skip]と設定ファイルで偽陽性を減らす- Moldリンカーと専用プロファイルでパフォーマンス最適化
他の言語でのミューテーションテスト
この記事ではRust用のcargo-mutantsを紹介しましたが、ミューテーションテストの考え方は言語を問わず有効です。他の言語にも同様のツールがあります。
- JavaScript/TypeScript: Stryker
- Java: PITest
- Python: mutmut, cosmic-ray
- Go: go-mutesting
テストの品質を高めたいと考えている方は、ぜひお使いの言語のツールも調べてみてください。
テストは通っている。でも、本当に守っているのか。
ミューテーションテストは万能ではない。実行時間もかかるし、等価ミュータントの問題もある。それでも、「テストを書いた」という自己満足に気づかせてくれる。私があの日気づいたように。
その問いを持ち続けることが、テストを意味のあるものにする第一歩だと思う。