ミューテーションテストでテストをテストする ── Stryker.NETで確かめていないテストを見つける

· · テスト, ミューテーションテスト, Stryker.NET, C#, .NET, AI生成コード, 品質

Xでポスト はてなブックマーク
更新履歴(初版のみ・2026年11月01日公開)
初版公開
この記事を引用する(DOI: 10.5281/zenodo.23852483)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「ミューテーションテストでテストをテストする ── Stryker.NETで確かめていないテストを見つける」合同会社小村ソフト. https://doi.org/10.5281/zenodo.23852483 https://comcomponent.com/blog/mutation-testing-test-your-tests/

DOI(最新版)
10.5281/zenodo.23852483
DOI(この版)
10.5281/zenodo.23852484

AIにコードとテストをまとめて書かせると、テストは全部通り、カバレッジも高いのに、テストが何も確かめていないことがあります。結果を受け取るだけで比べない、0より大きいことしか見ない、といったテストです。このシリーズの1本目「AIが書いたテストを信用してよいか」で、こうしたテストの型を取り上げました。

ミューテーションテストはこの種のテストを機械的に見つける方法です。本番のコードをわざと少し変えて(たとえば >= を > に)、テストを流します。テストが失敗すれば、テストはその変化に気づいたことになります。全部通ってしまえば、テストはその部分の動きを確かめていません。

この記事では C# と .NET 向けのツール Stryker.NET を使って、ミューテーションテストの考え方、導入と実行、レポートの読み方、生き残ったミュータントの扱い方、実行時間を抑える方法を説明します。コマンドと設定項目の名前は執筆時点(2026年11月)の Stryker.NET の公式資料で確かめたものです。記事中のコード例は説明のための小さな例です。割引の例は Stryker.NET 5.0.0 で実際に実行し、本文の結果になることを確かめています(.NET 10 SDK で実行し、テスト対象とテストのプロジェクトは .NET 8)。

このシリーズは次の4本です。

  1. AIが書いたテストを信用してよいか
  2. コードカバレッジでテストされていないコードを見つける
  3. ミューテーションテストでテストをテストする(この記事)
  4. プロパティベーステストで、AIが思いつかない入力を試す

ミューテーションテストの仕組み

Stryker の資料はミューテーションテストを次のように説明しています。本番のコードに不具合(ミュータント)を自動で入れ、ミュータントごとにテストを流します。テストが失敗すればそのミュータントは倒された(killed)ことになり、テストが通ればミュータントは生き残ります(survived)1。

ミューテーションテストの流れ本番のコードを少し変えたミュータントを作り、ミュータントごとにテストを流し、テストが失敗すればkilled、すべて通ればsurvivedになる流れを示す。どれかが失敗すべて通過本番のコード少し変えたミュータントを作るミュータントごとにテストを流すテストの結果killed(変化に気づいた)survived(変化に気づかない)

図1: ミュータントごとにテストを流し、失敗すればkilled、すべて通ればsurvivedになる。

ミュータントの作り方は決まった書き換えの規則(ミューテーター)で決まっています。Stryker.NET の資料には、たとえば次のような書き換えが載っています2。

種類 元のコード 書き換え後
算術演算子 * /
算術演算子 / *
比較演算子 >= >
比較演算子 >= <
比較演算子 == !=
比較演算子 != ==
真偽値 true false
文字列 "foo" ""

どの書き換えも実際のコードで起こりうる小さな間違いに似ています。境界の >= と > の取り違えや、演算子の書き間違いです。テストがこうした変化で失敗しないなら、同じ種類の本物の間違いが入っても気づかない可能性があります。

ミュータントの状態

Stryker は結果を次の状態に分けます3。

状態 意味
Killed ミュータントが有効な間に、少なくとも1つのテストが失敗した
Survived ミュータントが有効な間に、すべてのテストが通った
No coverage ミュータントの場所をどのテストも実行しておらず、その結果として生き残った
Timeout ミュータントを有効にしてテストを流したら時間切れになった
Runtime error テストの失敗ではなく、実行時のエラーになった
Compile error ミュータントがコンパイルエラーになった
Ignored 除外する設定になっていて、テストしなかった

