更新履歴(初版のみ・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本です。
- AIが書いたテストを信用してよいか
- コードカバレッジでテストされていないコードを見つける
- ミューテーションテストでテストをテストする(この記事)
- プロパティベーステストで、AIが思いつかない入力を試す
ミューテーションテストの仕組み
Stryker の資料はミューテーションテストを次のように説明しています。本番のコードに不具合(ミュータント)を自動で入れ、ミュータントごとにテストを流します。テストが失敗すればそのミュータントは倒された(killed)ことになり、テストが通ればミュータントは生き残ります(survived)1。
flowchart TB
accTitle: ミューテーションテストの流れ
accDescr: 本番のコードを少し変えたミュータントを作り、ミュータントごとにテストを流し、テストが失敗すればkilled、すべて通ればsurvivedになる流れを示す。
A["本番のコード"] --> B["少し変えたミュータントを作る"]
B --> C["ミュータントごとにテストを流す"]
C --> D["テストの結果"]
D -->|"どれかが失敗"| E["killed(変化に気づいた)"]
D -->|"すべて通過"| F["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 のほうです。
flowchart TB
accTitle: ミュータントの状態の分かれ方
accDescr: ミュータントの場所をテストが実行していなければNo coverage、実行していてテストが失敗すればKilled、時間切れならTimeout、すべて通ればSurvivedになることを示す。
M["ミュータント"] --> Q1["その場所をテストが実行するか"]
Q1 -->|"実行しない"| NC["No coverage(テストを足す)"]
Q1 -->|"実行する"| Q2["テストの結果"]
Q2 -->|"失敗"| K["Killed"]
Q2 -->|"時間切れ"| T["Timeout"]
Q2 -->|"すべて通過"| S["Survived(確かめていない)"]
図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。実行された行がテストで正しさを確かめられたとは限らないからです。
flowchart TB
accTitle: カバレッジとミューテーションテストが答える問い
accDescr: コードカバレッジはテストがその行を実行したかに答え、ミューテーションテストはその行を変えたときにテストが失敗するかに答えることを示す。
L["本番のコードの1行"] --> C1["カバレッジの問い: テストが実行したか"]
L --> M1["ミューテーションテストの問い: 変えたらテストが失敗するか"]
C1 -.-> C2["実行しただけでも緑になる"]
M1 -.-> M2["結果を確かめていないと生き残る"]
図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つがあると、境界の取り違えや計算の誤りに気づけるテストになります。
flowchart TB
accTitle: 弱いテストと直したテストの違い
accDescr: 結果が0より大きいことだけを見るテストではミュータントが生き残り、境界の両側の入力で期待値と比べるテストではミュータントが倒れることを示す。
T1["結果が0より大きいかだけを見るテスト"] --> R1["4つのミュータントが生き残る"]
T2["境界の両側で期待値と比べるテスト"] --> R2["4つのミュータントが倒れる"]
R1 -.-> N1["カバレッジでは実行済みに見える"]
図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。
flowchart TB
accTitle: Stryker.NETの導入から実行まで
accDescr: ツールのマニフェストを作ってdotnet-strykerを入れ、単体テストのプロジェクトのフォルダーでdotnet strykerを実行し、HTMLのレポートを読む流れを示す。
A["ツールのマニフェストを作る"] --> B["dotnet-strykerを入れる"]
B --> C["単体テストのプロジェクトのフォルダーへ移る"]
C --> D["dotnet strykerを実行"]
D --> E["HTMLのレポートを読む"]
F["stryker-config.json"] -.-> D
図5: ツールを入れ、単体テストのプロジェクトのフォルダーで実行し、レポートを読む。
レポートを読む
レポーターの既定は html と progress です。html のレポートは既定では mutation-report という名前で出力されます5。--open-report を付けて実行すると、終わったときにレポートを開けます5。
レポートではミュータントごとに状態を確かめられます。次の順に読みます。
- No coverage のミュータントがある場所は、テストがその処理を実行していません。テストを足すかどうかから考えます。
- Survived のミュータントは、どう書き換えたのに通ったのかを1つずつ読みます。次の節の基準で、テストを直すか、そのままにするかを決めます。
生き残ったミュータントをどう扱うか
Survived のミュータントには2種類あります。1つは、テストが確かめるべき動きを確かめていないものです。もう1つは、元のコードと同じ動きをするので、どんなテストでも倒せないものです。後者を等価ミュータントと呼びます6。
次の例では、>= を > に変えても結果が変わりません。
public static int Max(int a, int b) => a >= b ? a : b;
a と b が等しいとき、元のコードは a を返し、書き換えたコードは b を返します。しかし2つは同じ値なので、返る値は変わりません。どんな入力を入れても区別できないので、このミュータントは等価ミュータントです。
flowchart TB
accTitle: 生き残ったミュータントの判断
accDescr: 生き残ったミュータントについて、元のコードと結果が変わる入力があればテストを足すか直し、どの入力でも結果が変わらなければ等価ミュータントとしてコードの書き直しかスコアの未達を受け入れることを示す。
S["Survivedのミュータント"] --> Q["結果が変わる入力はあるか"]
Q -->|"ある"| A["その入力で期待値と比べるテストを足す"]
Q -->|"ない"| E["等価ミュータント"]
E --> E1["コードを書き直す"]
E --> E2["スコアが100%に届かないことを受け入れる"]
図6: 結果が変わる入力があればテストを足し、なければ等価ミュータントとして扱う。
判断の基準は「元のコードと書き換えたコードで、結果が変わる入力があるか」です。
- 結果が変わる入力がある場合は、その入力をテストに足し、期待する値と比べます。前の節の割引の例がこれに当たります。境界の値、0や空の入力、負の値など、AIが書いたテストで抜けやすい入力から考えます。
- どの入力でも結果が変わらない場合は、等価ミュータントです。Stryker の資料は、等価ミュータントを見つけるには手で調べるしかなく時間がかかると書いています。対応としては、等価ミュータントが生まれないようにコードを書き直すか、スコアが100%に届かないことを受け入れるかを挙げています6。
等価ミュータントがあるので、スコアの数字だけを目標にすると、倒せないミュータントのために時間を使うことになります。スコアは生き残ったミュータントを読むきっかけとして使い、判断は1つずつ行います。
実行時間を抑える
ミューテーションテストはミュータントごとにテストを流すので、普通にテストを流すより時間がかかります。プロジェクト全体を毎回対象にするのではなく範囲を絞って使います。
sinceを使うと、指定したブランチやコミットから変わったコードだけを対象にします。レポートに結果が出るのは変更したコードのミュータントだけで、それ以外は結果を持ちません。テストのプロジェクトのファイルを変えたときは、そのファイルのテストが実行するミュータントがすべて変更扱いになります5。コマンドでは--since:mainのように指定し、設定ファイルでは"since": { "target": "main" }と書きます。mutateの glob で、ロジックを持つファイルだけを対象にし、生成コードなどを外します5。concurrencyで同時に動かす数を変えられます。既定は論理プロセッサー数の半分です5。
flowchart TB
accTitle: Stryker.NETの実行範囲を絞る方法
accDescr: sinceで変更したコードに絞り、mutateで対象のファイルを絞り、thresholdsのbreakを下回ると0以外の終了コードで終わることを使ってPRの確認に組み込むことを示す。
P["PRで変更したコード"] --> S1["sinceで変更部分だけを対象にする"]
S1 --> S2["mutateで対象のファイルを絞る"]
S2 --> R["スコアを確かめる"]
R -->|"breakを下回る"| X["0以外の終了コードで終わる"]
R -->|"breakを上回る"| OK["そのまま進む"]
図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が思いつかない入力を試す」で扱います。
関連記事
- AIが書いたテストを信用してよいか
- コードカバレッジでテストされていないコードを見つける
- プロパティベーステストで、AIが思いつかない入力を試す
- ユニットテストと結合テストの境界をどう引くか
- テストのないレガシー業務アプリに安全に手を入れる
参考リンク
-
Stryker Mutator, Introduction. ミューテーションテストの考え方(ミュータントを入れてテストを流し、失敗すればkilled、通ればsurvived)と、カバレッジだけではテストの効果が分からないこと。 ↩ ↩2
-
Stryker Mutator, Mutations (Stryker.NET). 算術演算子・比較演算子・論理演算子・真偽値・文字列などの書き換えの規則。 ↩
-
Stryker Mutator, Mutant states and metrics. ミュータントの状態の定義と、ミューテーションスコアの計算式。 ↩ ↩2 ↩3
-
Stryker Mutator, Getting started (Stryker.NET). インストールと実行のコマンド、実行に必要なランタイム。 ↩ ↩2 ↩3
-
Stryker Mutator, Configuration (Stryker.NET). stryker-config.json、mutate、project、reporters、since、thresholds、concurrency、open-reportの設定。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Stryker Mutator, Equivalent mutants. 等価ミュータントの説明と、その扱い方。 ↩ ↩2
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
カバレッジでテストされていないコードを見つける ── AIが書いたC#のテストを確かめる
AIが書いたC#のテストが、どのコードを実行していないかをカバレッジで調べます。行と分岐の違い、Coverletでの測り方、レポートの読み方、割合を目標にしない理由、生成コードの除外、変更した行だけの確認をまとめます。
プロパティベーステストで、例では届かない入力を試す ── FsCheckとxUnitによるC#の実践
AIが書いたテストは、書いた側が思いついた入力しか確かめません。どの入力でも成り立つ性質を書き、入力をFsCheckに作らせるプロパティベーステストを、C#とxUnitのコードで説明します。
AIが書いたテストを信用してよいか ── 通るのに何も確かめないテストの見分け方
AIが書いたテストは、通っていても何も確かめていないことがあります。アサーションの不足、実装から写した期待値、代役の使いすぎ、正常系だけのテスト、不具合に合わせた書き換えの見分け方と、道具での補い方をまとめます。
業務アプリの日時とタイムゾーン ── DateTimeの罠からUTC保存の原則、テスト設計まで
サーバー移設で時刻が9時間ずれる──日時事故の原因をDateTimeのKindと暗黙変換から整理します。DateTimeOffsetとの使い分け、UTC保存の原則、TimeZoneInfoと夏時間、TimeProviderによるテスト設計まで解説します。
WindowsデスクトップアプリのUI自動テスト ── UI Automationの仕組みとFlaUIで作る壊れにくいテスト
WinForms/WPFアプリのUI自動テストを、Windows UI Automationの仕組みから整理します。FlaUIによる最小実装、AutomationId設計と条件待機で壊れにくくする方法、CI無人実行の罠まで実務目線で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
技術相談・設計レビュー
生き残ったミュータントのどれに手当てが要るかは、そのコードが業務で何を保証すべきかで決まります。テストの方針から一緒に整理したい場合は、技術相談として進めやすい題材です。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- ミューテーションテストとは何ですか?
- 本番のコードを少しだけ変えたもの(ミュータント)を自動で作り、テストを流して失敗するかを見る方法です。テストが失敗すればミュータントは「killed」、すべて通れば「survived」になります。生き残ったミュータントは、テストがその変化を確かめていない場所を示します。
- コードカバレッジが高ければミューテーションテストは要りませんか?
- カバレッジはテストがそのコードを実行したかを示すだけで、結果を確かめたかまでは示しません。結果を確かめないテストでもカバレッジは上がります。ミューテーションテストは、コードを変えたときにテストが失敗するかで、確かめているかどうかを調べます。
- Stryker.NETはどうやって動かしますか?
- dotnet tool install で dotnet-stryker を入れ、単体テストのプロジェクトのフォルダーで dotnet stryker を実行します。設定は stryker-config.json に書けます。執筆時点の公式資料では、実行には .NET 10 以降のランタイムが必要で、対象のアプリが .NET 10 を使っている必要はありません。
- ミューテーションスコアは100%を目指すべきですか?
- 目指さなくてかまいません。元のコードと同じ動きをする等価ミュータントはどのテストでも倒せないので、100%に届かないことがあります。点数を上げることより、生き残ったミュータントを1つずつ読んで、確かめるべき動きが抜けていないかを判断することに使います。