关于 dotnet-tunit-test
面向 .NET 开发者的 TUnit 测试框架使用指南,适用于新建测试项目、编写各类测试或从其他框架迁移。介绍源码生成、默认并行执行、异步断言及 ASP.NET Core 集成测试。
主要功能
- 01源码生成式测试发现
- 02异步断言与内置分析器
- 03数据驱动测试
- 04生命周期钩子与并行控制
- 05ASP.NET Core 集成测试
- 06覆盖率和 TRX 报告
适用场景
- 01为 .NET 解决方案创建 TUnit 测试项目
- 02将 xUnit、NUnit 或 MSTest 测试迁移至 TUnit
- 03为 ASP.NET Core 应用编写集成测试
- 04在 CI/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 集成测试 - 为 CI/CD 流水线配置 TUnit,并生成覆盖率和 TRX 报告
什么是 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 使用 Microsoft.Testing.Platform,而不是 VSTest |
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(...),测试方法必须是 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);
重要:始终等待断言完成
// WRONG -- assertion never executes, test always passes
Assert.That(result).IsEqualTo(5);
// CORRECT
await Assert.That(result).IsEqualTo(5);
TUnit 内置了一个分析器,可对未等待的断言发出警告。
数据驱动测试
[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);
}
}
对于引用类型,请返回 Func<T>(而不是 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)] | 每个类执行一次 | 静态 |
[Before(Assembly)] / [After(Assembly)] | 每个程序集执行一次 | 静态 |
[Before(TestSession)] / [After(TestSession)] | 每次测试运行执行一次 | 静态 |
全局钩子 -- [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]
限制并发测试数量(命令行)
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
常见错误
忘记等待断言
// 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 | 未等待的断言会静默通过 |
测试方法使用 async Task | TUnit 的断言模型要求这样做 |
| 每个测试只验证一种逻辑行为 | 让测试重点突出,失败消息清晰 |
对相关检查使用 Assert.Multiple | 一次查看所有失败 |
优先使用 [DependsOn],而非 [NotInParallel(Order)] | 让无关测试保持并行 |
对开销较大的资源使用 [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