Survived と No coverage は、どちらもテストが気づかなかったミュータントですが、意味が違います。No coverage はテストがその場所を実行していないので、まずテストを足す必要があります。Survived はテストがその場所を実行しているのに、結果の違いに気づいていません。この記事が問題にするのは主に Survived のほうです。

ミュータントの状態の分かれ方ミュータントの場所をテストが実行していなければNo coverage、実行していてテストが失敗すればKilled、時間切れならTimeout、すべて通ればSurvivedになることを示す。実行しない実行する失敗時間切れすべて通過ミュータントその場所をテストが実行するかNo coverage(テストを足す)テストの結果KilledTimeoutSurvived(確かめていない)

図2: テストが実行しない場所はNo coverage、実行しても気づかない場所はSurvivedになる。

ミューテーションスコア

結果はミューテーションスコアという1つの割合にまとめられます。Stryker の定義では検出した数(Killed と Timeout の合計)を、有効なミュータントの数(検出した数と、Survived と No coverage の合計)で割って100を掛けます。コンパイルエラーや実行時エラーになったミュータントは、有効な数に入りません3。

ミューテーションスコア = 検出した数 ÷ 有効な数 × 100
  検出した数 = Killed + Timeout
  有効な数   = 検出した数 + Survived + No coverage

テストが実行した場所だけで計算した「カバーされたコードのミューテーションスコア」もあり、こちらは検出した数を、カバーされた数(検出した数と Survived の合計)で割ります3。No coverage を分母から外すので、テストが実行している場所で、どれだけ変化に気づけているかが分かります。

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全6件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

カバレッジでは見えない「確かめていないテスト」

コードカバレッジは、テストを流したときにどの行や分岐が実行されたかを示します(シリーズの2本目で扱います)。Stryker の資料も、カバレッジはテストの効果をすべて教えてはくれないと書いています1。実行された行がテストで正しさを確かめられたとは限らないからです。

カバレッジとミューテーションテストが答える問いコードカバレッジはテストがその行を実行したかに答え、ミューテーションテストはその行を変えたときにテストが失敗するかに答えることを示す。本番のコードの1行カバレッジの問い: テストが実行したかミューテーションテストの問い: 変えたらテストが失敗するか実行しただけでも緑になる結果を確かめていないと生き残る

図3: カバレッジは実行したかを、ミューテーションテストは確かめたかを問う。

次の小さな例で見てみます。購入額が10,000円以上なら1割引きにするメソッドです。

public static class Discount
{
    // 購入額が10,000円以上なら1割引きにする
    public static int Apply(int amount)
    {
        if (amount >= 10_000)
        {
            return amount * 9 / 10;
        }

        return amount;
    }
}

これに対して、次のようなテストがあったとします(xUnit で書いています)。

public class DiscountTests
{
    [Fact]
    public void Apply_ReturnsPositiveValue()
    {
        var result = Discount.Apply(20_000);

        Assert.True(result > 0);
    }
}

このテストは通り、割引の行も実行します。ところが確かめているのは「結果が0より大きい」ことだけです。先ほどの書き換えの規則で作られるミュータントを、このテストに当てて考えてみます。

  • >= を > に変えても、20,000円は割引されて18,000円になり、0より大きいので通ります。
  • >= を < に変えると、20,000円は割引されずに20,000円のままですが、0より大きいので通ります。
  • * を / に変えると、20000 / 9 / 10 で222になりますが、0より大きいので通ります。
  • / を * に変えると、20000 * 9 * 10 で1,800,000になりますが、0より大きいので通ります。

