关于 dotnet-xunit
dotnet-xunit 是面向 .NET 开发者的 xUnit 测试框架技能,重点介绍 xUnit v3,并附带与 v2 的兼容说明。内容涵盖测试用例、夹具、并行执行、断言和分析器。
主要功能
- 01编写 [Fact] 与 [Theory] 测试
- 02使用类级及集合级夹具
- 03配置测试并行执行
- 04实现异步初始化与清理
- 05扩展断言并使用分析器
适用场景
- 01为 .NET 代码编写参数化测试
- 02在测试间共享昂贵资源
- 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 v2 | xUnit v3 |
|---|---|---|
| 包 | xunit (2.x) | xunit.v3 |
| 运行器 | xunit.runner.visualstudio | xunit.runner.visualstudio (3.x) |
| 异步生命周期 | IAsyncLifetime | IAsyncLifetime(现在返回 ValueTask) |
| Assert 包 | 捆绑提供 | 独立的 xunit.v3.assert(可扩展性需要使用 xunit.v3.assert.source) |
| 默认并行粒度 | 每个集合 | 每个集合(相同,但可按程序集配置) |
| 超时 | [Fact] 和 [Theory] 上的 Timeout 属性 | [Fact] 和 [Theory] 上的 Timeout 属性(不变) |
| 测试输出 | ITestOutputHelper | ITestOutputHelper(不变) |
[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 用于值类型参数 | 警告 |
xUnit1025 | InlineData 在同一 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 常见陷阱
- 不要在静态方法中使用通过构造函数注入的
ITestOutputHelper。ITestOutputHelper属于单个测试实例;应将其存储在实例字段中,而不是静态字段中。 - 不要忘记将 fixture 类设为
public。 xUnit 要求 fixture 类型为 public,且具有 public 无参数构造函数(或实现IAsyncLifetime)。非 public 的 fixture 会导致静默失败。 - 不要在同一个方法上同时使用
[Fact]和[Theory]。 一个方法要么是 fact,要么是 theory,不能两者兼有。 - 异步测试方法不要返回
void。 请返回Task或ValueTask。async void测试会报告错误的成功结果,因为 xUnit 无法观察异步操作是否完成。 - 没有对应的
[CollectionDefinition]时,不要使用[Collection]。 没有匹配定义的集合名称会静默创建一个具有默认行为的隐式集合,违背使用它的初衷。