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. 02将 xUnit、NUnit 或 MSTest 测试迁移至 TUnit
  3. 03为 ASP.NET Core 应用编写集成测试
  4. 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 TaskTUnit 的断言模型要求这样做
每个测试只验证一种逻辑行为让测试重点突出,失败消息清晰
对相关检查使用 Assert.Multiple一次查看所有失败
优先使用 [DependsOn],而非 [NotInParallel(Order)]让无关测试保持并行
对开销较大的资源使用 [ClassDataSource]使用 SharedType.PerTestSession 在测试间共享资源
测试行为,而非实现避免脆弱的模拟验证测试
在集成测试中使用 GetIsolatedName()确保并行测试隔离
将 [BeforeEvery]/[AfterEvery] 放在 GlobalHooks.cs 中便于查找全局钩子
不要安装 Microsoft.NET.Test.Sdk会导致 TUnit 测试发现失败

资源

评论

支持 Markdown