どの変化も、このテストは見逃します。実際に Stryker.NET 5.0.0 で実行すると、この4つに加えて条件全体を否定するミュータント(!(amount >= 10_000))も作られ、5つとも Survived でスコアは0%でした。if の中身を丸ごと消すミュータントも作られましたが、Stryker の判断で試す対象から外されました(Ignored)。割引の行は実行されているので、カバレッジのレポートではこの行は実行済みとして表示されます。

テストを次のように直すと、同じミュータントはどれも倒せます。

public class DiscountTests
{
    [Theory]
    [InlineData(9_999, 9_999)]   // 境界の1円手前は割引しない
    [InlineData(10_000, 9_000)]  // 境界ちょうどは割引する
    [InlineData(20_000, 18_000)]
    public void Apply_DiscountsTenPercentFrom10000Yen(int amount, int expected)
    {
        Assert.Equal(expected, Discount.Apply(amount));
    }
}
  • >= を > に変えると、10,000円が割引されずに10,000円になり、期待する9,000円と合わないので失敗します。
  • >= を < に変えると、9,999円が割引されて8,999円になり、期待する9,999円と合わないので失敗します。
  • * を / に、/ を * に変えたミュータントは、20,000円の結果が18,000円にならないので失敗します。

直したテストで実行すると、条件全体を否定するミュータントも含めて5つとも Killed になり、スコアは100%でした。違いは結果を期待する値と比べていることと、境界の両側の入力を入れていることです。この2つがあると、境界の取り違えや計算の誤りに気づけるテストになります。

弱いテストと直したテストの違い結果が0より大きいことだけを見るテストではミュータントが生き残り、境界の両側の入力で期待値と比べるテストではミュータントが倒れることを示す。結果が0より大きいかだけを見るテスト4つのミュータントが生き残る境界の両側で期待値と比べるテスト4つのミュータントが倒れるカバレッジでは実行済みに見える

図4: 結果を期待値と比べ、境界の両側を入れると、同じミュータントを倒せる。

AIにテストを書かせると、最初の例のように「例外が出ない」「nullでない」「0より大きい」といった、何を入れても成り立ちやすい確かめ方になることがあります。ミューテーションテストはそうしたテストを Survived のミュータントとして指し示します。

Stryker.NET の導入と実行

インストール

公式資料の手順では、Stryker.NET は .NET のツールとして入れます。チームで同じバージョンを使うなら、リポジトリにツールのマニフェストを作って入れる方法が向いています4。

dotnet new tool-manifest
dotnet tool install dotnet-stryker

ほかの開発者はリポジトリを取得したあとに次のコマンドで同じツールを入れられます。

dotnet tool restore

執筆時点の公式資料では、Stryker.NET の実行には .NET 10 以降のランタイムが必要です。テスト対象のアプリが .NET 10 を使っている必要はなく、ランタイムが入っていれば足ります。Stryker.NET 5.0.0 のパッケージは .NET 10 向けにだけ作られていて、.NET 8 の SDK だけの環境では dotnet tool install dotnet-stryker が DotnetToolSettings.xml が見つからないというエラーで失敗しました。.NET Framework のプロジェクトでは、NuGet が PATH にあることなども求められています4。

実行

単体テストのプロジェクトのフォルダーに移って、次のコマンドを実行します4。

dotnet stryker

設定は実行するフォルダーに置いた stryker-config.json に書けます。ルートのキーは stryker-config です5。たとえば、テスト対象のプロジェクト、変える対象のファイル、出力するレポートを指定すると次のようになります。

{
  "stryker-config": {
    "project": "Shop.Core.csproj",
    "mutate": [
      "**/*.cs",
      "!**/*.Generated.cs"
    ],
    "reporters": ["progress", "html"]
  }
}

project には、テストのプロジェクトが参照している本番のプロジェクトのファイル名を書きます。パスではなく名前で書く決まりです。mutate はファイルの glob で、先頭に ! を付けたものは対象から外れます5。

