关于 testing
面向 .NET 10 开发者的测试指南,涵盖 API、业务逻辑和数据库行为。它强调使用真实数据库进行集成测试、遵循 AAA 结构,以及验证可观察行为。
主要功能
- 01xUnit v3 测试模式
- 02WebApplicationFactory 集成测试
- 03Testcontainers 真实数据库测试
- 04Verify 快照测试
- 05测试数据构建器
- 06FakeTimeProvider 时间相关测试
适用场景
- 01测试 API 的完整 HTTP 流程
- 02使用 PostgreSQL 或 SQL Server 测试数据库行为
- 03通过快照比较复杂响应
- 04测试依赖时间的逻辑
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill testing安装后在 Claude Code 中输入/testing确认已加载。
详细介绍
测试(.NET 10)
核心原则
- 集成测试价值最高 — 单个
WebApplicationFactory测试即可一次性覆盖路由、绑定、验证、业务逻辑和持久化。编写单元测试前,先从这里开始。 - 在测试中使用真实数据库 — 使用 Testcontainers 启动真实的 PostgreSQL/SQL Server 实例。内存提供程序会掩盖实际问题(事务、约束、SQL 生成)。
- 必须遵循 AAA 模式 — 每个测试都包含三个清晰分隔的部分:准备(Arrange)、执行(Act)、断言(Assert)。不可混用。
- 测试行为,而非实现 — 测试应能经受重构。测试系统的行为,而非实现方式。
模式
xUnit v3 基础知识
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_WithValidItems_ReturnsSuccessResult()
{
// Arrange
var db = CreateInMemoryDb();
var clock = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero));
var service = new OrderService(db, clock);
var request = new CreateOrderRequest("customer-1", [new("product-1", 2)]);
// Act
var result = await service.CreateAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.NotEqual(Guid.Empty, result.Value.Id);
Assert.Equal(clock.GetUtcNow(), result.Value.CreatedAt);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task CreateOrder_WithInvalidCustomerId_ReturnsFailure(string? customerId)
{
// Arrange
var service = CreateService();
// Act
var result = await service.CreateAsync(new CreateOrderRequest(customerId!, []));
// Assert
Assert.False(result.IsSuccess);
}
}
使用 WebApplicationFactory 进行集成测试
价值最高的测试模式。测试完整的 HTTP 管道。
// Fixtures/ApiFixture.cs
public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:18")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace the real DB with Testcontainers
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
// xUnit v3: IAsyncLifetime.InitializeAsync returns ValueTask (v2 used Task)
public async ValueTask InitializeAsync()
{
await _postgres.StartAsync();
// Apply migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
// xUnit v3: IAsyncLifetime inherits IAsyncDisposable — override the
// ValueTask DisposeAsync that WebApplicationFactory already provides
public override async ValueTask DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
}
// Tests/Orders/CreateOrderTests.cs
public class CreateOrderTests(ApiFixture fixture) : IClassFixture<ApiFixture>
{
private readonly HttpClient _client = fixture.CreateClient();
[Fact]
public async Task CreateOrder_ReturnsCreated_WithValidRequest()
{
// Arrange
var request = new CreateOrderRequest("customer-1", [new("product-1", 2)]);
// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var order = await response.Content.ReadFromJsonAsync<OrderResponse>();
Assert.NotNull(order);
Assert.NotEqual(Guid.Empty, order.Id);
Assert.Contains("/api/orders/", response.Headers.Location?.ToString());
}
[Fact]
public async Task CreateOrder_ReturnsValidationProblem_WithEmptyItems()
{
// Arrange
var request = new CreateOrderRequest("customer-1", []);
// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
使用 Testcontainers 测试真实数据库
// For SQL Server
private readonly MsSqlContainer _mssql = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
// For PostgreSQL
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:18")
.Build();
// For Redis
private readonly RedisContainer _redis = new RedisBuilder()
.WithImage("redis:7")
.Build();
使用 Verify 进行快照测试
对于复杂的响应对象,手动断言容易出错,此时可使用 Verify。
[Fact]
public async Task GetOrder_MatchesSnapshot()
{
// Arrange
await SeedOrder(fixture);
// Act
var response = await _client.GetAsync("/api/orders/known-id");
var content = await response.Content.ReadAsStringAsync();
// Assert — compares against a stored .verified.txt file
await Verify(content);
}
首次运行时,Verify 会创建一个 .verified.txt 文件。之后运行时,它会比较输出。如果输出发生变化,测试就会失败并显示差异。
测试数据构建器
public class OrderBuilder
{
private string _customerId = "default-customer";
private List<OrderItem> _items = [new("product-1", 1, 9.99m)];
private OrderStatus _status = OrderStatus.Pending;
public OrderBuilder WithCustomer(string customerId)
{
_customerId = customerId;
return this;
}
public OrderBuilder WithItems(params OrderItem[] items)
{
_items = [..items];
return this;
}
public OrderBuilder WithStatus(OrderStatus status)
{
_status = status;
return this;
}
public Order Build() => Order.Create(_customerId, _items, _status);
}
// Usage in tests
var order = new OrderBuilder()
.WithCustomer("vip-customer")
.WithStatus(OrderStatus.Confirmed)
.Build();
测试依赖时间的代码
使用 TimeProvider(.NET 8+ 内置)和 Microsoft.Extensions.TimeProvider.Testing 中的 FakeTimeProvider。
[Fact]
public async Task ExpireOrders_MarksOldPendingOrdersAsExpired()
{
// Arrange
var clock = new FakeTimeProvider(new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero));
var db = CreateDb();
var order = Order.Create("customer-1", items, clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync();
// Advance time past expiry threshold
clock.Advance(TimeSpan.FromDays(31));
var handler = new ExpireOrders.Handler(db, clock);
// Act
await handler.Handle(new ExpireOrders.Command(), CancellationToken.None);
// Assert
var updated = await db.Orders.FindAsync(order.Id);
Assert.Equal(OrderStatus.Expired, updated!.Status);
}
测试命名约定
使用以下模式:MethodName_StateUnderTest_ExpectedBehavior
[Fact] public async Task CreateOrder_WithValidItems_ReturnsSuccessResult() { }
[Fact] public async Task CreateOrder_WithEmptyItems_ReturnsValidationError() { }
[Fact] public async Task GetOrder_WithNonExistentId_ReturnsNotFound() { }
[Fact] public async Task CancelOrder_WhenAlreadyShipped_ReturnsConflict() { }
反模式
不要在集成测试中使用内存数据库
// BAD — hides real SQL behavior, transactions, constraints
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// GOOD — Testcontainers with real database
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(testContainer.GetConnectionString()));
不要测试实现细节
// BAD — testing that a specific repository method was called
mock.Verify(x => x.AddAsync(It.IsAny<Order>()), Times.Once);
mock.Verify(x => x.SaveChangesAsync(), Times.Once);
// GOOD — test the observable outcome
var order = await db.Orders.FindAsync(orderId);
Assert.NotNull(order);
Assert.Equal(OrderStatus.Created, order.Status);
不要在测试之间共享可变状态
// BAD — static shared state
private static readonly AppDbContext SharedDb = CreateDb();
// GOOD — fresh state per test (or use IAsyncLifetime for shared fixtures)
private AppDbContext CreateDb() => new(new DbContextOptionsBuilder<AppDbContext>()...);
不要编写没有断言的测试
// BAD — no assertion, only checks it doesn't throw
[Fact]
public async Task CreateOrder_Works()
{
await service.CreateAsync(request);
// "it didn't throw, so it works!" — NO
}
// GOOD — assert the expected outcome
[Fact]
public async Task CreateOrder_PersistsOrderToDatabase()
{
var result = await service.CreateAsync(request);
var persisted = await db.Orders.FindAsync(result.Value.Id);
Assert.NotNull(persisted);
Assert.Equal(request.CustomerId, persisted.CustomerId);
}
决策指南
| 场景 | 建议 |
|---|---|
| 测试 API 端点 | 使用 WebApplicationFactory 进行集成测试 |
| 隔离测试业务逻辑 | 使用伪对象/桩对象进行单元测试 |
| 依赖数据库的测试 | Testcontainers(真实数据库) |
| 验证复杂响应 | 使用 Verify 进行快照测试 |
| 依赖时间的逻辑 | FakeTimeProvider |
| 依赖外部 API | 使用 WireMock.Net 或 HttpMessageHandler 桩对象 |
| 参数化测试用例 | 使用 [Theory] 搭配 [InlineData] 或 [MemberData] |
| 测试数据准备 | 构建器模式 |
| 共享开销较大的测试夹具 | 使用 IClassFixture<T> 和 IAsyncLifetime |
评论
支持 Markdown