テストは通る。コードは間違っている。

100%のline coverageがある。すべての分岐が実行されている。すべての関数が呼ばれている。それなのに、誰かが価格ロジックの+-に変えてテストを実行しても、すべて通ってしまう。

これは仮想の問題ではない。テストがコードを実行したが、実際には振る舞いを検証していないときに起きることだ。Coverageはどの行が実行されたかを測定し、どの出力が検査されたかは測定しない。Mutation testingは、意図的に小さなバグを導入し、テストがそれを捉えるか確認することで、その隙間を埋める。

Rustチームにとっての問題は、mutation testingが良いアイデアかどうかではない。エコシステムで支配的なツールであるcargo-mutantsが、Rustのコンパイル時間とtype systemを考慮すると実用的かどうかだ。答えはyesだが、重要な注意点がある。

Mutation Testingは実際に何をするのか

Mutation testingの概念はシンプルだ。ツールがソースコードに小さな変更を加え、テストスイートを実行し、何か失敗するか確認する。

テストスイートが失敗すれば、mutantは「killed」される。それが望ましい状態だ。テストがバグに気づいたということだ。

テストスイートが通れば、mutantは「survives」する。つまりテストがmutatedコードを実行し、何も間違いに気づかなかったということだ。弱いテストがあるということだ。

一般的なmutationには、算術演算子の置き換え(+-になる)、比較演算子の入れ替え(>>=になる)、論理値リテラルの置き換え(truefalseになる)、戻り値を返す関数呼び出しの削除などがある。それぞれの変更は、人間がバグだと認識できる程度に小さい。テストスイートもそれを認識すべきだ。

cargo-mutantsがRustコードでどう動作するか

cargo-mutantsはRust専用に構築されたmutation testingツールだ。テストにannotationを付ける必要も、ビルドシステムを変更する必要もない。インストールして実行するだけだ。

cargo install cargo-mutants
cargo mutants

ツールはソースファイルをスキャンし、ASTにtransformation rulesを適用してmutantを生成し、それぞれに対してcargo testを実行する。どのmutantがsurviveしたかを追跡し、レポートを出力する。

見た目は堅牢だが実際にはそうでないテストを持つ関数がある:

pub fn apply_discount(price: f64, rate: f64) -> f64 {
    price * (1.0 - rate)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_apply_discount() {
        let result = apply_discount(100.0, 0.2);
        // We ran the function. Coverage is 100%.
        // But we never asserted the result.
    }
}

cargo mutantsは、*/に変更するか、1.0 - rate1.0 + rateに置き換えるmutantを生成する。テストはresultを確認しないため、通ってしまう。Surviveしたmutantが問題を示す。

Mutantをkillする本物のテストはこうだ:

#[test]
fn test_apply_discount() {
    assert_eq!(apply_discount(100.0, 0.2), 80.0);
    assert_eq!(apply_discount(50.0, 0.0), 50.0);
}

これですべての算術mutantが失敗する。assertionsが誤った出力を捉えるからだ。

出力はどう見えるか

cargo mutantsを実行すると、サマリーが得られる:

Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants

Missed mutantはsurviveしたものだ。cargo mutantsはそれぞれをdiffとファイルパスと共にmutants.out/に書き出す。diffを読んで、欠落しているassertionを追加する。

Timeoutは、mutantがinfinite loopを引き起こしたときに発生する。cargo-mutantsはこれを検出し、timeoutによってkilledとマークする。これは成功としてカウントされる。

Unviable mutantはコンパイルできない変更だ。Rustのtype systemが、テストが実行される前にそれらを拒否する。

RustのType Systemは諸刃の剣だ

JavaScriptやPythonでは、mutation testingツールはほぼあらゆる演算子を置き換えてもコードは実行される。ただ結果が間違うだけだ。Rustでは、多くのmutationはコンパイラによってテストが実行される前に捕捉される。

符号なし整数で+-に置き換えると、overflowになるかもしれないが、コードはコンパイルする。Generic contextで><に置き換えると、コンパイラはtrait boundsが比較をサポートしていなければ拒否する。呼び出し元が期待する値を返す関数呼び出しを削除すると、コンパイルエラーになる。

これは、cargo-mutantsが他の言語の同等のツールよりも少ないviable mutantを生成することを意味する。Pythonプロジェクトでは1モジュールあたり200のmutantが出るかもしれない。Rustプロジェクトでは40かもしれない。コンパイルするmutantは、実際に本番に混入しうるものだ。Type systemがノイズを除去する。

