测试层次
单元测试(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 模式下运行。