mcpField
D
dotnet-tunit-test

dotnet-tunit-test

github.com/wshaddixテスト・品質8670 回インストール35 週間前に更新
リポジトリ

dotnet-tunit-test について

.NET 向けソース生成型テストフレームワーク TUnit の利用ガイドです。テストスイートを作成・保守する開発者向けに、並列実行、非同期アサーション、データ駆動テスト、ASP.NET Core の統合テストを説明します。

主な機能

  1. 01ソース生成によるテスト検出
  2. 02非同期アサーションと組み込みアナライザー
  3. 03データ駆動テスト
  4. 04ライフサイクルフックと並列実行制御
  5. 05ASP.NET Core 統合テスト
  6. 06カバレッジと TRX レポート

活用シーン

  1. 01.NET ソリューションに TUnit のテストプロジェクトを作成
  2. 02xUnit、NUnit、MSTest のテストを TUnit に移行
  3. 03ASP.NET Core アプリの統合テストを作成
  4. 04CI/CD でテストを実行しカバレッジレポートを生成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-tunit-test

インストール後、Claude Code で/dotnet-tunit-testを入力して読み込まれたか確認します。

詳細

TUnit を使用したテスト

このスキルを使用する場面

次のような場合にこのスキルを使用します:

  • 新しい TUnit テストプロジェクトを作成する、または既存のソリューションに TUnit を追加する
  • TUnit を使用して単体テスト、統合テスト、受け入れテストを作成する
  • xUnit、NUnit、MSTest から TUnit にテストを移行する
  • [Arguments]、[MethodDataSource]、[ClassDataSource] を使用してデータ駆動型テストを設定する
  • テストライフサイクルフック([Before]/[After])を設定する
  • [NotInParallel]、[DependsOn]、並列グループを使用して並列実行を制御する
  • TUnit.AspNetCore を使用して ASP.NET Core 統合テストを作成する
  • カバレッジと TRX レポートを含め、CI/CD パイプライン向けに TUnit を設定する

TUnit とは?

TUnit は、Microsoft Testing Platform 上に構築された、最新のソース生成型 .NET テストフレームワークです。主な特徴は次のとおりです:

  • ソース生成型 - テストはリフレクションではなく、コンパイル時に検出されます
  • デフォルトで並列実行 - 高速化のため、テストは同時に実行されます
  • 非同期優先のアサーション - すべてのアサーションを await する必要があります
  • テストごとに新しいクラスインスタンスを作成 - テストクラスはテストメソッドごとに新しくインスタンス化されます
  • [TestClass] 属性は不要 - メソッドに [Test] を付けるだけです
  • Native AOT と単一ファイルをサポート - リフレクションベースのフレームワークでは動作しない環境でも使用できます
  • コードカバレッジと TRX レポートを標準搭載 - Coverlet は不要です

インストール

テンプレートから(推奨)
dotnet new install TUnit.Templates
dotnet new TUnit -n "MyApp.Tests"
手動でのセットアップ
dotnet new console --name MyApp.Tests
cd MyApp.Tests
dotnet add package TUnit --prerelease

自動生成された Program.cs は削除してください。TUnit がエントリポイントを処理します。

プロジェクトファイル
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="TUnit" Version="*" />
    </ItemGroup>
</Project>
重要:以下のパッケージは使用しないでください
パッケージ理由
Microsoft.NET.Test.SdkTUnit のテスト検出を妨げます。TUnit は VSTest ではなく Microsoft.Testing.Platform を使用します
coverlet.collector / coverlet.msbuildTUnit とは互換性がありません。代わりに組み込みの --coverage フラグを使用してください
グローバル using

TUnit は TUnit.Core、TUnit.Assertions、TUnit.Assertions.Extensions のグローバル using を自動的に提供します。テストファイルに明示的な using ステートメントを記述する必要はありません。


テストの作成

基本的なテスト
namespace MyApp.Tests;

public class CalculatorTests
{
    [Test]
    public async Task Add_TwoNumbers_ReturnsSum()
    {
        var result = 2 + 3;

        await Assert.That(result).IsEqualTo(5);
    }
}
テストメソッドのシグネチャ
[Test]
public void SyncTest()           // Valid -- synchronous, no assertions
{
    var result = Calculate(2, 3);
}

