Chapter 09

测试与质量

从 xUnit 单元测试到 WebApplicationFactory 集成测试、TestContainers 真实数据库测试与 BenchmarkDotNet 性能分析。

测试层次

单元测试(Unit Tests)

  • 测试单个类/方法的逻辑
  • 所有依赖用 Mock 替代
  • 运行极快(毫秒级)
  • 数量最多,占 70%
  • 工具:xUnit + Moq + FluentAssertions

集成测试(Integration Tests)

  • 测试多个组件协同工作
  • 使用真实 HTTP 客户端
  • 运行较慢(秒级)
  • 数量中等,占 20%
  • 工具:WebApplicationFactory + TestContainers

xUnit:基础单元测试

[Fact]
单个无参数测试用例,验证一个特定的行为或状态。命名约定:方法名_条件_预期结果(如 GetUser_WithValidId_ReturnsUser)。
[Theory] + [InlineData]
参数化测试,相同逻辑用不同输入测试多次。[InlineData] 直接内联数据,[MemberData] 可以引用静态属性/方法提供复杂数据集,[ClassData] 可以从类中获取数据。
public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _repoMock = new();
    private readonly Mock<IEmailService>    _emailMock = new();
    private readonly OrderService           _sut;   // System Under Test

    public OrderServiceTests()
    {
        _sut = new OrderService(_repoMock.Object, _emailMock.Object);
    }

    [Fact]
    public async Task CreateOrder_WithValidRequest_ReturnsOrderWithId()
    {
        // Arrange
        var request = new CreateOrderRequest("user1", [new("product-1", 2, 99.99m)]);
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
                 .ReturnsAsync((Order o, CancellationToken _) =>
                 {
                     o.Id = 42;
                     return o;
                 });

        // Act
        var result = await _sut.CreateAsync(request);

        // Assert(FluentAssertions)
        result.Should().NotBeNull();
        result.Id.Should().Be(42);
        result.Total.Should().Be(199.98m);

        // 验证邮件发送被调用了一次
        _emailMock.Verify(
            e => e.SendOrderConfirmationAsync("user1", 42, It.IsAny<CancellationToken>()),
            Times.Once()
        );
    }

    [Theory]
    [InlineData("discount10", 100m, 90m)]
    [InlineData("discount20", 100m, 80m)]
    [InlineData("invalid", 100m, 100m)]
    public void ApplyDiscount_WithVariousCodes_ReturnsCorrectTotal(
        string code, decimal original, decimal expected)
    {
        var result = DiscountCalculator.Apply(code, original);
        result.Should().Be(expected);
    }
}

FluentAssertions:可读性极强的断言

// 数值断言
result.Should().BeGreaterThan(0);
result.Should().BeInRange(1, 100);

// 字符串断言
message.Should().StartWith("Hello").And.EndWith("!");
message.Should().Contain("World", Exactly.Once());

// 集合断言
list.Should().HaveCount(3).And.Contain(x => x.IsActive);
list.Should().BeInAscendingOrder(x => x.Name);
list.Should().AllSatisfy(x => x.Age.Should().BePositive());

// 异常断言
var act = () => service.CreateUser(null);
await act.Should().ThrowAsync<ArgumentNullException>()
    .WithMessage("*email*");

// 对象属性匹配
user.Should().BeEquivalentTo(new { Name = "Alice", Age = 30 },
    opts => opts.ExcludingMissingMembers());

WebApplicationFactory:集成测试

