0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMなしでC#のテストコードを自動生成!XUnit_IngeniousGenerator 全機能解説

0
Last updated at Posted at 2026-10-25

はじめに

前回の記事『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)がなければクラス名に、テストファイルはあるのにテストのない関数があれば、その関数名に警告が出ます。

マウスオンでメニューを表示
image.png

クイックフィックスを選択
image.png

Fix を実行したあと
image.png

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
}

追加した関数に警告
image.png

Triple の上で Fix を実行すると、CalculatorTests.cs に Triple のテストだけが追加されます。

🔹 TEST002: 自動生成できないテストの明示

  • タイトル: 自動生成できないテストの明示
  • メッセージ: '{0}' のテストコードは自動生成できません({1})。別の方法で確認したうえで、警告を Ignore してください。
  • 解説:
    ファイル・DB・通信を直接使う関数のように、単体テストを自動では作れない関数に、理由つきで 警告を出します。
    Fix は // Ignore TEST002 を挿入するだけにしてあります。
    黙って見逃すと、「このクラスのテストはどこまで揃っているのか」が分からなくなります。
    そこで、人間が「別の方法で確認した」と判断した印を、コードに残す設計にしました。

理由つきで警告
image.png

TEST002 の Fix は Ignore のみ(他の項目は .NET 標準・Copilot の機能)
image.png

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 ページから気軽にお知らせください!

最後までお読みいただき、ありがとうございました!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?