[Test]
public async Task AsyncTest()    // Recommended -- required if using assertions
{
    await Assert.That(42).IsEqualTo(42);
}

// async void is NOT allowed -- compiler error

ルール:Assert.That(...) を使用する場合、アサーションは await 可能なため、テストメソッドは 必ず async Task にする必要があります。


アサーション

TUnit のアサーションはすべて、await Assert.That(actual).SomeCondition() のパターンに従います。

基本的なアサーション
// Equality
await Assert.That(result).IsEqualTo(5);
await Assert.That(result).IsNotEqualTo(0);

// Comparison
await Assert.That(score).IsGreaterThan(70);
await Assert.That(age).IsLessThanOrEqualTo(100);
await Assert.That(temp).IsBetween(20, 30);

// Boolean
await Assert.That(isValid).IsTrue();
await Assert.That(isDeleted).IsFalse();

// Null
await Assert.That(result).IsNotNull();
await Assert.That(optional).IsNull();

// Type
await Assert.That(obj).IsTypeOf<MyClass>();
文字列のアサーション
await Assert.That(message).Contains("Hello");
await Assert.That(filename).StartsWith("test_");
await Assert.That(email).Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$");
await Assert.That(input).IsNotEmpty();
コレクションのアサーション
await Assert.That(numbers).Contains(42);
await Assert.That(items).Count().IsEqualTo(5);
await Assert.That(list).IsNotEmpty();
await Assert.That(values).All(x => x > 0);
await Assert.That(numbers).IsEquivalentTo(new[] { 5, 4, 3, 2, 1 }); // order-independent
await Assert.That(numbers).IsInOrder();
例外のアサーション
// Basic exception testing
await Assert.That(() => int.Parse("not a number"))
    .Throws<FormatException>();

// Async exception testing
await Assert.That(async () => await FailingOperationAsync())
    .Throws<HttpRequestException>();

// Exact type (no subclasses)
await Assert.That(() => throw new ArgumentNullException())
    .ThrowsExactly<ArgumentNullException>();

// Exception message
await Assert.That(() => throw new InvalidOperationException("Operation failed"))
    .Throws<InvalidOperationException>()
    .WithMessage("Operation failed");

await Assert.That(() => throw new ArgumentException("The parameter 'userId' is invalid"))
    .Throws<ArgumentException>()
    .WithMessageContaining("userId");

// ArgumentException parameter name
await Assert.That(() => ValidateUser(null!))
    .Throws<ArgumentNullException>()
    .WithParameterName("user");

// Inner exceptions
await Assert.That(() => ThrowWithInner())
    .Throws<InvalidOperationException>()
    .WithInnerException()
    .Throws<FormatException>();

// No exception thrown
await Assert.That(() => int.Parse("42"))
    .ThrowsNothing();
And/Or による連結
await Assert.That(username)
    .IsNotNull()
    .And.IsNotEmpty()
    .And.Length().IsGreaterThan(3)
    .And.Length().IsLessThan(20);

await Assert.That(statusCode)
    .IsEqualTo(200)
    .Or.IsEqualTo(201)
    .Or.IsEqualTo(204);
Assert.Multiple(すべての失敗を報告)
using (Assert.Multiple())
{
    await Assert.That(user.FirstName).IsEqualTo("John");
    await Assert.That(user.LastName).IsEqualTo("Doe");
    await Assert.That(user.Age).IsGreaterThan(18);
}
// All failures reported together, not just the first one
浮動小数点数の許容誤差
await Assert.That(3.14159).IsEqualTo(Math.PI).Within(0.001);
重要:アサーションは必ず await する
// WRONG -- assertion never executes, test always passes
Assert.That(result).IsEqualTo(5);

// CORRECT
await Assert.That(result).IsEqualTo(5);

TUnit には、await されていないアサーションを警告するアナライザーが組み込まれています。


データ駆動型テスト

[Arguments] -- コンパイル時定数
[Test]
[Arguments(1, 1, 2)]
[Arguments(1, 2, 3)]
[Arguments(2, 2, 4)]
public async Task Add_ReturnsExpectedResult(int a, int b, int expected)
{
    await Assert.That(a + b).IsEqualTo(expected);
}