WebApplicationFactory<TProgram> 在内存中启动完整的 ASP.NET Core 应用,通过真实 HTTP 客户端发送请求,但不开监听真实端口,速度快且无端口冲突。

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureServices(services =>
                {
                    // 替换数据库为 SQLite 内存库
                    services.RemoveAll<DbContextOptions<AppDbContext>>();
                    services.AddDbContext<AppDbContext>(opts =>
                        opts.UseInMemoryDatabase("TestDb"));
                });
            })
            .CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOkWithList()
    {
        var response = await _client.GetAsync("/api/products");

        response.Should().HaveStatusCode(HttpStatusCode.OK);
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        products.Should().NotBeNull().And.BeEmpty();
    }

    [Fact]
    public async Task CreateProduct_WithValidData_Returns201()
    {
        var req = new { Name = "Test Product", Price = 9.99m };
        var response = await _client.PostAsJsonAsync("/api/products", req);

        response.Should().HaveStatusCode(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
    }
}

TestContainers:真实数据库集成测试

TestContainers 在测试时自动启动 Docker 容器(PostgreSQL、Redis、Kafka 等),使用真实数据库而非模拟。测试结束后自动清理容器。

// 安装: dotnet add package Testcontainers.PostgreSql

public class DatabaseIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres =
        new PostgreSqlBuilder()
            .WithImage("postgres:16")
            .WithDatabase("testdb")
            .Build();

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        // 应用迁移
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgres.GetConnectionString())
            .Options;
        await using var db = new AppDbContext(options);
        await db.Database.MigrateAsync();
    }

    public async Task DisposeAsync() => await _postgres.DisposeAsync().AsTask();

    [Fact]
    public async Task SaveAndQuery_User_WorksWithRealPostgres()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgres.GetConnectionString()).Options;

        await using var db = new AppDbContext(options);
        db.Users.Add(new User { Email = "test@example.com", Name = "Test" });
        await db.SaveChangesAsync();

        var user = await db.Users.FirstOrDefaultAsync(u => u.Email == "test@example.com");
        user.Should().NotBeNull();
        user!.Name.Should().Be("Test");
    }
}

BenchmarkDotNet:性能基准测试

// 安装: dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]      // 显示内存分配
[SimpleJob]             // 单 Job,快速结果
public class StringBenchmarks
{
    private readonly string _input = string.Join(",", Enumerable.Range(1, 1000));

    [Benchmark(Baseline = true)]
    public string[] SplitClassic() => _input.Split(',');

    [Benchmark]
    public List<string> SplitWithSpan()
    {
        var result = new List<string>();
        var remaining = _input.AsSpan();
        int idx;
        while ((idx = remaining.IndexOf(',')) >= 0)
        {
            result.Add(remaining[..idx].ToString());
            remaining = remaining[(idx + 1)..];
        }
        result.Add(remaining.ToString());
        return result;
    }
}

// 运行(必须 Release 模式)
// dotnet run -c Release
BenchmarkRunner.Run<StringBenchmarks>();
BenchmarkDotNet 典型输出 ───────────────────────────────────────────────────────────── | Method | Mean | Error | StdDev | Alloc Ratio | |-------------- |---------:|--------:|--------:|------------:| | SplitClassic | 12.45 μs | 0.12 μs | 0.11 μs | 1.00 | | SplitWithSpan | 10.23 μs | 0.09 μs | 0.08 μs | 0.82 | ───────────────────────────────────────────────────────────── Span 版本快 ~18%,内存分配减少 ~18%
Tip — 测试覆盖率 使用 dotnet test --collect:"XPlat Code Coverage" 生成覆盖率报告(Cobertura 格式)。在 CI 中集成 Coverlet + Codecov,可以在 PR 中直接看到覆盖率变化。目标:业务逻辑层 >80%,工具类 >90%,不必强求 100%(测试 UI 代码和第三方库没有意义)。
本章小结 xUnit + Moq + FluentAssertions 是 .NET 单元测试的黄金组合:xUnit 提供测试框架,Moq 创建灵活的 Mock,FluentAssertions 让断言像自然语言一样易读。WebApplicationFactory 的内存 HTTP 集成测试速度快且不需要真实服务器。TestContainers 解决了"集成测试需要真实 DB"的痛点。BenchmarkDotNet 是发现性能瓶颈的必备工具,总在 Release 模式下运行。