测试体系:xUnit 与 Moq

系统讲解 .NET 测试体系的搭建,覆盖 xUnit 与 NUnit 的结构组织、Moq 模拟与依赖隔离、基于 WebApplicationFactory 的集成测试,以及覆盖率度量与 CI 流水线中的测试策略。

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) { ... }
}
能力xUnitNUnit
普通测试[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.SharedFixture 与工具

避坑: 测试命名里避免模糊词(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 三段式
MoqSetup 行为 + Verify 交互,It.IsAny 匹配
异步直接 await,TSC 控制时序,TimeProvider 控时钟
集成测试WebApplicationFactory 内存托管,真实 HTTP
替身Fake 优先,Mock 兜底,按意图选择
覆盖率与 CIcoverlet 收集 + CI 门槛 + 突变验证

测试体系的价值不是「数字好看」,而是让变更可以被信任:重构有回归兜底、新功能有行为契约、故障有可复现的测试用例。先锁定关键业务路径的行为契约,再谈覆盖率数字——测试金字塔的顶端是那些「错了会出大事故」的代码,它们值得最高的断言质量。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 消息与后台任务
  2. 缓存与并发控制
  3. 托管生命周期与部署