mcpField
D
dotnet-xunit

dotnet-xunit

github.com/wshaddix测试与质量86614 次安装35 周前更新
代码仓库

关于 dotnet-xunit

dotnet-xunit 是面向 .NET 开发者的 xUnit 测试框架技能,重点介绍 xUnit v3,并附带与 v2 的兼容说明。内容涵盖测试用例、夹具、并行执行、断言和分析器。

主要功能

  1. 01编写 [Fact] 与 [Theory] 测试
  2. 02使用类级及集合级夹具
  3. 03配置测试并行执行
  4. 04实现异步初始化与清理
  5. 05扩展断言并使用分析器

适用场景

  1. 01为 .NET 代码编写参数化测试
  2. 02在测试间共享昂贵资源
  3. 03迁移 xUnit v2 测试到 v3

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/dotnet-xunit确认已加载。

详细介绍

dotnet-xunit

面向 .NET 的 xUnit v3 测试框架功能。涵盖 [Fact] 和 [Theory] 特性、测试夹具(IClassFixture、ICollectionFixture)、并行执行配置、用于异步初始化和清理的 IAsyncLifetime、自定义断言以及 xUnit 分析器。还包含行为存在差异时的 v2 兼容性说明。

版本假设: 主要针对 xUnit v3(基线为 .NET 8.0+)。v3 与 v2 行为不同时,会附上兼容性说明。xUnit v2 仍被广泛使用;许多项目在迁移期间会同时遇到这两个版本。

不在范围内: 测试项目脚手架(创建 xUnit 项目、包引用、Directory.Build.props)由 [skill:dotnet-add-testing] 负责。测试策略和测试类型选择由 [skill:dotnet-testing-strategy] 介绍。集成测试模式(WebApplicationFactory、Testcontainers)由 [skill:dotnet-integration-testing] 介绍。

前置条件: 已通过 [skill:dotnet-add-testing] 搭建测试项目并引用 xUnit 包。运行 [skill:dotnet-version-detection],确认基线为 .NET 8.0+,以支持 xUnit v3。

交叉引用:[skill:dotnet-testing-strategy] 介绍测试什么以及如何测试,[skill:dotnet-integration-testing] 介绍如何将 xUnit 与 WebApplicationFactory 和 Testcontainers 结合使用。


xUnit v3 与 v2:主要变化

功能xUnit v2xUnit v3
包xunit (2.x)xunit.v3
运行器xunit.runner.visualstudioxunit.runner.visualstudio (3.x)
异步生命周期IAsyncLifetimeIAsyncLifetime(现在返回 ValueTask)
Assert 包捆绑提供独立的 xunit.v3.assert(可扩展性需要使用 xunit.v3.assert.source)
默认并行粒度每个集合每个集合(相同,但可按程序集配置)
超时[Fact] 和 [Theory] 上的 Timeout 属性[Fact] 和 [Theory] 上的 Timeout 属性(不变)
测试输出ITestOutputHelperITestOutputHelper(不变)
[ClassData]返回 IEnumerable<object[]>返回 IEnumerable<TheoryDataRow<T>>(强类型)
[MemberData]返回 IEnumerable<object[]>支持 TheoryData<T> 和 TheoryDataRow<T>
断言消息Assert 方法上的可选字符串参数已移除,改用自定义断言(v3.0);需要显式消息时使用 Assert.Fail()

v2 兼容性说明: 如果从 v2 迁移,请将 xunit 包替换为 xunit.v3。大多数 [Fact] 和 [Theory] 测试无需修改即可运行。主要迁移工作涉及 IAsyncLifetime(返回类型改为 ValueTask)、[ClassData](强类型行格式)以及已移除的断言消息参数。


Facts 和 Theories

[Fact] —— 单个测试用例

对不带参数的测试使用 [Fact]:

public class DiscountCalculatorTests
{
    [Fact]
    public void Apply_NegativePercentage_ThrowsArgumentOutOfRangeException()
    {
        var calculator = new DiscountCalculator();

        var ex = Assert.Throws<ArgumentOutOfRangeException>(
            () => calculator.Apply(100m, percentage: -5));

        Assert.Equal("percentage", ex.ParamName);
    }

