dotnet-tunit-test について
.NET 向けソース生成型テストフレームワーク TUnit の利用ガイドです。テストスイートを作成・保守する開発者向けに、並列実行、非同期アサーション、データ駆動テスト、ASP.NET Core の統合テストを説明します。
主な機能
- 01ソース生成によるテスト検出
- 02非同期アサーションと組み込みアナライザー
- 03データ駆動テスト
- 04ライフサイクルフックと並列実行制御
- 05ASP.NET Core 統合テスト
- 06カバレッジと TRX レポート
活用シーン
- 01.NET ソリューションに TUnit のテストプロジェクトを作成
- 02xUnit、NUnit、MSTest のテストを TUnit に移行
- 03ASP.NET Core アプリの統合テストを作成
- 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.Sdk | TUnit のテスト検出を妨げます。TUnit は VSTest ではなく Microsoft.Testing.Platform を使用します |
coverlet.collector / coverlet.msbuild | TUnit とは互換性がありません。代わりに組み込みの --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 Studio | Tools > Manage Preview Features で「Use testing platform server mode」を有効にします |
| Rider | Settings > Build, Execution, Deployment > Unit Testing > Testing Platform で「Testing Platform support」を有効にします |
| VS Code | C# 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 のテスト検出に失敗します |
リソース
- TUnit ドキュメント: https://tunit.dev/docs/intro
- GitHub: https://github.com/thomhurst/TUnit
- NuGet: https://www.nuget.org/packages/TUnit
- TUnit.AspNetCore: https://www.nuget.org/packages/TUnit.AspNetCore
- xUnit からの移行: https://tunit.dev/docs/migration/xunit
- NUnit からの移行: https://tunit.dev/docs/migration/nunit
- MSTest からの移行: https://tunit.dev/docs/migration/mstest