1. xUnit 与 NUnit 结构
一句话总结: xUnit 与 NUnit 是 .NET 两大测试框架,用 [Fact] 描述无参测试、[Theory] 描述参数化测试,断言库统一验证行为结果。
测试框架负责发现与执行测试。xUnit 的 [Fact] 是普通测试,[Theory] + [InlineData] 是参数化测试;NUnit 对应 [Test]、[TestCase]。两者都支持分组、异步、生命周期钩子,命名与结构约定略有差异。
// xUnit 测试类
public class OrderCalculatorTests
{
private readonly OrderCalculator _calc = new();
[Fact]
public void CalculateTotal_WithTwoLines_ReturnsSum()
{
var order = new Order
{
Lines = [ new(10m), new(20m) ]
};
var total = _calc.CalculateTotal(order);
Assert.Equal(30m, total);
}
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 0.1, 90)]
[InlineData(100, 0.5, 50)]
public void ApplyDiscount_WithRate_ReturnsExpected(decimal price,
decimal discount, decimal expected)
{
Assert.Equal(expected, _calc.ApplyDiscount(price, discount));
}
}
// NUnit 等价写法
[TestFixture]
public class OrderCalculatorTests
{
private OrderCalculator _calc;
[SetUp]
public void Setup() => _calc = new OrderCalculator();
[Test]
public void CalculateTotal_WithTwoLines_ReturnsSum() { ... }
[TestCase(100, 0, 100)]
[TestCase(100, 0.1, 90)]
public void ApplyDiscount_WithRate_ReturnsExpected(
decimal price, decimal discount, decimal expected) { ... }
}
| 能力 | xUnit | NUnit |
|---|---|---|
| 普通测试 | [Fact] | [Test] |
| 参数化 | [Theory] / [InlineData] | [TestCase] |
| 初始化 | 构造函数 | [SetUp] |
| 断言 | Assert.* | Assert.That(..., Is.EqualTo(...)) |
| 兼容 | .NET 8+ | 全平台 |
避坑: 测试类的初始化钩子语义不同——xUnit 每次测试都新建实例,构造函数就是 per-test 初始化;NUnit 的
[SetUp]也是每次,但[OneTimeSetUp]是整个 fixture 一次。共享状态放错了生命周期,测试就会互相污染。
2. 测试的组织与命名
一句话总结: 好的测试组织让失败信息自带上下文,命名遵循「被测方法_场景_期望结果」,Arrange-Act-Assert 三段式保持每个测试独立可读。
测试不是写得多就好,而是失败时能快速定位。命名约定 Method_Scenario_Expected 让测试名本身就是文档;每个测试只测一件事;公共准备逻辑抽到辅助方法或 Fixture。
public class OrderServiceTests
{
private readonly OrderService _service;
private readonly Mock<IOrderRepository> _repo = new();
public OrderServiceTests()
{
_service = new OrderService(_repo.Object);
}
[Fact]
public async Task GetById_OrderExists_ReturnsOrder()
{
// Arrange
var order = new Order { Id = 42, Total = 100m };
_repo.Setup(r => r.FindAsync(42))
.ReturnsAsync(order);
// Act
var result = await _service.GetByIdAsync(42);
// Assert
Assert.NotNull(result);
Assert.Equal(42, result.Id);
_repo.Verify(r => r.FindAsync(42), Times.Once);
}
[Fact]
public async Task GetById_OrderMissing_ReturnsNull()
{
_repo.Setup(r => r.FindAsync(It.IsAny<int>()))
.ReturnsAsync((Order?)null);
var result = await _service.GetByIdAsync(999);
Assert.Null(result);
}
}
| 分层 | 目录 | 职责 |
|---|---|---|
| 单元测试 | Shop.Api.Tests | 单类逻辑 |
| 集成测试 | Shop.Api.IntegrationTests | 真实组件协作 |
| 端到端 | e2e/ | 全链路 |
| 共享 | Test.Shared | Fixture 与工具 |
避坑: 测试命名里避免模糊词(
Test1、Method)。失败报告只有「Assert.Equal失败」是不够的,名字 + 断言消息 + Arrange 注释三者一起,才能让半夜被 pager 叫醒的人三分钟定位问题。每个测试一个断言主题,别堆十个断言在一个 Fact 里。
3. Moq 模拟与行为隔离
一句话总结: Moq 创建接口/虚类的替身,Setup 定义「当调用 X 时返回 Y」,Verify 断言「X 确实被调用过」,让被测类与真实依赖解耦。
单元测试只测被测类的逻辑,外部依赖(仓储、HTTP 客户端、消息总线)用 Moq 替身。Mock<T> 的 Setup 控制行为、Verify 检查交互、It.IsAny<T> / It.IsInRange 做参数匹配。
public interface IOrderRepository
{
Task<Order?> FindAsync(int id);
Task SaveAsync(Order order);
}
public class OrderService(IOrderRepository repo, IPaymentClient payments)
{
public async Task<Order> CheckoutAsync(int id)
{
var order = await repo.FindAsync(id)
?? throw new KeyNotFoundException($"订单 {id} 不存在");
await payments.ChargeAsync(order.CustomerId, order.Total);
order.MarkPaid();
await repo.SaveAsync(order);
return order;
}
}
// Moq 行为与验证
[Fact]
public async Task Checkout_PaymentSucceeds_MarksOrderPaid()
{
var order = new Order { Id = 1, CustomerId = 9, Total = 50m };
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.FindAsync(1)).ReturnsAsync(order);
var payments = new Mock<IPaymentClient>();
payments.Setup(p => p.ChargeAsync(9, 50m)).Returns(Task.CompletedTask);
var svc = new OrderService(repo.Object, payments.Object);
var result = await svc.CheckoutAsync(1);
Assert.True(result.IsPaid);
payments.Verify(p => p.ChargeAsync(9, 50m), Times.Once);
repo.Verify(r => r.SaveAsync(order), Times.Once);
}
| Moq 语法 | 用途 |
|---|---|
Setup(...).ReturnsAsync(...) | 定义返回值 |
Setup(...).ThrowsAsync(...) | 定义抛异常 |
It.IsAny<T>() | 任意参数匹配 |
Verify(..., Times.Once) | 断言调用次数 |
MockBehavior.Strict | 未 Setup 的调用直接失败 |
避坑: 过度模拟会让测试和实现绑死——Mock 的行为一旦与真实实现漂移,测试就是「绿灯的谎言」。对「不可控外部」(第三方 API、时钟、随机数)用 Mock;对「自己写的逻辑」优先用真实对象。另外
MockBehavior.Strict只在契约需要严格时用,默认 Loose 更务实。
4. 异步测试与并发验证
一句话总结: 异步测试用 async Task 直接 await,用 SemaphoreSlim、TaskCompletionSource 与延迟控制并发时序,验证竞态与取消行为。
异步代码的测试要点是「让异步点真实发生」:直接 await 被测的 async 方法,用 TaskCompletionSource 手动控制完成时机,用 CancellationTokenSource 模拟取消。
[Fact]
public async Task FetchAll_RunsConcurrently_AllComplete()
{
var client = new Mock<IHttpClient>();
// 每次调用返回受控的 Task
client.Setup(c => c.GetAsync(It.IsAny<string>()))
.Returns<string>(u => Task.FromResult($"data-{u}"));
var fetcher = new ConcurrentFetcher(client.Object);
var results = await fetcher.FetchAllAsync(["a", "b", "c"]);
Assert.Equal(3, results.Count);
client.Verify(c => c.GetAsync("a"), Times.Once);
}
[Fact]
public async Task Run_Cancelled_ThrowsOperationCanceled()
{
using var cts = new CancellationTokenSource();
cts.Cancel(); // 预先取消
var worker = new Worker();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
worker.RunAsync(cts.Token));
}
| 场景 | 技巧 |
|---|---|
| 等待真实异步 | 直接 await |
| 控制完成时机 | TaskCompletionSource<T> |
| 模拟并发 | Task.WhenAll + 延迟 |
| 验证取消 | CancellationTokenSource.Cancel() |
| 避免真实时钟 | 注入 TimeProvider 抽象 |
避坑: 异步测试最常见的翻车是用
Task.Run包同步代码制造「假异步」,或死等Task.Delay真实时间。需要可控时钟就注入TimeProvider(.NET 8+ 内置抽象),需要可控完成就用手动TaskCompletionSource——让测试跑得快且确定,而不是靠运气。
5. 集成测试与 WebApplicationFactory
一句话总结: WebApplicationFactory 直接在内存中托管被测应用,集成测试以真实 HTTP 请求贯穿中间件、路由与数据库,替换外部依赖用 override 机制。
集成测试验证「组件拼起来是否真的能工作」。WebApplicationFactory<T> 在测试进程中启动完整应用,HttpClient 发真实请求。用 WithWebHostBuilder 覆盖注册,把数据库、消息总线替换成测试替身(如 SQLite 或真实测试库)。
public class ShopApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// 覆盖配置:测试库连接串
builder.UseSetting("ConnectionStrings:Default",
"Server=localhost;Database=shop_test;...");
// 替换外部服务
builder.ConfigureServices(services =>
{
services.RemoveAll<IPaymentClient>();
services.AddSingleton<IPaymentClient, FakePaymentClient>();
});
}
}
public class OrdersApiTests : IClassFixture<ShopApiFactory>
{
private readonly HttpClient _client;
public OrdersApiTests(ShopApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrder_Existing_Returns200AndBody()
{
var response = await _client.GetAsync("/api/orders/1");
response.EnsureSuccessStatusCode();
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
Assert.NotNull(order);
Assert.Equal(1, order.Id);
}
[Fact]
public async Task CreateOrder_Invalid_Returns400()
{
var payload = new { CustomerName = "" }; // 触发验证失败
var response = await _client.PostAsJsonAsync("/api/orders", payload);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
| 覆盖手段 | 场景 |
|---|---|
UseSetting | 替换配置值 |
ConfigureServices | 替换 DI 注册 |
RemoveAll<T> | 移除原注册 |
IClassFixture | 复用同一工厂 |
CreateClient() | 真实 HTTP 客户端 |
避坑: WebApplicationFactory 的测试共享同一个 DI 容器,替换注册会影响整个 fixture 的所有测试。数据库测试务必用独立 schema 或每次清理;
Program的可见性需要InternalsVisibleTo或分部类暴露,否则工厂找不到入口点。
6. 测试替身与隔离策略
一句话总结: 测试替身家族包括 Stub(预置返回)、Mock(验证交互)、Fake(可运行替身)与 Spy(记录调用),按测试意图选择,避免把所有替身都叫 Mock。
替身的选择由「要验证什么」决定:只关心返回值用 Stub,关心调用次数与参数用 Mock,需要真实行为(如内存版消息队列)用 Fake。混淆它们会让测试意图模糊。
// Fake:内存版消息队列,真实行为
public class InMemoryMessageBus : IMessageBus
{
private readonly Channel<Message> _channel = Channel.CreateUnbounded<Message>();
public async ValueTask PublishAsync(Message msg) =>
await _channel.Writer.WriteAsync(msg);
public async ValueTask<Message?> ConsumeAsync(CancellationToken ct) =>
await _channel.Reader.ReadAsync(ct);
}
// 测试里直接使用
var bus = new InMemoryMessageBus();
await bus.PublishAsync(new("order.created", id: 1));
var msg = await bus.ConsumeAsync(ct);
Assert.Equal(1, msg.Id);
| 替身 | 行为 | 验证点 |
|---|---|---|
| Stub | 预置返回值 | 无 |
| Mock | 预置行为 + 记录交互 | 调用次数/参数 |
| Spy | 包装真实对象 | 记录调用 |
| Fake | 真实可运行替身 | 业务结果 |
避坑: 优先级排序——Fake 优于 Mock:能写出真实行为的替身(内存队列、SQLite、Testcontainers 起真实依赖)远比手动 Setup 一堆方法可靠。Mock 是「廉价但脆」的选择,只在外部依赖不可控时才值得。Testcontainers 让 Docker 里的真实 Redis/Postgres 成为集成测试标配。
7. 覆盖率与 CI 集成
一句话总结: 覆盖率衡量「哪些代码被测试执行到」,用收集器生成报告、CI 设置最低门槛,但覆盖率是约束不是目标——关键路径的断言质量比数字更重要。
Microsoft.Testing.Platform + coverlet 收集覆盖率,ReportGenerator 生成 HTML 报告。CI(GitHub Actions / Azure DevOps)里跑测试、生成报告、强制门槛(如 80%),防止覆盖率倒退。
# 安装收集器
dotnet add package coverlet.collector
# 收集覆盖率(输出到 TestResults/)
dotnet test --collect:"XPlat Code Coverage"
# 生成可读报告
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" \
-targetdir:"coveragereport" -reporttypes:Html
# GitHub Actions 片段
- name: Run tests with coverage
run: dotnet test --collect:"XPlat Code Coverage" /p:Threshold=80
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage
path: coveragereport
| 指标 | 含义 |
|---|---|
| 行覆盖率 | 执行到的代码行占比 |
| 分支覆盖率 | 分支路径覆盖占比 |
| 方法覆盖率 | 被调用的方法占比 |
| 突变分数 | 测试能否揪出注入的 bug |
避坑: 覆盖率是必要的护栏,不是质量本身——100% 覆盖率的测试如果全是「断言实现细节」依然没有防护力。关注关键业务逻辑(支付、库存扣减、并发边界)的分支覆盖,并配合突变测试验证断言是否真的会失败。CI 里把覆盖率设为降级门槛(新代码必须达标),比一刀切总量更务实。
8. 总结
| 环节 | 要点 |
|---|---|
| 框架 | xUnit Fact/Theory,NUnit Test/TestCase |
| 组织 | 方法_场景_期望 命名,AAA 三段式 |
| Moq | Setup 行为 + Verify 交互,It.IsAny 匹配 |
| 异步 | 直接 await,TSC 控制时序,TimeProvider 控时钟 |
| 集成测试 | WebApplicationFactory 内存托管,真实 HTTP |
| 替身 | Fake 优先,Mock 兜底,按意图选择 |
| 覆盖率与 CI | coverlet 收集 + CI 门槛 + 突变验证 |
测试体系的价值不是「数字好看」,而是让变更可以被信任:重构有回归兜底、新功能有行为契约、故障有可复现的测试用例。先锁定关键业务路径的行为契约,再谈覆盖率数字——测试金字塔的顶端是那些「错了会出大事故」的代码,它们值得最高的断言质量。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。