3. 单元测试基础与xUnit实战
在 ASP.NET Core 项目中,单元测试是质量工程的第一道防线。它不依赖数据库、网络或文件系统,只针对一个类或一个方法验证行为是否符合预期。xUnit 是 .NET 生态中最主流的测试框架,本节围绕 Fact 与 Theory、FluentAssertions 断言、测试命名与组织、IClassFixture 与 ICollectionFixture 夹具,以及如何为纯业务逻辑和领域服务编写首批单元测试展开。
Fact 与 Theory:固定用例与数据驱动
[Fact] 表示一个无参数、结果固定的测试用例,适合验证单一确定行为。例如验证订单金额计算:
[Fact]
public void CalculateTotal_两件单价10元商品_返回20元()
{
var calculator = new OrderCalculator();
var total = calculator.CalculateTotal(new[] { 10m, 10m });
Assert.Equal(20m, total);
}
[Theory] 配合 [InlineData] 实现数据驱动,同一逻辑用多组输入验证,避免复制粘贴:
[Theory]
[InlineData(0, 0)]
[InlineData(1, 10)]
[InlineData(3, 30)]
public void CalculateTotal_按数量计算_返回单价乘数量(int qty, decimal expected)
{
var calculator = new OrderCalculator();
Assert.Equal(expected, calculator.CalculateTotal(qty, 10m));
}
选择原则:行为唯一且无需参数化时用 Fact;同一规则需要覆盖边界值、等价类时用 Theory。Theory 的每组 InlineData 在测试资源管理器中都是独立用例,失败时能精确定位到具体数据行。
FluentAssertions:让断言表达意图
xUnit 自带的 Assert.Equal 在复杂对象比较时信息有限。FluentAssertions 提供链式语法,失败信息更可读:
result.Should().Be(20m);
order.Items.Should().HaveCount(2)
.And.Contain(i => i.Price == 10m);
action.Should().Throw<ArgumentNullException>()
.WithParameterName("items");
常用断言包括 Be、BeEquivalentTo(忽略属性顺序做深度比较)、Contain、Throw、NotBeNull。建议统一使用 FluentAssertions,减少 Assert 与 Should 混用带来的风格割裂。
测试命名与组织
推荐命名格式:方法名_场景_预期结果,中文或英文均可,关键是让失败信息自解释。例如 CalculateTotal_数量为负_抛出ArgumentException。组织上按“被测类”分文件夹,一个测试类对应一个被测类,测试类名加 Tests 后缀。测试项目独立为 *.Tests,通过项目引用访问被测代码,避免生产项目引用测试包。
测试夹具:IClassFixture 与 ICollectionFixture
当多个测试需要共享同一份昂贵资源(如内存数据库、配置对象)时,使用夹具避免重复创建。IClassFixture<T> 让同一个测试类内所有测试共享一个实例;ICollectionFixture<T> 让多个测试类共享,需配合 [Collection] 标记。
public class DbFixture : IDisposable
{
public AppDbContext Context { get; }
public DbFixture() => Context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("test").Options);
public void Dispose() => Context.Dispose();
}
[CollectionDefinition("db")]
public class DbCollection : ICollectionFixture<DbFixture> { }
[Collection("db")]
public class OrderServiceTests
{
private readonly DbFixture _fixture;
public OrderServiceTests(DbFixture fixture) => _fixture = fixture;
}
注意:IClassFixture 每个测试类一份实例,ICollectionFixture 整个集合一份实例,后者适合真正全局只读或需串行访问的资源。若测试间会修改共享状态,应改用类夹具或每个测试独立创建,避免相互污染。
为纯业务逻辑与领域服务编写首批测试
纯业务逻辑无外部依赖,测试最直接。以订单折扣领域服务为例,规则为:满 100 元减 10 元,满 200 元减 30 元,VIP 额外 95 折。先写测试再实现,或实现后补测均可,关键是覆盖边界:99、100、199、200 以及 VIP 组合。
public class DiscountService
{
public decimal Apply(decimal amount, bool isVip)
{
decimal discount = amount switch
{
>= 200m => 30m,
>= 100m => 10m,
_ => 0m
};
var result = amount - discount;
return isVip ? result * 0.95m : result;
}
}
对应测试用 Theory 覆盖边界,用 FluentAssertions 断言:
[Theory]
[InlineData(99, false, 99)]
[InlineData(100, false, 90)]
[InlineData(200, false, 170)]
[InlineData(200, true, 161.5)]
public void Apply_按金额与VIP计算_返回折后价(
decimal amount, bool isVip, decimal expected)
{
var service = new DiscountService();
service.Apply(amount, isVip).Should().Be(expected);
}
领域服务若依赖仓储接口,可用手写 Fake 或 Moq 替换,只验证业务规则本身,不触碰真实数据库。首批测试建议优先覆盖:正常路径、边界值、异常输入(null、负数)、以及状态变化后的结果。
把上述结构落地后,一个典型的测试项目目录如下:Tests/OrderServiceTests.cs、Tests/DiscountServiceTests.cs、Fixtures/DbFixture.cs。运行 dotnet test,观察通过数与失败用例名称,即可判断业务规则是否被正确覆盖。首批测试不必追求 100% 覆盖率,但应确保核心折扣、金额计算、异常分支都有对应用例,为后续重构提供安全网。