引き換えに払う代償はコンパイル時間だ。すべてのviable mutantはrebuildを引き起こす。5分のテストスイートを持つプロジェクトは、cargo mutantsの実行に1時間かかるかもしれない。

コンパイル時間の税は本物だ

これがチームが躊躇する主な理由だ。Mutation testingは理論上は恥ずかしげもなく並列化できる。それぞれのmutantは独立している。実際には、Rustのビルドシステムは同じソースツリー上での数十のコンパイラ呼び出しをきれいに並列化しない。

cargo-mutantsには--jobsフラグがあるが、ディスクI/Oとcrate graphのロックがボトルネックになる。2コアの典型的なCIランナーでは、ジョブはうまくスケールしない。

これを軽減できる。--in-placeを使って、すべてのmutantに対してソースツリーをコピーするのを避ける。--file--excludeを使って特定のモジュールを対象にする。Mutation testingを毎回のpushではなく、毎晩または毎週実行する。

cargo-mutantsが見逃すもの

どのmutation testingツールもすべてを捉えることはできない。cargo-mutantsには知っておくべき具体的な制限がある。

マクロ展開をmutateしない。重要なロジックがマクロの内側にある場合、ツールはinvocationを見るが、生成されたコードは見ない。

意味的等価性を理解しない。一部のmutantは、すべての有効な入力に対して正しいままの異なる振る舞いを生成する。冗長な+ 0は、テストが気にしないためsurviveするかもしれない。mutationが実際のバグでなくてもだ。これらは手動でtriageする必要がある。

Mutation Testingがコストに見合う価値があるとき

すべてのコミットでcargo mutantsを実行する必要はない。テストスイートが大きくなって、自分のassertionsを信じられなくなったときに必要だ。

重要なモジュールが高いcoverageを持っているのに、それでもバグを出荷してしまったとき、あるいはrefactorが微妙な方法でロジックを変更し、assertionsが厳密であることに自信を持ちたいときに実行する。

テストスイートがすでにflakyであるとき、あるいはコンパイル時間がみんなが文句を言うボトルネックであるときは実行しない。まず根本的な問題を修正する。

パイプラインを壊さずにCIに追加する

実用的なセットアップは、すべてのpull requestでのゲートではなく、スケジュールされたジョブだ。

週次で実行するGitHub Actionsワークフローはこうだ:

name: Mutation Testing

on:
  schedule:
    - cron: "0 3 * * 1"
  workflow_dispatch:

jobs:
  mutants:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install cargo-mutants
        run: cargo install cargo-mutants
      - name: Run mutation testing
        run: cargo mutants --in-place
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mutants-report
          path: mutants.out/

--in-placeフラグはディスク使用量を抑える。rust-cacheは初期ビルド時間を短縮する。スケジュールされたトリガーは開発者の作業を妨げない。レポートをartifactとしてアップロードして、CIログをスクロールすることなくsurviving mutantをレビューできるようにする。

1つのモジュールから始めろ

コードベース全体をmutateする必要はない。ビジネスクリティカルなロジックを持ち、バグの歴史がある1つのモジュールを選ぶ。cargo mutants --file src/pricing.rsを実行する。レポートを読む。最も弱いテストを修正する。

最初の実行は常に最悪だ。コードを実行するが何もassertしないテストが見つかる。分岐結果を確認しないテストによってカバーされている分岐が見つかる。それらのテストがどうして十分だと感じられたのか疑問に思うだろう。

それがポイントだ。Mutation testingはコードのバグを見つけるのではない。テストのバグを見つけるのだ。コンパイラが明らかなミスをすでに捉えるRustにおいて、それがまさに必要とするフィードバックループだ。


よくある質問

Mutation testingとは何か

Mutation testingは、ソースコードに小さな意図的なバグを導入することでテストスイートを評価する。テストが失敗すれば、mutantは「killed」される。テストが通れば、mutantは「survives」し、隙間があるということだ。

Mutation testingはコードカバレッジとどう違うのか

カバレッジはどの行が実行されたかを測定する。Mutation testingは、それらの行から誤った出力が出た場合にテストが検出するかを測定する。テストは100%のcoverageを持ち、ゼロのmutantを捉えることもありうる。

すべてのRustプロジェクトでmutation testingは遅いのか

コストはコンパイル時間とテスト数に比例して増大する。小さなライブラリでは数分で終わる。大きなworkspace projectはかなり長くかかる。--file--excludeを使って、実行を特定のモジュールに絞る。

False positive mutantを無視できるか

はい。cargo-mutantsmutants.toml設定ファイルをサポートし、ファイル、関数、または特定のmutationタイプを除外できる。本物のテストの隙間を隠さないよう、控えめに使うこと。