    [Fact]
    public async Task ApplyAsync_ValidDiscount_ReturnsDiscountedPrice()
    {
        var calculator = new DiscountCalculator();

        var result = await calculator.ApplyAsync(100m, percentage: 15);

        Assert.Equal(85m, result);
    }
}
[Theory] —— 参数化测试

使用 [Theory],以不同输入运行相同的测试逻辑。

[InlineData]

适用于简单值类型:

[Theory]
[InlineData(100, 10, 90)]      // 10% off 100 = 90
[InlineData(200, 25, 150)]     // 25% off 200 = 150
[InlineData(50, 0, 50)]        // 0% off = no change
[InlineData(100, 100, 0)]      // 100% off = 0
public void Apply_VariousInputs_ReturnsExpectedPrice(
    decimal price, decimal percentage, decimal expected)
{
    var calculator = new DiscountCalculator();

    var result = calculator.Apply(price, percentage);

    Assert.Equal(expected, result);
}
使用 TheoryData<T> 的 [MemberData]

适用于复杂数据或共享数据集:

public class OrderValidatorTests
{
    public static TheoryData<Order, bool> ValidationCases => new()
    {
        { new Order { Items = [new("SKU-1", 1)], CustomerId = "C1" }, true },
        { new Order { Items = [], CustomerId = "C1" }, false },              // no items
        { new Order { Items = [new("SKU-1", 1)], CustomerId = "" }, false }, // no customer
    };

    [Theory]
    [MemberData(nameof(ValidationCases))]
    public void IsValid_VariousOrders_ReturnsExpected(Order order, bool expected)
    {
        var validator = new OrderValidator();

        var result = validator.IsValid(order);

        Assert.Equal(expected, result);
    }
}
[ClassData]

适用于在多个测试类之间共享的数据:

// xUnit v3: use TheoryDataRow<T> for strongly-typed rows
public class CurrencyConversionData : IEnumerable<TheoryDataRow<string, string, decimal>>
{
    public IEnumerator<TheoryDataRow<string, string, decimal>> GetEnumerator()
    {
        yield return new("USD", "EUR", 0.92m);
        yield return new("GBP", "USD", 1.27m);
        yield return new("EUR", "GBP", 0.86m);
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// xUnit v2 compatibility: v2 uses IEnumerable<object[]> instead of TheoryDataRow<T>
// public class CurrencyConversionData : IEnumerable<object[]>
// {
//     public IEnumerator<object[]> GetEnumerator()
//     {
//         yield return new object[] { "USD", "EUR", 0.92m };
//     }
//     IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
// }

[Theory]
[ClassData(typeof(CurrencyConversionData))]
public void Convert_KnownPairs_ReturnsExpectedRate(
    string from, string to, decimal expectedRate)
{
    var converter = new CurrencyConverter();

    var rate = converter.GetRate(from, to);

    Assert.Equal(expectedRate, rate, precision: 2);
}

夹具:共享初始化和清理

夹具可在多个测试间共享开销较大的资源,同时保持测试隔离。

IClassFixture<T> —— 在每个测试类中共享

当同一个类中的多个测试需要共享开销较大的资源(数据库连接、配置)时使用:

public class DatabaseFixture : IAsyncLifetime
{
    public string ConnectionString { get; private set; } = "";

    public ValueTask InitializeAsync()
    {
        // xUnit v3: returns ValueTask (v2 returns Task)
        ConnectionString = $"Host=localhost;Database=test_{Guid.NewGuid():N}";
        // Create database, run migrations, etc.
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        // xUnit v3: returns ValueTask (v2 returns Task)
        // Drop database
        return ValueTask.CompletedTask;
    }
}

public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _db;

    public OrderRepositoryTests(DatabaseFixture db)
    {
        _db = db;
        // Each test gets the shared database fixture
    }

    [Fact]
    public async Task GetById_ExistingOrder_ReturnsOrder()
    {
        var repo = new OrderRepository(_db.ConnectionString);

        var result = await repo.GetByIdAsync(KnownOrderId);

        Assert.NotNull(result);
    }
}

v2 兼容性说明: 在 xUnit v2 中,IAsyncLifetime.InitializeAsync() 和 DisposeAsync() 返回 Task。在 v3 中,它们返回 ValueTask。迁移时请相应更改返回类型。

ICollectionFixture<T> —— 在多个测试类之间共享

当多个测试类需要相同的开销较大的资源时使用:

// 1. Define the collection
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
    // This class has no code -- it is a marker for the collection
}

