mutation testing(変異テスト)── カバレッジでは見えないテストの穴を探す

※この記事は「エムティーアイ Blog Summer 2026」の 6/10 分の記事です。

こんにちは!AIオーケストレーションエンジニア部の原です!

今回は「mutation testing(変異テスト)」という手法を紹介します。ざっくり言うと「テストコードがちゃんと仕事をしているかを検証する」という考え方です。実際に手を動かして試してみました。

本記事では、まず mutation testing とは何かを整理します。そのうえで C#(Stryker.NET)で実際に動かし、「カバレッジ100%なのにテストに穴がある」状態を自分の目で確認していきます。

もくじ

テストの「正しさ」は誰が保証するのか

まず「テスト=コードを検証する」と考えている人が多いと思います。 では、そのテスト自体が正しいかは、誰が検証しているのでしょうか。

「テストがあるから安心」と思いがちですが、テストの中身が甘ければ、バグがあっても気づけません。極端な例を挙げます。

[Fact]
public void Test_Calculate()
{
    _calculator.Calculate(100);   // 呼んでいるだけ。結果を検証(Assert)していない
}

このテストはメソッドを呼んではいるので、カバレッジ(網羅率)の上では「テスト済み」になります。しかし結果を何も確認していないので、Calculate がどんな間違った値を返してもパスしてしまいます。

mutation testing は、この「カバレッジでは見えないテストの質」を測るための手法です。

mutation testing(変異テスト)とは

やることはシンプルで、コードにわざと小さなバグを仕込み、テストがそれに気づくかどうかを確認するだけです。

仕込んだバグ入りのコードを「ミュータント(mutant)」と呼びます。テストにかけた結果は、次のように判定します。

  • テストが失敗した → バグに気づけた → 良いテスト(ミュータントを killed=殺した)
  • テストがパスした → バグを見逃した → 穴のあるテスト(ミュータントが survived=生き残った)

ここで思考の向きが普段と逆になっている点が面白いところです。

普段のテスト mutation testing
固定するもの コード テスト
変えるもの 入力 コードそのもの
確かめたいこと コードは正しいか テストは優秀か

いわば「テストをテストする」ということですね。

どうやってコードを壊すのか

でたらめに壊すわけではなく、「人間がやりがちなミス」を模した決まったパターンで書き換えます。代表的なものを挙げます。

種類 変異の例
比較演算子 age >= 18age > 18a == ba != b
算術演算子 a + ba - b
論理演算子 a && ba || b
真偽の反転 if (x)if (!x)
処理の削除 { DoSomething(); }{}

カバレッジとの違い

ここが一番大事なポイントです。

測っているもの
カバレッジ テストがそのコードを「通ったか」(量)
mutation score テストがコードの変更を「検知できるか」(質)

カバレッジは「実行したか」しか見ていません。しかし「実行した」と「ちゃんと検証した」は別物です。先ほどの Assert を書き忘れたテストがまさにそれで、通ってはいるが検証はしていないわけです。

なぜ「小さく壊す」だけで十分なのか

「実際のバグはもっと複雑なのに、1箇所だけ小さく壊して意味があるの?」と思うかもしれません。これには昔から2つの仮説が根拠として挙げられています。

  • Competent Programmer Hypothesis(有能なプログラマ仮説): プログラマが書くコードは正解に近く、バグの多くは「>=> にした」程度の小さなズレです。だから小さな変異でも、現実のバグをよく再現できます。
  • Coupling Effect(結合効果): 単純な変異をすべて検知できるテストは、より複雑なバグも検知できます。

この2つがあるからこそ、「小さな変異を網羅的に試す」というシンプルな戦略がテスト品質の指標として機能します。なんとなく壊しているわけではない、ということですね。

実際にやってみる(Stryker.NET)

ここからは C# で実際に動かしてみます。.NET の mutation testing ツールには Stryker.NET を使いました(公式ドキュメント)。

題材として、よくある送料計算のロジックを用意しました。

// 仕様:
//   3,000円以上 → 送料無料(0円) / 3,000円未満 → 500円 / 離島は +300円
private const int FreeShippingThreshold = 3000;
private const int BaseShippingFee = 500;
private const int RemoteAreaSurcharge = 300;

public int CalculateShippingFee(int price, bool isRemoteArea)
{
    int fee = price >= FreeShippingThreshold ? 0 : BaseShippingFee;
    if (isRemoteArea)
    {
        fee += RemoteAreaSurcharge;
    }
    return fee;
}

これに対して、xUnit でテストを書きました。ここでは試しに境界値(3,000円ちょうど)のテストを入れない状態から始めます。

