はじめに
前回の記事『C#の不毛なコードレビューをゼロに!CSharp_IngeniousAnalyzer 全11ルール徹底解説』では、静的解析アナライザーの全ルールを紹介しました。
おかげさまで多くの方に読んでいただき、心より感謝申し上げます!
前回の記事:
今回は、新しく開発した 『XUnit_IngeniousGenerator』 を紹介します。
C# のクラスから、xUnit のテストコードを LLM を使わずに Roslyn だけで 自動生成するツールです。
アナライザーと同じく NuGet で公開していて、パッケージを追加するだけで Visual Studio / VS Code から使えます。
なぜ作ったのか
私たちのチームでは、単体テストのコードを AI(LLM)に頼んで書いてもらうことが増えていました。
便利な一方で、困ったことが2つありました。
- LLM のライセンスを持っていないメンバーは使えない
- 同じクラスでも、頼むたびに違うテストが出てくる
そこで、コードを解析すれば機械的に決まるテスト(例外、境界値、分岐)だけでも、誰でも同じ結果を得られるようにしたいと考えました。
100% の網羅を目指すものではありません。
目指したのは、「コードを読めば思いつく確認」をテストに落とし込み、ステップ実行で一つずつ確かめる手間を減らすことです。
導入方法
1. NuGet からインストール
テスト対象(プロダクトコード)のプロジェクトに、XUnit_IngeniousGenerator を追加します。
現在はプレリリース版(0.1.0-preview.2)となっています。
※ Windows+Visual Studio で見つかった問題を修正した版で、再確認が済んでいないため
※ NuGet パッケージマネージャーで 「プレリリースを含める」 にチェックを入れて検索すると見つかります
dotnet add package XUnit_IngeniousGenerator --prerelease
前回紹介した CSharp_IngeniousAnalyzer とは独立したパッケージです。
どちらか一方だけでも、両方を入れても使えます。
テスト生成はこれからも頻繁に改善していくため、アナライザーのバージョンに影響しないよう、別のパッケージとして公開しました。
2. ルールの有効・無効
パッケージを入れること自体がテスト生成を使う意思表示なので、2つのルールはどちらも 既定で有効 にしてあります。
インストールするだけで警告が出ます。
止めたい場合は、.editorconfig で重大度を none にしてください。
dotnet_diagnostic.TEST001.severity = none
dotnet_diagnostic.TEST002.severity = none
止めている間は解析そのものを行わないので、ビルドが重くなることもありません。
ルール解説
🔹 TEST001: テストコードの生成によるテスト漏れの防止
- タイトル: テストコードの生成によるテスト漏れの防止
-
メッセージ(テストファイルがない場合):
クラス '{0}' のテストコード({0}Tests)が見つかりません。クイックフィックスでテストコードを生成してください。 -
メッセージ(テストのない関数がある場合):
'{0}' のテストが {1} にありません。クイックフィックスでテストコードを追加してください。 -
解説:
テストが足りない箇所を警告し、Fix(Ctrl + .)でテストコードを生成します。
警告は、Fix で実際にテストを生成できる場合にだけ 出します。
テストファイル(XxxTests.cs)がなければクラス名に、テストファイルはあるのにテストのない関数があれば、その関数名に警告が出ます。
Fix の動き
| 状態 | 警告の位置 | Fix で起きること |
|---|---|---|
| テストプロジェクトがない | クラス名 | 隣のフォルダに プロジェクト名.Tests を作り、テストファイルを生成 |
| テストファイルがない | クラス名 | テストプロジェクトに XxxTests.cs を生成 |
| テストのない関数がある | 関数名 | その関数のテストだけ を既存のテストファイルに追加 |
関数のテストがあるかどうかは、テストメソッド名が 関数名_ で始まるかで判定します。
既存のテストファイルに追加するときは、手で書き換えたテストメソッドには一切触れません。
名前がまだないテストメソッドと、足りない using だけを追加します。
例:あとから関数を追加した場合
public class Calculator
{
public int Add(int a, int b) => a + b; // テストあり
public int Triple(int value) => value * 3; // ← 追加した関数に TEST001
}
Triple の上で Fix を実行すると、CalculatorTests.cs に Triple のテストだけが追加されます。
🔹 TEST002: 自動生成できないテストの明示
- タイトル: 自動生成できないテストの明示
-
メッセージ:
'{0}' のテストコードは自動生成できません({1})。別の方法で確認したうえで、警告を Ignore してください。 -
解説:
ファイル・DB・通信を直接使う関数のように、単体テストを自動では作れない関数に、理由つきで 警告を出します。
Fix は// Ignore TEST002を挿入するだけにしてあります。
黙って見逃すと、「このクラスのテストはどこまで揃っているのか」が分からなくなります。
そこで、人間が「別の方法で確認した」と判断した印を、コードに残す設計にしました。
TEST002 の Fix は Ignore のみ(他の項目は .NET 標準・Copilot の機能)