Stryker.NETの導入から実行までツールのマニフェストを作ってdotnet-strykerを入れ、単体テストのプロジェクトのフォルダーでdotnet strykerを実行し、HTMLのレポートを読む流れを示す。ツールのマニフェストを作るdotnet-strykerを入れる単体テストのプロジェクトのフォルダーへ移るdotnet strykerを実行HTMLのレポートを読むstryker-config.json

図5: ツールを入れ、単体テストのプロジェクトのフォルダーで実行し、レポートを読む。

レポートを読む

レポーターの既定は html と progress です。html のレポートは既定では mutation-report という名前で出力されます5。--open-report を付けて実行すると、終わったときにレポートを開けます5。

レポートではミュータントごとに状態を確かめられます。次の順に読みます。

  1. No coverage のミュータントがある場所は、テストがその処理を実行していません。テストを足すかどうかから考えます。
  2. Survived のミュータントは、どう書き換えたのに通ったのかを1つずつ読みます。次の節の基準で、テストを直すか、そのままにするかを決めます。

生き残ったミュータントをどう扱うか

Survived のミュータントには2種類あります。1つは、テストが確かめるべき動きを確かめていないものです。もう1つは、元のコードと同じ動きをするので、どんなテストでも倒せないものです。後者を等価ミュータントと呼びます6。

次の例では、>= を > に変えても結果が変わりません。

public static int Max(int a, int b) => a >= b ? a : b;

a と b が等しいとき、元のコードは a を返し、書き換えたコードは b を返します。しかし2つは同じ値なので、返る値は変わりません。どんな入力を入れても区別できないので、このミュータントは等価ミュータントです。

生き残ったミュータントの判断生き残ったミュータントについて、元のコードと結果が変わる入力があればテストを足すか直し、どの入力でも結果が変わらなければ等価ミュータントとしてコードの書き直しかスコアの未達を受け入れることを示す。あるないSurvivedのミュータント結果が変わる入力はあるかその入力で期待値と比べるテストを足す等価ミュータントコードを書き直すスコアが100%に届かないことを受け入れる

図6: 結果が変わる入力があればテストを足し、なければ等価ミュータントとして扱う。

判断の基準は「元のコードと書き換えたコードで、結果が変わる入力があるか」です。

  • 結果が変わる入力がある場合は、その入力をテストに足し、期待する値と比べます。前の節の割引の例がこれに当たります。境界の値、0や空の入力、負の値など、AIが書いたテストで抜けやすい入力から考えます。
  • どの入力でも結果が変わらない場合は、等価ミュータントです。Stryker の資料は、等価ミュータントを見つけるには手で調べるしかなく時間がかかると書いています。対応としては、等価ミュータントが生まれないようにコードを書き直すか、スコアが100%に届かないことを受け入れるかを挙げています6。

等価ミュータントがあるので、スコアの数字だけを目標にすると、倒せないミュータントのために時間を使うことになります。スコアは生き残ったミュータントを読むきっかけとして使い、判断は1つずつ行います。

実行時間を抑える

ミューテーションテストはミュータントごとにテストを流すので、普通にテストを流すより時間がかかります。プロジェクト全体を毎回対象にするのではなく範囲を絞って使います。

  • since を使うと、指定したブランチやコミットから変わったコードだけを対象にします。レポートに結果が出るのは変更したコードのミュータントだけで、それ以外は結果を持ちません。テストのプロジェクトのファイルを変えたときは、そのファイルのテストが実行するミュータントがすべて変更扱いになります5。コマンドでは --since:main のように指定し、設定ファイルでは "since": { "target": "main" } と書きます。
  • mutate の glob で、ロジックを持つファイルだけを対象にし、生成コードなどを外します5。
  • concurrency で同時に動かす数を変えられます。既定は論理プロセッサー数の半分です5。
Stryker.NETの実行範囲を絞る方法sinceで変更したコードに絞り、mutateで対象のファイルを絞り、thresholdsのbreakを下回ると0以外の終了コードで終わることを使ってPRの確認に組み込むことを示す。breakを下回るbreakを上回るPRで変更したコードsinceで変更部分だけを対象にするmutateで対象のファイルを絞るスコアを確かめる0以外の終了コードで終わるそのまま進む

