mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 10:43:20 +08:00
322 lines
8.4 KiB
Markdown
322 lines
8.4 KiB
Markdown
---
|
||
name: csharp-testing
|
||
description: 使用 xUnit、FluentAssertions、模拟、集成测试和测试组织最佳实践的 C# 和 .NET 测试模式。
|
||
origin: ECC
|
||
---
|
||
|
||
# C# 测试模式
|
||
|
||
使用 xUnit、FluentAssertions 和现代测试实践为 .NET 应用程序提供的全面测试模式。
|
||
|
||
## 何时使用
|
||
|
||
* 为 C# 代码编写新测试
|
||
* 审查测试质量和覆盖率
|
||
* 为 .NET 项目搭建测试基础设施
|
||
* 调试不稳定或缓慢的测试
|
||
|
||
## 测试框架栈
|
||
|
||
| 工具 | 用途 |
|
||
|---|---|
|
||
| **xUnit** | 测试框架(.NET 首选) |
|
||
| **FluentAssertions** | 可读的断言语法 |
|
||
| **NSubstitute** 或 **Moq** | 模拟依赖项 |
|
||
| **Testcontainers** | 集成测试中的真实基础设施 |
|
||
| **WebApplicationFactory** | ASP.NET Core 集成测试 |
|
||
| **Bogus** | 生成逼真的测试数据 |
|
||
|
||
## 单元测试结构
|
||
|
||
### 安排-操作-断言
|
||
|
||
```csharp
|
||
public sealed class OrderServiceTests
|
||
{
|
||
private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>();
|
||
private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>();
|
||
private readonly OrderService _sut;
|
||
|
||
public OrderServiceTests()
|
||
{
|
||
_sut = new OrderService(_repository, _logger);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PlaceOrderAsync_ReturnsSuccess_WhenRequestIsValid()
|
||
{
|
||
// Arrange
|
||
var request = new CreateOrderRequest
|
||
{
|
||
CustomerId = "cust-123",
|
||
Items = [new OrderItem("SKU-001", 2, 29.99m)]
|
||
};
|
||
|
||
// Act
|
||
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsSuccess.Should().BeTrue();
|
||
result.Value.Should().NotBeNull();
|
||
result.Value!.CustomerId.Should().Be("cust-123");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PlaceOrderAsync_ReturnsFailure_WhenNoItems()
|
||
{
|
||
// Arrange
|
||
var request = new CreateOrderRequest
|
||
{
|
||
CustomerId = "cust-123",
|
||
Items = []
|
||
};
|
||
|
||
// Act
|
||
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsSuccess.Should().BeFalse();
|
||
result.Error.Should().Contain("at least one item");
|
||
}
|
||
}
|
||
```
|
||
|
||
### 使用 Theory 的参数化测试
|
||
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("", false)]
|
||
[InlineData("a", false)]
|
||
[InlineData("ab@c.d", false)]
|
||
[InlineData("user@example.com", true)]
|
||
[InlineData("user+tag@example.co.uk", true)]
|
||
public void IsValidEmail_ReturnsExpected(string email, bool expected)
|
||
{
|
||
EmailValidator.IsValid(email).Should().Be(expected);
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(InvalidOrderCases))]
|
||
public async Task PlaceOrderAsync_RejectsInvalidOrders(CreateOrderRequest request, string expectedError)
|
||
{
|
||
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||
|
||
result.IsSuccess.Should().BeFalse();
|
||
result.Error.Should().Contain(expectedError);
|
||
}
|
||
|
||
public static TheoryData<CreateOrderRequest, string> InvalidOrderCases => new()
|
||
{
|
||
{ new() { CustomerId = "", Items = [ValidItem()] }, "CustomerId" },
|
||
{ new() { CustomerId = "c1", Items = [] }, "at least one item" },
|
||
{ new() { CustomerId = "c1", Items = [new("", 1, 10m)] }, "SKU" },
|
||
};
|
||
```
|
||
|
||
## 使用 NSubstitute 进行模拟
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task GetOrderAsync_ReturnsNull_WhenNotFound()
|
||
{
|
||
// Arrange
|
||
var orderId = Guid.NewGuid();
|
||
_repository.FindByIdAsync(orderId, Arg.Any<CancellationToken>())
|
||
.Returns((Order?)null);
|
||
|
||
// Act
|
||
var result = await _sut.GetOrderAsync(orderId, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().BeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PlaceOrderAsync_PersistsOrder()
|
||
{
|
||
// Arrange
|
||
var request = ValidOrderRequest();
|
||
|
||
// Act
|
||
await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||
|
||
// Assert — verify the repository was called
|
||
await _repository.Received(1).AddAsync(
|
||
Arg.Is<Order>(o => o.CustomerId == request.CustomerId),
|
||
Arg.Any<CancellationToken>());
|
||
}
|
||
```
|
||
|
||
## ASP.NET Core 集成测试
|
||
|
||
### WebApplicationFactory 设置
|
||
|
||
```csharp
|
||
public sealed class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||
{
|
||
private readonly HttpClient _client;
|
||
|
||
public OrderApiTests(WebApplicationFactory<Program> factory)
|
||
{
|
||
_client = factory.WithWebHostBuilder(builder =>
|
||
{
|
||
builder.ConfigureServices(services =>
|
||
{
|
||
// Replace real DB with in-memory for tests
|
||
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||
services.AddDbContext<AppDbContext>(options =>
|
||
options.UseInMemoryDatabase("TestDb"));
|
||
});
|
||
}).CreateClient();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetOrder_Returns404_WhenNotFound()
|
||
{
|
||
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");
|
||
|
||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CreateOrder_Returns201_WithValidRequest()
|
||
{
|
||
var request = new CreateOrderRequest
|
||
{
|
||
CustomerId = "cust-1",
|
||
Items = [new("SKU-001", 1, 19.99m)]
|
||
};
|
||
|
||
var response = await _client.PostAsJsonAsync("/api/orders", request);
|
||
|
||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||
response.Headers.Location.Should().NotBeNull();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 使用 Testcontainers 进行测试
|
||
|
||
```csharp
|
||
public sealed class PostgresOrderRepositoryTests : IAsyncLifetime
|
||
{
|
||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||
.WithImage("postgres:16-alpine")
|
||
.Build();
|
||
|
||
private AppDbContext _db = null!;
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
await _postgres.StartAsync();
|
||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||
.UseNpgsql(_postgres.GetConnectionString())
|
||
.Options;
|
||
_db = new AppDbContext(options);
|
||
await _db.Database.MigrateAsync();
|
||
}
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
await _db.DisposeAsync();
|
||
await _postgres.DisposeAsync();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AddAsync_PersistsOrder()
|
||
{
|
||
var repo = new SqlOrderRepository(_db);
|
||
var order = Order.Create("cust-1", [new OrderItem("SKU-001", 2, 10m)]);
|
||
|
||
await repo.AddAsync(order, CancellationToken.None);
|
||
|
||
var found = await repo.FindByIdAsync(order.Id, CancellationToken.None);
|
||
found.Should().NotBeNull();
|
||
found!.Items.Should().HaveCount(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 测试组织
|
||
|
||
```
|
||
tests/
|
||
MyApp.UnitTests/
|
||
Services/
|
||
OrderServiceTests.cs
|
||
PaymentServiceTests.cs
|
||
Validators/
|
||
EmailValidatorTests.cs
|
||
MyApp.IntegrationTests/
|
||
Api/
|
||
OrderApiTests.cs
|
||
Repositories/
|
||
OrderRepositoryTests.cs
|
||
MyApp.TestHelpers/
|
||
Builders/
|
||
OrderBuilder.cs
|
||
Fixtures/
|
||
DatabaseFixture.cs
|
||
```
|
||
|
||
## 测试数据构建器
|
||
|
||
```csharp
|
||
public sealed class OrderBuilder
|
||
{
|
||
private string _customerId = "cust-default";
|
||
private readonly List<OrderItem> _items = [new("SKU-001", 1, 10m)];
|
||
|
||
public OrderBuilder WithCustomer(string customerId)
|
||
{
|
||
_customerId = customerId;
|
||
return this;
|
||
}
|
||
|
||
public OrderBuilder WithItem(string sku, int quantity, decimal price)
|
||
{
|
||
_items.Add(new OrderItem(sku, quantity, price));
|
||
return this;
|
||
}
|
||
|
||
public Order Build() => Order.Create(_customerId, _items);
|
||
}
|
||
|
||
// Usage in tests
|
||
var order = new OrderBuilder()
|
||
.WithCustomer("cust-vip")
|
||
.WithItem("SKU-PREMIUM", 3, 99.99m)
|
||
.Build();
|
||
```
|
||
|
||
## 常见反模式
|
||
|
||
| 反模式 | 修复方法 |
|
||
|---|---|
|
||
| 测试实现细节 | 测试行为和结果 |
|
||
| 共享的可变测试状态 | 每个测试使用新实例(xUnit 通过构造函数实现) |
|
||
| 在异步测试中使用 `Thread.Sleep` | 使用带超时的 `Task.Delay` 或轮询辅助方法 |
|
||
| 对 `ToString()` 输出进行断言 | 对类型化属性进行断言 |
|
||
| 每个测试一个巨型断言 | 每个测试一个逻辑断言 |
|
||
| 测试名称描述实现 | 按行为命名:`Method_ExpectedResult_WhenCondition` |
|
||
| 忽略 `CancellationToken` | 始终传递并验证取消 |
|
||
|
||
## 运行测试
|
||
|
||
```bash
|
||
# Run all tests
|
||
dotnet test
|
||
|
||
# Run with coverage
|
||
dotnet test --collect:"XPlat Code Coverage"
|
||
|
||
# Run specific project
|
||
dotnet test tests/MyApp.UnitTests/
|
||
|
||
# Filter by test name
|
||
dotnet test --filter "FullyQualifiedName~OrderService"
|
||
|
||
# Watch mode during development
|
||
dotnet watch test --project tests/MyApp.UnitTests/
|
||
```
|