mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-03 23:53:29 +08:00
322 lines
8.4 KiB
Markdown
322 lines
8.4 KiB
Markdown
---
|
|
name: csharp-testing
|
|
description: C# and .NET testing patterns with xUnit, FluentAssertions, mocking, integration tests, and test organization best practices.
|
|
origin: ECC
|
|
---
|
|
|
|
# C# Testing Patterns
|
|
|
|
Comprehensive testing patterns for .NET applications using xUnit, FluentAssertions, and modern testing practices.
|
|
|
|
## When to Activate
|
|
|
|
- Writing new tests for C# code
|
|
- Reviewing test quality and coverage
|
|
- Setting up test infrastructure for .NET projects
|
|
- Debugging flaky or slow tests
|
|
|
|
## Test Framework Stack
|
|
|
|
| Tool | Purpose |
|
|
|---|---|
|
|
| **xUnit** | Test framework (preferred for .NET) |
|
|
| **FluentAssertions** | Readable assertion syntax |
|
|
| **NSubstitute** or **Moq** | Mocking dependencies |
|
|
| **Testcontainers** | Real infrastructure in integration tests |
|
|
| **WebApplicationFactory** | ASP.NET Core integration tests |
|
|
| **Bogus** | Realistic test data generation |
|
|
|
|
## Unit Test Structure
|
|
|
|
### Arrange-Act-Assert
|
|
|
|
```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");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Parameterized Tests with 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" },
|
|
};
|
|
```
|
|
|
|
## Mocking with 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 Integration Tests
|
|
|
|
### WebApplicationFactory Setup
|
|
|
|
```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();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing with 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);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Test Organization
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
## Test Data Builders
|
|
|
|
```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();
|
|
```
|
|
|
|
## Common Anti-Patterns
|
|
|
|
| Anti-Pattern | Fix |
|
|
|---|---|
|
|
| Testing implementation details | Test behavior and outcomes |
|
|
| Shared mutable test state | Fresh instance per test (xUnit does this via constructors) |
|
|
| `Thread.Sleep` in async tests | Use `Task.Delay` with timeout, or polling helpers |
|
|
| Asserting on `ToString()` output | Assert on typed properties |
|
|
| One giant assertion per test | One logical assertion per test |
|
|
| Test names describing implementation | Name by behavior: `Method_ExpectedResult_WhenCondition` |
|
|
| Ignoring `CancellationToken` | Always pass and verify cancellation |
|
|
|
|
## Running Tests
|
|
|
|
```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/
|
|
```
|