図7: 変更部分とファイルを絞って実行し、breakを下回ると失敗として扱える。

PRの確認に組み込む場合は、thresholds を使います。既定は high が80、low が60、break が0です。公式資料は、スコアが break を下回ると Stryker が終了コード1で終わると説明しています5。Stryker.NET 5.0.0 で試すと終了コードは2でした。どちらも0以外なので、CI は失敗として扱えます。最初は break を低めに置き、生き残ったミュータントを読む習慣がついてから上げていくと、等価ミュータントのせいで止まり続けることを避けられます。

ミューテーションテストの限界

ミューテーションテストが教えてくれるのは、「決まった書き換えに対して、テストが失敗するか」です。次のことは分かりません。

  • 書き換えの規則にない種類の間違い(仕様の読み違い、抜けている機能、別のモジュールとの組み合わせの誤り)にテストが気づけるか。
  • 期待値そのものが正しいか。コードと期待値がそろって仕様と違っていても、テストは通り、ミュータントは倒れます。

また、等価ミュータントがあるのでスコアは100%に届かないことがあり、実行には時間がかかります。カバレッジで実行されていない場所を見つけ、ミューテーションテストで確かめていない場所を見つけ、期待値は仕様と突き合わせて人が確かめる、という分担で使います。入力の範囲を広く試す方法は、シリーズの4本目「プロパティベーステストで、AIが思いつかない入力を試す」で扱います。

関連記事

参考リンク

  1. Stryker Mutator, Introduction. ミューテーションテストの考え方(ミュータントを入れてテストを流し、失敗すればkilled、通ればsurvived)と、カバレッジだけではテストの効果が分からないこと。 ↩ ↩2

  2. Stryker Mutator, Mutations (Stryker.NET). 算術演算子・比較演算子・論理演算子・真偽値・文字列などの書き換えの規則。 ↩

  3. Stryker Mutator, Mutant states and metrics. ミュータントの状態の定義と、ミューテーションスコアの計算式。 ↩ ↩2 ↩3

  4. Stryker Mutator, Getting started (Stryker.NET). インストールと実行のコマンド、実行に必要なランタイム。 ↩ ↩2 ↩3

  5. Stryker Mutator, Configuration (Stryker.NET). stryker-config.json、mutate、project、reporters、since、thresholds、concurrency、open-reportの設定。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  6. Stryker Mutator, Equivalent mutants. 等価ミュータントの説明と、その扱い方。 ↩ ↩2

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

ミューテーションテストとは何ですか?
本番のコードを少しだけ変えたもの(ミュータント)を自動で作り、テストを流して失敗するかを見る方法です。テストが失敗すればミュータントは「killed」、すべて通れば「survived」になります。生き残ったミュータントは、テストがその変化を確かめていない場所を示します。
コードカバレッジが高ければミューテーションテストは要りませんか?
カバレッジはテストがそのコードを実行したかを示すだけで、結果を確かめたかまでは示しません。結果を確かめないテストでもカバレッジは上がります。ミューテーションテストは、コードを変えたときにテストが失敗するかで、確かめているかどうかを調べます。
Stryker.NETはどうやって動かしますか?
dotnet tool install で dotnet-stryker を入れ、単体テストのプロジェクトのフォルダーで dotnet stryker を実行します。設定は stryker-config.json に書けます。執筆時点の公式資料では、実行には .NET 10 以降のランタイムが必要で、対象のアプリが .NET 10 を使っている必要はありません。
ミューテーションスコアは100%を目指すべきですか?
目指さなくてかまいません。元のコードと同じ動きをする等価ミュータントはどのテストでも倒せないので、100%に届かないことがあります。点数を上げることより、生き残ったミュータントを1つずつ読んで、確かめるべき動きが抜けていないかを判断することに使います。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る