Fix による Ignore 適用例
{ } の本体を持つ関数では、CSharp_IngeniousAnalyzer と同じく、関数の1行目に挿入します。
// Before
public int Divide(int dividend, int divisor)
{
if (divisor == 0) throw new ArgumentOutOfRangeException(nameof(divisor));
return dividend / divisor;
}
// After (Fix適用後)
public int Divide(int dividend, int divisor)
{
// Ignore TEST002
if (divisor == 0) throw new ArgumentOutOfRangeException(nameof(divisor));
return dividend / divisor;
}
=> で書いた関数やプロパティは、中に入れる場所がないので、宣言の直前に挿入します。
// Before
public static string Load(string path) => File.ReadAllText(path);
// After (Fix適用後)
// Ignore TEST002
public static string Load(string path) => File.ReadAllText(path);
生成されるテストコード
ここからは、実際に生成されたテストコードを紹介します。
どれも、記事用に手を加えていない生成結果そのままです。
例外のテスト(引数ガードから)
メソッドの先頭にある引数チェック(if (...) throw、ThrowIf~、?? throw)を読み取り、Assert.Throws のテストを作ります。
ParamName まで確認するので、別の引数のチェックで同じ例外が出ても、誤って成功することはありません。
// テスト対象
public int Divide(int dividend, int divisor)
{
if (divisor == 0) throw new ArgumentOutOfRangeException(nameof(divisor));
return dividend / divisor;
}
// 生成されたテスト
[Fact]
public void Divide_DivisorIsZero_ThrowsArgumentOutOfRangeException()
{
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => _sut.Divide(0, 0));
Assert.Equal("divisor", exception.ParamName);
}
境界値のテスト(期待値もコードから計算)
比較している定数の前後(>= 80 なら 79・80・81)と、隣り合う境界の中間(60 と 80 なら 70)を入力にして、[Theory] のテストを作ります。
期待値は、ツールがコードを静的に解釈して計算し、リテラルで書き込みます。
利用者のコードを実行することはありません。
// テスト対象
public string Judge(int score) => score switch
{
>= 80 => "合格",
>= 60 => "再試験",
_ => "不合格",
};
// 生成されたテスト
[Theory]
[InlineData(1, "不合格")]
[InlineData(0, "不合格")]
[InlineData(59, "不合格")]
[InlineData(60, "再試験")]
[InlineData(61, "再試験")]
[InlineData(70, "再試験")]
[InlineData(79, "再試験")]
[InlineData(80, "合格")]
[InlineData(81, "合格")]
public void Judge_BoundaryValues_ReturnsExpected(int score, string expected)
{
Assert.Equal(expected, _sut.Judge(score));
}
引数チェックで範囲が決まっている場合は、「小さすぎる値・Min・中間・Max・大きすぎる値」がそろいます。
範囲外の値は例外のテストに、範囲内の値は [Theory] に分かれます。
// テスト対象(0〜100 だけを受け付ける)
public int Percent(int value)
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 100);
return value;
}
| 値 | 生成されるテスト |
|---|---|
| -1(小さすぎる) | Percent_ValueIsMinus1_ThrowsArgumentOutOfRangeException |
| 0(Min)・1・50(中間)・99・100(Max) |
Percent_BoundaryValues_ReturnsExpected の [InlineData]
|
| 101(大きすぎる) | Percent_ValueIs101_ThrowsArgumentOutOfRangeException |
decimal のように属性に書けない型も、TheoryData を使って同じように期待値まで生成します。
スナップショットのテスト(依存は Moq で自動モック)
メソッド呼び出しやループなどで、静的には期待値を計算できない場合もあります。
そのときは Verify で結果を記録するテストを作ります。
初回の実行で結果が自動で記録され、2回目以降はその結果と比べます。
コンストラクタに注入されたインターフェースは、Moq で自動的にモックします。
// 生成されたテスト(PriceService は IRateProvider をコンストラクタで受け取る)
private readonly PriceService _sut = new PriceService(new Mock<IRateProvider>().Object);
[Theory]
[MemberData(nameof(Convert_SnapshotInputs))]
public Task Convert_BoundaryValues_MatchesSnapshot(decimal amount, string currency)
{
return Verifier.Verify(_sut.Convert(amount, currency)).UseParameters(amount, currency).AutoVerify(verifiedFile => !File.Exists(verifiedFile), includeBuildServer: false, throwException: false);
}
ただし、コンストラクタで受け取るだけで、どのメソッドでも使わない依存には、モックではなく null を渡します。
値の判定だけのテストに、余計な Moq が出てこないようにするためです。
null チェックがある場合や、メソッドで使っている場合は、モックを渡します。
例外が出ないことの確認(時刻・乱数を使う関数)
DateTime.Now のように実行するたびに結果が変わる関数は、戻り値を確かめられません。
代わりに、境界値の入力で 例外が出ないこと だけを確かめます。
// テスト対象
public string Label(string currency) => currency.ToUpperInvariant() + " / " + DateTime.Now.Year;
// 生成されたテスト
[Theory]
[InlineData("a")]
[InlineData("")]
[InlineData(" ")]
public void Label_BoundaryValues_DoesNotThrow(string currency)
{
var exception = Record.Exception(() => _sut.Label(currency));
Assert.Null(exception);
}
期待値は「今のコードの挙動」
生成されたテストの期待値は、今のコードがそう動く という値で、仕様として正しいかまでは保証しません。
ただ、コードを読みながら人間がテストを書いても、実は同じことになります。
人間が行うのは、生成されたテストを PR でレビューすることだけにしました。
現時点の制限
プレリリース版なので、まだ対応していないものがあります。
- 引数ではなく、フィールドなどの状態を見ている条件分岐(前提となる状態を作れないため)
- ジェネリックのクラス・メソッド、
ref/out引数 - TEST002 の理由の文章は日本語のみ
- テストプロジェクトを Fix で作ったとき、Visual Studio ではソリューションにも追加します。
VS Code では画面には表示されますが、ソリューションファイル(.sln / .slnx)には追加されません。
ソリューション単位でdotnet testを実行する場合などは、dotnet sln addで追加してください - Windows 版 Visual Studio で見つかった問題(テストプロジェクトの作成に失敗する)は
0.1.0-preview.2で修正しました。
修正後の Visual Studio での確認はこれから
おわりに
『XUnit_IngeniousGenerator』は、「コードを読めば分かるテストを、毎回手で書いたり AI に頼んだりするのはもったいない」という思いから作ったツールです。
LLM のような柔軟さはありませんが、誰が使っても同じテストが出てくる ことと、生成できないものは正直に「できない」と伝える ことを大切にしています。
もし実際に試してみて、
- 「こういうコードのテストも生成してほしい」
- 「生成されたテストのこの書き方を変えてほしい」
- 「TEST002 の理由が分かりにくい」
といったご意見・アイデアがありましたら、ぜひ本記事のコメント欄や、GitHub の Issues、NuGet ページから気軽にお知らせください!
- NuGet: XUnit_IngeniousGenerator
- GitHub: kanda-IngeniousDesign/XUnit_IngeniousGenerator
- 前回のアナライザー: CSharp_IngeniousAnalyzer
最後までお読みいただき、ありがとうございました!




