名称: dotnet-xunit 描述: “编写 xUnit v3 测试:事实、理论、夹具、并行性、IAsyncLifetime。” 用户可调用: false
dotnet-xunit
.NET 的 xUnit v3 测试框架功能。涵盖 [Fact] 和 [Theory] 属性、测试夹具(IClassFixture、ICollectionFixture)、并行执行配置、用于异步设置/清理的 IAsyncLifetime、自定义断言和 xUnit 分析器。包括 v2 兼容性说明,其中行为不同。
版本假设: xUnit v3 为主要版本(.NET 8.0+ 基线)。在 v3 行为与 v2 不同的地方,提供内联兼容性说明。xUnit v2 仍被广泛使用;许多项目在迁移期间会遇到两个版本。
范围
- [Fact] 和 [Theory] 测试属性及数据源
- 测试夹具(IClassFixture、ICollectionFixture)和共享上下文
- 并行执行配置和集合排序
- IAsyncLifetime 用于异步设置/清理
- xUnit 分析器和自定义断言
- 从 xUnit v2 迁移到 v3(TheoryDataRow、ValueTask 生命周期)
超出范围
- 测试项目脚手架 – 参见 [skill:dotnet-add-testing]
- 测试策略和测试类型决策 – 参见 [skill:dotnet-testing-strategy]
- 集成测试模式(WebApplicationFactory、Testcontainers) – 参见 [skill:dotnet-integration-testing]
- 使用 Verify 的快照测试 – 参见 [skill:dotnet-snapshot-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) |
| 断言包 | 捆绑 | 独立的 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](强类型行格式)和移除的断言消息参数上。
事实和理论
[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% 折扣 100 = 90
[InlineData(200, 25, 150)] // 25% 折扣 200 = 150
[InlineData(50, 0, 50)] // 0% 折扣 = 无变化
[InlineData(100, 100, 0)] // 100% 折扣 = 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);
}
[MemberData] with TheoryData<T>
最适合复杂数据或共享数据集:
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 }, // 无项目
{ new Order { Items = [new("SKU-1", 1)], CustomerId = "" }, false }, // 无客户
};
[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: 使用 TheoryDataRow<T> 获取强类型行
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 兼容性: v2 使用 IEnumerable<object[]> 而不是 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: 返回 ValueTask (v2 返回 Task)
ConnectionString = $"Host=localhost;Database=test_{Guid.NewGuid():N}";
// 创建数据库,运行迁移等。
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
// xUnit v3: 返回 ValueTask (v2 返回 Task)
// 删除数据库
return ValueTask.CompletedTask;
}
}
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _db;
public OrderRepositoryTests(DatabaseFixture db)
{
_db = db;
// 每个测试获取共享的数据库夹具
}
[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. 定义集合
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
// 此类无代码 -- 它是集合的标记
}
// 2. 在测试类中使用
[Collection("Database")]
public class OrderRepositoryTests
{
private readonly DatabaseFixture _db;
public OrderRepositoryTests(DatabaseFixture db)
{
_db = db;
}
[Fact]
public async Task Insert_ValidOrder_Persists()
{
// 使用共享的数据库夹具
}
}
[Collection("Database")]
public class CustomerRepositoryTests
{
private readonly DatabaseFixture _db;
public CustomerRepositoryTests(DatabaseFixture db)
{
_db = db;
}
}
IAsyncLifetime on Test Classes
用于每个测试的异步设置/清理,无需共享夹具:
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
Alice,30
Bob,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
{
// 这些测试在此集合内顺序运行
}
程序集级配置
在测试项目根目录创建 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(用于 LoggerFactory)
// + 写入 ITestOutputHelper 的日志提供程序
// 常见方法:使用简单适配器
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);
}
}
// 用法
[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]
# 允许开发期间跳过的测试
dotnet_diagnostic.xUnit1004.severity = suggestion
关键原则
- 每个
[Fact]一个事实,每个[Theory]一个概念。 如果[Theory]测试根本不同的场景,拆分为单独的[Fact]方法。 - 使用
IClassFixture用于昂贵共享资源 在单个测试类内。使用ICollectionFixture当多个类共享相同资源时。 - 不要全局禁用并行性。 相反,将共享可变状态的测试分组到命名集合中。
- 使用
IAsyncLifetime用于异步设置/清理 而不是构造函数和IDisposable。构造函数不能是异步的,IDisposable.Dispose()不等待。 - 保持测试数据接近测试。 首选
[InlineData]用于简单情况。仅当数据复杂或共享时使用[MemberData]或[ClassData]。 - 在所有测试项目中启用 xUnit 分析器。 它们捕获导致假通过或不稳定测试的常见错误。
代理注意事项
- 不要在静态方法中使用构造函数注入的
ITestOutputHelper。ITestOutputHelper是每个测试实例的;存储在实例字段中,而不是静态字段。 - 不要忘记使夹具类
public。 xUnit 要求夹具类型为 public,具有 public 无参数构造函数(或IAsyncLifetime)。非 public 夹具导致静默失败。 - 不要在同一方法上混合
[Fact]和[Theory]。 方法是事实或理论,不能两者都是。 - 不要从异步测试方法返回
void。 返回Task或ValueTask。async void测试报告假成功,因为 xUnit 无法观察异步完成。 - 不要使用
[Collection]没有匹配的[CollectionDefinition]。 未匹配的集合名称静默创建具有默认行为的隐式集合,违背目的。