DisplayName、Categories、Skip のメタデータに対応しています:

[Test]
[Arguments("Chrome", "120")]
[Arguments("Safari", "17", Skip = "Safari not available in CI")]
public async Task BrowserTest(string browser, string version) { }
[MethodDataSource] -- 動的/複雑なデータ
public static class TestData
{
    public static IEnumerable<Func<(int A, int B, int Expected)>> AdditionCases()
    {
        yield return () => (1, 2, 3);
        yield return () => (2, 2, 4);
        yield return () => (5, 5, 10);
    }
}

public class MathTests
{
    [Test]
    [MethodDataSource(typeof(TestData), nameof(TestData.AdditionCases))]
    public async Task Add_WithData(int a, int b, int expected)
    {
        await Assert.That(a + b).IsEqualTo(expected);
    }
}

参照型の場合は、各テストで新しいインスタンスが生成されるように、T ではなく Func<T> を返してください。

[ClassDataSource] -- 注入可能な共有リソース
public class TestWebServer : IAsyncInitializer, IAsyncDisposable
{
    public WebApplicationFactory<Program>? Factory { get; private set; }

    public async Task InitializeAsync()
    {
        Factory = new WebApplicationFactory<Program>();
        await Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        if (Factory != null) await Factory.DisposeAsync();
    }
}

[ClassDataSource<TestWebServer>(Shared = SharedType.PerTestSession)]
public class ApiTests(TestWebServer server)
{
    [Test]
    public async Task HealthCheck_ReturnsOk()
    {
        var client = server.Factory!.CreateClient();
        var response = await client.GetAsync("/health");

        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }
}

SharedType のオプション:

  • None(デフォルト)-- テストごとに新しいインスタンス
  • PerClass -- テストクラス内で共有
  • PerAssembly -- アセンブリ内で共有
  • PerTestSession -- テスト実行全体で単一のインスタンス
  • Keyed -- 同じ Key を持つテスト間で共有

テストのライフサイクル

テストごとのインスタンス

TUnit は、テストメソッドごとにテストクラスの 新しいインスタンス を作成します。インスタンスフィールドがテスト間で共有されることはありません。

public class MyTests
{
    private int _value;

    [Test, NotInParallel]
    public void Test1() { _value = 99; }

    [Test, NotInParallel]
    public async Task Test2()
    {
        // _value is 0 here -- different instance!
        await Assert.That(_value).IsEqualTo(0);
    }
}

共有状態が意図的に必要な場合は、static フィールドを使用してください。

セットアップフック -- [Before]
public class DatabaseTests
{
    private TestDatabase? _database;

    [Before(Test)]    // Instance method, runs before each test
    public async Task SetupDatabase()
    {
        _database = await TestDatabase.CreateAsync();
    }

    [Before(Class)]   // Must be static, runs once before all tests in class
    public static async Task ClassSetup()
    {
        await GlobalResource.InitializeAsync();
    }

    [Before(Assembly)] // Must be static, runs once before all tests in assembly
    public static async Task AssemblySetup() { }
}
クリーンアップフック -- [After]
public class DatabaseTests
{
    [After(Test)]     // Instance method, runs after each test
    public async Task Cleanup()
    {
        if (_database != null) await _database.DisposeAsync();
    }

    [After(Class)]    // Must be static, runs once after all tests in class
    public static async Task ClassCleanup() { }
}

以前の [After] メソッドが失敗した場合でも、すべての [After] メソッドが実行されます。例外は集約されます。

フックのレベル
レベル範囲静的?
[Before(Test)] / [After(Test)]各テストインスタンス
[Before(Class)] / [After(Class)]クラスごとに1回静的
[Before(Assembly)] / [After(Assembly)]アセンブリごとに1回静的
[Before(TestSession)] / [After(TestSession)]テスト実行ごとに1回静的
グローバルフック -- [BeforeEvery] / [AfterEvery]

プロジェクトのルートにある GlobalHooks.cs に記述します。

public static class GlobalHooks
{
    [BeforeEvery(Test)]
    public static void BeforeEachTest(TestContext context)
    {
        Console.WriteLine($"Starting: {context.Metadata.TestName}");
    }