// 2. Use in test classes
[Collection("Database")]
public class OrderRepositoryTests
{
    private readonly DatabaseFixture _db;

    public OrderRepositoryTests(DatabaseFixture db)
    {
        _db = db;
    }

    [Fact]
    public async Task Insert_ValidOrder_Persists()
    {
        // Uses the shared database fixture
    }
}

[Collection("Database")]
public class CustomerRepositoryTests
{
    private readonly DatabaseFixture _db;

    public CustomerRepositoryTests(DatabaseFixture db)
    {
        _db = db;
    }
}
测试类上的 IAsyncLifetime

无需共享夹具时,用于每个测试的异步初始化和清理:

public class FileProcessorTests : IAsyncLifetime
{
    private string _tempDir = "";

    public ValueTask InitializeAsync()
    {
        _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
        Directory.CreateDirectory(_tempDir);
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        if (Directory.Exists(_tempDir))
            Directory.Delete(_tempDir, recursive: true);
        return ValueTask.CompletedTask;
    }

    [Fact]
    public async Task Process_CsvFile_ExtractsRecords()
    {
        var filePath = Path.Combine(_tempDir, "data.csv");
        await File.WriteAllTextAsync(filePath, "Name,Age\nAlice,30\nBob,25");

        var processor = new FileProcessor();
        var records = await processor.ProcessAsync(filePath);

        Assert.Equal(2, records.Count);
    }
}

并行执行

默认行为

xUnit 会按顺序运行同一集合中的测试类,但会并行运行不同集合中的测试类。每个未显式标注 [Collection] 特性的测试类都属于各自的隐式集合,因此默认情况下,测试类会并行运行。

控制并行度
禁用特定测试的并行运行

将共享可变状态的测试放在同一集合中:

[CollectionDefinition("Sequential", DisableParallelization = true)]
public class SequentialCollection { }

[Collection("Sequential")]
public class StatefulServiceTests
{
    // These tests run sequentially within this collection
}
程序集级配置

在测试项目根目录中创建 xunit.runner.json:

{
    "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
    "parallelizeAssembly": false,
    "parallelizeTestCollections": true,
    "maxParallelThreads": 4
}

确保将其复制到输出目录:

<ItemGroup>
  <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

v2 兼容性说明: 在 v2 中,通过 xunit.runner.json 或程序集特性进行配置。v3 保留了对 xunit.runner.json 的支持,且属性名称相同。


测试输出

ITestOutputHelper

捕获会显示在测试结果中的诊断输出:

public class DiagnosticTests
{
    private readonly ITestOutputHelper _output;

    public DiagnosticTests(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public async Task ProcessBatch_LargeDataset_CompletesWithinTimeout()
    {
        var sw = Stopwatch.StartNew();

        var result = await processor.ProcessBatchAsync(largeDataset);

        sw.Stop();
        _output.WriteLine($"Processed {result.Count} items in {sw.ElapsedMilliseconds}ms");
        Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5));
    }
}
与 ILogger 集成

将 xUnit 输出桥接到 Microsoft.Extensions.Logging,用于集成测试:

// NuGet: Microsoft.Extensions.Logging (for LoggerFactory)
// + a logging provider that writes to ITestOutputHelper
// Common approach: use a simple adapter
public class XunitLoggerProvider : ILoggerProvider
{
    private readonly ITestOutputHelper _output;

    public XunitLoggerProvider(ITestOutputHelper output) => _output = output;

    public ILogger CreateLogger(string categoryName) =>
        new XunitLogger(_output, categoryName);

    public void Dispose() { }
}

public class XunitLogger(ITestOutputHelper output, string category) : ILogger
{
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
    public bool IsEnabled(LogLevel logLevel) => true;

    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
        Exception? exception, Func<TState, Exception?, string> formatter)
    {
        output.WriteLine($"[{logLevel}] {category}: {formatter(state, exception)}");
        if (exception is not null)
            output.WriteLine(exception.ToString());
    }
}

自定义断言

使用自定义方法扩展 Assert

创建特定于领域的断言,让测试代码更简洁:

public static class OrderAssert
{
    public static void HasStatus(Order order, OrderStatus expected)
    {
        Assert.NotNull(order);
        if (order.Status != expected)
        {
            throw Xunit.Sdk.EqualException.ForMismatchedValues(
                expected, order.Status);
        }
    }

    public static void ContainsItem(Order order, string sku, int quantity)
    {
        Assert.NotNull(order);
        var item = Assert.Single(order.Items, i => i.Sku == sku);
        Assert.Equal(quantity, item.Quantity);
    }
}

// Usage
[Fact]
public void Complete_ValidOrder_SetsCompletedStatus()
{
    var order = new Order();
    order.Complete();

    OrderAssert.HasStatus(order, OrderStatus.Completed);
}
使用 Assert.Multiple(xUnit v3)

将相关断言分组,即使其中一项失败,也会评估所有断言:

[Fact]
public void CreateOrder_ValidRequest_SetsAllProperties()
{
    var order = OrderFactory.Create(request);

    Assert.Multiple(
        () => Assert.Equal("cust-123", order.CustomerId),
        () => Assert.Equal(OrderStatus.Pending, order.Status),
        () => Assert.NotEqual(Guid.Empty, order.Id),
        () => Assert.NotEmpty(order.Items)
    );
}

v2 兼容性说明: Assert.Multiple 是 xUnit v3 中新增的功能。在 v2 中,请使用单独的断言——测试会在首次失败时停止。


xUnit 分析器

xunit.analyzers 包(随 xUnit v3 一同提供)可在编译时捕获常见的测试编写错误。

重要规则
规则说明严重性
xUnit1004不应跳过测试方法信息
xUnit1012不应将 null 用于值类型参数警告
xUnit1025InlineData 在同一 Theory 中应保持唯一警告
xUnit2000应将常量和字面量用作预期参数警告
xUnit2002不要对值类型使用 null 检查警告
xUnit2007不要使用 typeof 表达式检查类型警告
xUnit2013不要使用相等性检查来检查集合大小警告
xUnit2017不要使用 Contains() 检查集合中是否存在某个值警告
禁用特定规则

在测试项目的 .editorconfig 中:

[tests/**.cs]
# Allow skipped tests during development
dotnet_diagnostic.xUnit1004.severity = suggestion

核心原则

  • 每个 [Fact] 验证一个事实,每个 [Theory] 验证一个概念。 如果一个 [Theory] 测试了本质上不同的场景,请拆分为多个独立的 [Fact] 方法。
  • 使用 IClassFixture 在单个测试类中共享开销较大的资源。 多个类需要共享同一资源时,使用 ICollectionFixture。
  • 不要全局禁用并行运行。 而是将共享可变状态的测试分组到命名集合中。
  • 使用 IAsyncLifetime 进行异步设置和清理, 而不是使用构造函数和 IDisposable。构造函数不能是异步的,且 IDisposable.Dispose() 不会等待。
  • 让测试数据靠近测试。 简单情形优先使用 [InlineData]。仅当数据复杂或需要共享时,才使用 [MemberData] 或 [ClassData]。
  • 在所有测试项目中启用 xUnit 分析器。 它们可以捕获可能导致测试错误通过或不稳定的常见错误。

Agent 常见陷阱

  1. 不要在静态方法中使用通过构造函数注入的 ITestOutputHelper。 ITestOutputHelper 属于单个测试实例;应将其存储在实例字段中,而不是静态字段中。
  2. 不要忘记将 fixture 类设为 public。 xUnit 要求 fixture 类型为 public,且具有 public 无参数构造函数(或实现 IAsyncLifetime)。非 public 的 fixture 会导致静默失败。
  3. 不要在同一个方法上同时使用 [Fact] 和 [Theory]。 一个方法要么是 fact,要么是 theory,不能两者兼有。
  4. 异步测试方法不要返回 void。 请返回 Task 或 ValueTask。async void 测试会报告错误的成功结果,因为 xUnit 无法观察异步操作是否完成。
  5. 没有对应的 [CollectionDefinition] 时,不要使用 [Collection]。 没有匹配定义的集合名称会静默创建一个具有默认行为的隐式集合,违背使用它的初衷。

参考资料

评论

支持 Markdown