[Theory]
[InlineData(2999, false, 500)]
[InlineData(3001, true, 300)]
public void 購入金額による送料のテスト(int price, bool isRemoteArea, int expectedFee)
{
    var calc = new ShippingCalculator();
    var fee = calc.CalculateShippingFee(price, isRemoteArea);
    Assert.Equal(expectedFee, fee);
}

そして、テストプロジェクトのディレクトリで実行します。

dotnet stryker

しばらくすると HTML レポートが生成されます。

結果 生き残ったミュータント

レポートを開くと、結果はこうなりました。

境界テストを外すと、ミュータントが1つ生き残った(score 83.33%)

注目したのはこの箇所です。

元:   price >= FreeShippingThreshold ? 0 : BaseShippingFee
変異: price >  FreeShippingThreshold ? 0 : BaseShippingFee    ← Survived(生き残った)

>=> に書き換えたミュータント(=バグを仕込んだコード)が、テストをすり抜けて生き残りました。レポートにはこう書かれています。

Covered by 3 tests (yet still survived) (3つのテストに実行されているのに、生き残った)

ここがまさに核心です。この行は3つのテストすべてに実行されています。つまりカバレッジ上は「テスト済み」です。にもかかわらず、ミュータントは死にませんでした。

なぜ見逃したのか

理由を考えてみます。>=> で結果が食い違うのは、ちょうど 3,000円のときだけです。

price 本物 >= 偽物 > 食い違うか
2,999 500 500 同じ
3,001 0 0 同じ
3,000 0 500 ← ここだけ違う

今回は 3,000円のテストを入れていないので、テストにあるのは 29993001 だけです。どちらもこの行を実行してはいますが、>=> が唯一食い違う 3,000円 を確かめていません。そのため、違いを見抜けるテストが1つもなく、ミュータントがすり抜けます。「通ってはいるが、肝心なところを確かめていない」——カバレッジ100%でも検証が抜けるとは、まさにこういう状態です。

テストを足して殺す

そこで、境界値である 3,000円のテストを1ケース追加しました。

[InlineData(3000, false, 0)]   // ちょうど3,000円は無料

再び dotnet stryker を実行します。

境界テストを戻すと全部 Killed(score 100%)

mutation score が 100% に戻り、先ほど生き残ったミュータントも killed に変わりました。

ここで注目してもらいたいのは、カバレッジは前も後も100%のままだったということです(その行はずっとテストに実行されていた)。変わったのは mutation score だけ。たった1つの境界テストの有無で、テストの「質」がはっきり可視化されました。

状態 3,000円のテスト カバレッジ mutation score 境界のミュータント
追加前 なし 100% 83.33% Survived 🔴
追加後 あり 100% 100% Killed 🟢

ただし、万能ではない

ここまで便利に見える mutation testing ですが、過信は禁物です。

  • 等価ミュータント(equivalent mutant): 変異させても元と挙動が完全に同じになり、どんなテストでも殺せないミュータントが存在します。これは「テストの穴」ではないのに survived と記録されるため、ノイズになります。
  • 実装の「漏れ」は検出できない: mutation testing は「書かれているコード」しか壊せません。「nullチェックを書き忘れた」のような実装漏れは変異のしようがなく、素通りします。つまり mutation score 100% = バグゼロではありません
  • 実行が重い: ミュータントの数だけテストをまるごと実行するため、大規模なコードでは時間がかかります。現実には重要なロジックや変更箇所に絞って使うことになります。

mutation score は「目標」ではなく「診断」

mutation score は 100%を目指すための目標(target)にすべきではない、ということです。スコアを上げること自体が目的になると、生き残ったミュータントを無理に殺すために、内部実装をこじ開けるような不自然なテストを書きがちになります。そうしたテストは実装に密着していて壊れやすく、かえってテストの質を下げてしまいます。

そうではなく、生き残ったミュータントの一覧を「テストの穴を示す地図」として使う。これが本来の使い方です。スコアの数字を追うのではなく、「どこを検証できていないか」を教えてくれる診断ツールとして付き合うのがよいでしょう。

そして、この視点は今こそ重要だと感じています。AI にコードやテストを書かせる場面が当たり前になってきました。ただ、AI が生成するテストには「カバレッジは稼ぐが、肝心の検証は薄い」ものが混ざることもあります。コードもテストも大量に生成できる時代だからこそ、「そのテストは本当に機能しているのか」を測る視点の価値は、むしろ上がっているのではないでしょうか。

おわりに

mutation testing は、「テストがあること」と「テストがちゃんと機能していること」が別物だと気づかせてくれる手法でした。「カバレッジが高い」ことと「テストが効いている」ことは別なのだと、実際に手を動かして確かめられました。

一方で、スコアを追いかける道具ではなく、テストの穴を見つける診断として使うのが大事だという点も、実際に動かしてみて実感しました。普段書いているテストに一度かけてみると、思わぬ穴が見つかるかもしれません。

参考