    [AfterEvery(Test)]
    public static async Task AfterEachTest(TestContext context)
    {
        if (context.Execution.Result?.State == TestState.Failed)
        {
            await CaptureScreenshotAsync();
        }
    }
}
フックのパラメーター

フックではコンテキストとキャンセレーショントークンを受け取れます。

[Before(Test)]
public async Task Setup(TestContext context, CancellationToken ct)
{
    Console.WriteLine($"Setting up: {context.Metadata.TestName}");
    await SomeOperation(ct);
}

並列実行

デフォルト:テストは並列実行されます

TUnit はデフォルトですべてのテストを並列実行します。独立したステートレスなテストを作成してください。

[NotInParallel] -- 特定のテストで並列実行を無効にする
[Test, NotInParallel]
public async Task ModifiesSharedResource() { }
制約キー -- 並列グループ
// These two won't run in parallel with each other (shared key)
[Test, NotInParallel("DatabaseTest")]
public async Task DbTest1() { }

[Test, NotInParallel("DatabaseTest")]
public async Task DbTest2() { }

// This can still run in parallel with the above
[Test, NotInParallel("FileTest")]
public async Task FileTest1() { }
[DependsOn] -- 並列実行しながら順序を指定する
[Test]
public async Task Step1_CreateUser() { }

[Test]
[DependsOn(nameof(Step1_CreateUser))]
public async Task Step2_UpdateUser() { }

[Test]
[DependsOn(nameof(Step2_UpdateUser))]
public async Task Step3_DeleteUser() { }
// Other unrelated tests still run in parallel
すべての並列実行を無効にする
[assembly: NotInParallel]
同時実行するテスト数を制限する(CLI)
dotnet run -c Release --maximum-parallel-tests 8

依存性注入

public class MicrosoftDiDataSourceAttribute
    : DependencyInjectionDataSourceAttribute<IServiceScope>
{
    private static readonly IServiceProvider ServiceProvider = CreateProvider();

    public override IServiceScope CreateScope(DataGeneratorMetadata metadata)
        => ServiceProvider.CreateScope();

    public override object? Create(IServiceScope scope, Type type)
        => scope.ServiceProvider.GetService(type);

    private static IServiceProvider CreateProvider()
        => new ServiceCollection()
            .AddSingleton<IMyService, MyService>()
            .AddTransient<IRepository, Repository>()
            .BuildServiceProvider();
}

[MicrosoftDiDataSource]
public class ServiceTests(IMyService service, IRepository repo)
{
    [Test]
    public async Task ServiceWorks()
    {
        var result = await service.DoWorkAsync();
        await Assert.That(result).IsNotNull();
    }
}

ASP.NET Core の統合テスト

TUnit.AspNetCore パッケージをインストールします。

dotnet add package TUnit.AspNetCore
ファクトリー+基底クラスのパターン
using TUnit.AspNetCore;

public class AppFactory : TestWebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration((_, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "ConnectionStrings:Default", "..." }
            });
        });
    }
}

public abstract class IntegrationTestBase : WebApplicationTest<AppFactory, Program> { }
統合テストの作成
public class TodoApiTests : IntegrationTestBase
{
    [Test]
    public async Task GetTodos_ReturnsOk()
    {
        var client = Factory.CreateClient();
        var response = await client.GetAsync("/todos");

        await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
    }

    // Override per-test services
    protected override void ConfigureTestServices(IServiceCollection services)
    {
        services.ReplaceService<IEmailService>(new FakeEmailService());
    }

    // Override per-test configuration
    protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
    {
        config.AddInMemoryCollection(new Dictionary<string, string?>
        {
            { "Feature:Enabled", "true" }
        });
    }
}
共有コンテナーによるテストの分離

各テストには、リソースを分離するための一意の ID が割り当てられます。

public abstract class DatabaseTestBase : IntegrationTestBase
{
    protected string TableName { get; private set; } = null!;

    protected override async Task SetupAsync()
    {
        TableName = GetIsolatedName("todos"); // "Test_42_todos"
        await CreateTableAsync(TableName);
    }

    protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
    {
        config.AddInMemoryCollection(new Dictionary<string, string?>
        {
            { "Database:TableName", TableName }
        });
    }

    [After(HookType.Test)]
    public async Task Cleanup() => await DropTableAsync(TableName);
}

テストの実行

コマンドライン
# Preferred -- easier flag passing
dotnet run -c Release

# With coverage and TRX report
dotnet run -c Release --coverage --report-trx

# Using dotnet test (flags go after --)
dotnet test -c Release -- --coverage --report-trx
IDE のサポート
IDE必要な設定
Visual StudioTools > Manage Preview Features で「Use testing platform server mode」を有効にします
RiderSettings > Build, Execution, Deployment > Unit Testing > Testing Platform で「Testing Platform support」を有効にします
VS CodeC# Dev Kit をインストールし、「Dotnet > Test Window > Use Testing Platform Protocol」を有効にします

CI/CD との統合

GitHub Actions
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with:
        dotnet-version: 9.0.x
    - run: dotnet restore
    - run: dotnet build --no-restore -c Release
    - run: dotnet run --project tests/MyApp.Tests -c Release --no-build -- --report-trx --coverage
    - uses: actions/upload-artifact@v4
      if: always()
      with:
        name: test-results
        path: |
          **/TestResults/*.trx
          **/TestResults/**/coverage.cobertura.xml

テストの命名規則

説明的な名前を使用します:Method_Scenario_ExpectedBehavior

[Test]
public async Task CalculateTotal_WithDiscount_ReturnsReducedPrice() { }

[Test]
public async Task CreateUser_WithDuplicateEmail_ThrowsConflictException() { }

テストの整理

本番コードの構造に合わせます。

MyApp/
  Services/
    OrderService.cs
MyApp.Tests/
  Services/
    OrderServiceTests.cs

よくある間違い

アサーションの await を忘れる
// WRONG -- silently passes
Assert.That(result).IsEqualTo(5);

// CORRECT
await Assert.That(result).IsEqualTo(5);
テスト間でインスタンスの状態を使用する
// WRONG -- each test gets a new class instance
private int _counter;

[Test, NotInParallel]
public void Increment() { _counter++; }

[Test, NotInParallel]
public async Task Check()
{
    await Assert.That(_counter).IsEqualTo(1); // Fails -- _counter is 0
}
Microsoft.NET.Test.Sdk を使用する

このパッケージは VSTest ベースのフレームワーク用です。TUnit は Microsoft.Testing.Platform を使用します。このパッケージを含めると、テストの検出に失敗します。

テストの実行順序に依存する

テストはデフォルトで並列実行されます。[DependsOn] または [NotInParallel(Order = N)] を使用しない限り、順序を前提にしないでください。

モックの使いすぎ
// BAD -- mock everything
var mockLogger = new Mock<ILogger>();
var mockValidator = new Mock<IValidator>();
var mockCalculator = new Mock<IPriceCalculator>();

// BETTER -- only mock external dependencies
var logger = NullLogger.Instance;
var validator = new OrderValidator();            // Real, fast
var mockRepository = new Mock<IOrderRepository>(); // Mock database

ベストプラクティスのまとめ

プラクティス理由
アサーションは必ず await するawait されていないアサーションは、何も通知せずに成功します
テストメソッドには async Task を使用するTUnit のアサーションモデルで必須です
1つのテストにつき、1つの論理的な振る舞いを検証するテストの焦点が明確になり、失敗メッセージも分かりやすくなります
関連するチェックには Assert.Multiple を使用するすべての失敗を一度に確認できます
[NotInParallel(Order)] より [DependsOn] を優先する関連のないテストの並列実行を維持できます
負荷の高いリソースには [ClassDataSource] を使用するSharedType.PerTestSession でテスト間で共有できます
実装ではなく振る舞いをテストする壊れやすいモック検証テストを避けられます
統合テストでは GetIsolatedName() を使用する並列テストの分離を確実にします
[BeforeEvery] / [AfterEvery] は GlobalHooks.cs に配置するグローバルフックを見つけやすくなります
Microsoft.NET.Test.Sdk をインストールしないTUnit のテスト検出に失敗します

リソース

コメント