Files
pos-system/microservices/.agent/skills/testing-patterns/SKILL.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

446 lines
13 KiB
Markdown

---
name: testing-patterns
description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers.
compatibility: ".NET 10+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
metadata:
author: Velik Ho
version: "1.0"
---
# Testing Patterns / Mẫu Kiểm Thử
Testing patterns cho GoodGo microservices với xUnit, NSubstitute, và Testcontainers.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Writing unit tests for handlers / Viết unit tests cho handlers
- Testing controllers with mocked dependencies / Test controllers với dependencies giả lập
- Creating integration tests with database / Tạo integration tests với database
- Setting up functional tests with TestServer / Cài đặt functional tests với TestServer
- Mocking services với NSubstitute / Giả lập services với NSubstitute
## Core Concepts / Khái Niệm Cốt Lõi
### Testing Pyramid / Kim Tự Tháp Testing
```
/\
/ \ E2E Tests (ít nhất)
/----\
/ \ Integration Tests
/--------\
/ \ Unit Tests (nhiều nhất)
--------------
```
| Level | Scope | Speed | Dependencies |
|-------|-------|-------|--------------|
| **Unit** | Single class/method | Milliseconds | Mocked |
| **Integration** | Multiple components + DB | Seconds | Real/Containerized |
| **Functional/E2E** | Full API workflow | Seconds-Minutes | Real services |
### Test Project Structure / Cấu Trúc Project Test
```
tests/
├── ServiceName.UnitTests/
│ ├── Handlers/
│ │ ├── CreateOrderCommandHandlerTests.cs
│ │ └── GetOrderQueryHandlerTests.cs
│ ├── Domain/
│ │ └── OrderTests.cs
│ └── ServiceName.UnitTests.csproj
├── ServiceName.IntegrationTests/
│ ├── Fixtures/
│ │ └── DatabaseFixture.cs
│ ├── Repositories/
│ │ └── OrderRepositoryTests.cs
│ └── ServiceName.IntegrationTests.csproj
└── ServiceName.FunctionalTests/
├── ApiTests/
│ └── OrdersApiTests.cs
└── ServiceName.FunctionalTests.csproj
```
## Key Patterns / Mẫu Chính
### Unit Test với xUnit + NSubstitute
```csharp
/// <summary>
/// EN: Unit test for command handler.
/// VI: Unit test cho command handler.
/// </summary>
public class CreateOrderCommandHandlerTests
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CreateOrderCommandHandler> _logger;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
// EN: Create mocks with NSubstitute
// VI: Tạo mocks với NSubstitute
_orderRepository = Substitute.For<IOrderRepository>();
_logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();
// EN: Create handler with mocked dependencies
// VI: Tạo handler với dependencies giả lập
_handler = new CreateOrderCommandHandler(_orderRepository, _logger);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrder()
{
// Arrange
var command = new CreateOrderCommand(
UserId: "user-123",
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"),
Items: new List<OrderItemDto>
{
new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 10.00m)
});
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(callInfo => callInfo.Arg<Order>());
_orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns(1);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.OrderId.Should().NotBeEmpty();
await _orderRepository.Received(1).AddAsync(
Arg.Is<Order>(o => o.UserId == "user-123"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_EmptyItems_ThrowsDomainException()
{
// Arrange
var command = new CreateOrderCommand(
UserId: "user-123",
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"),
Items: new List<OrderItemDto>());
// Act & Assert
await Assert.ThrowsAsync<DomainException>(() =>
_handler.Handle(command, CancellationToken.None));
await _orderRepository.DidNotReceive().AddAsync(
Arg.Any<Order>(),
Arg.Any<CancellationToken>());
}
}
```
### Domain Entity Tests / Test Domain Entity
```csharp
/// <summary>
/// EN: Tests for Order aggregate root.
/// VI: Tests cho Order aggregate root.
/// </summary>
public class OrderTests
{
[Fact]
public void AddItem_ValidItem_IncreasesTotalAmount()
{
// Arrange
var order = new Order("user-123", CreateAddress());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
// Assert
order.TotalAmount.Should().Be(20.00m);
order.OrderItems.Should().HaveCount(1);
}
[Fact]
public void AddItem_SameProduct_IncreasesQuantity()
{
// Arrange
var order = new Order("user-123", CreateAddress());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
order.AddItem(productId, quantity: 3, unitPrice: 10.00m);
// Assert
order.OrderItems.Should().HaveCount(1);
order.OrderItems.First().Quantity.Should().Be(5);
order.TotalAmount.Should().Be(50.00m);
}
[Fact]
public void Submit_EmptyOrder_ThrowsDomainException()
{
// Arrange
var order = new Order("user-123", CreateAddress());
// Act & Assert
var act = () => order.Submit();
act.Should().Throw<DomainException>()
.WithMessage("Cannot submit empty order");
}
private static Address CreateAddress() =>
new("123 Main St", "City", "State", "12345", "Country");
}
```
### Integration Tests với Testcontainers
```csharp
/// <summary>
/// EN: Database fixture using Testcontainers.
/// VI: Database fixture dùng Testcontainers.
/// </summary>
public class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
public ApplicationDbContext DbContext { get; private set; } = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(ConnectionString)
.Options;
DbContext = new ApplicationDbContext(options);
await DbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await DbContext.DisposeAsync();
await _container.DisposeAsync();
}
}
[Collection("Database")]
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
private readonly OrderRepository _repository;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
_repository = new OrderRepository(_fixture.DbContext);
}
[Fact]
public async Task AddAsync_ValidOrder_PersistsToDatabase()
{
// Arrange
var order = new Order("user-123", new Address("St", "City", "State", "12345", "US"));
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
// Assert
var savedOrder = await _repository.GetWithItemsAsync(order.Id);
savedOrder.Should().NotBeNull();
savedOrder!.OrderItems.Should().HaveCount(1);
}
}
```
### Functional Tests với TestServer
```csharp
/// <summary>
/// EN: Web application factory for functional tests.
/// VI: Web application factory cho functional tests.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// EN: Remove real DbContext
// VI: Xóa DbContext thật
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// EN: Add in-memory database for tests
// VI: Thêm in-memory database cho tests
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
}
}
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ValidRequest_Returns201()
{
// Arrange
var request = new
{
UserId = "user-123",
ShippingAddress = new { Street = "123 St", City = "City", State = "ST", PostalCode = "12345", Country = "US" },
Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m } }
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact]
public async Task GetOrder_NotFound_Returns404()
{
// Act
var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Testing Implementation Details
```csharp
// ❌ BAD: Testing internal state
order.GetType().GetField("_status", BindingFlags.NonPublic)
.GetValue(order).Should().Be(OrderStatus.Draft);
// ✅ GOOD: Testing behavior
order.Status.Should().Be(OrderStatus.Draft);
order.Submit();
order.Status.Should().Be(OrderStatus.Submitted);
```
### 2. Not Using Async Assertions
```csharp
// ❌ BAD: Blocking call
var result = _handler.Handle(command, ct).Result;
// ✅ GOOD: Async assertion
var result = await _handler.Handle(command, ct);
```
### 3. Sharing State Between Tests
```csharp
// ❌ BAD: Static shared state
private static Order _order = new Order(...);
// ✅ GOOD: Fresh instance per test
private Order CreateOrder() => new Order("user-123", CreateAddress());
```
### 4. Ignoring CancellationToken
```csharp
// ❌ BAD: Ignoring cancellation
await _handler.Handle(command, CancellationToken.None);
// ✅ GOOD: Testing cancellation
var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
_handler.Handle(command, cts.Token));
```
## Quick Reference / Tham Chiếu Nhanh
### xUnit Attributes
| Attribute | Purpose |
|-----------|---------|
| `[Fact]` | Single test case |
| `[Theory]` | Parameterized test |
| `[InlineData]` | Inline parameters |
| `[MemberData]` | Complex parameters |
| `[Collection]` | Shared fixture |
| `[ClassData]` | External data source |
### NSubstitute Patterns
```csharp
// EN: Create substitute / VI: Tạo substitute
var service = Substitute.For<IOrderService>();
// EN: Setup return value / VI: Thiết lập giá trị trả về
service.GetByIdAsync(Arg.Any<Guid>()).Returns(order);
// EN: Verify call / VI: Xác minh gọi
await service.Received(1).GetByIdAsync(orderId);
// EN: Capture arguments / VI: Capture arguments
Order? capturedOrder = null;
await repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o));
```
### FluentAssertions
```csharp
result.Should().NotBeNull();
result.Should().BeEquivalentTo(expected);
orders.Should().HaveCount(5);
exception.Should().Throw<DomainException>().WithMessage("*empty*");
```
### Test Commands
```bash
# EN: Run all tests / VI: Chạy tất cả tests
dotnet test
# EN: Run specific project / VI: Chạy project cụ thể
dotnet test tests/Service.UnitTests
# EN: Run with coverage / VI: Chạy với coverage
dotnet test --collect:"XPlat Code Coverage"
# EN: Run specific test / VI: Chạy test cụ thể
dotnet test --filter "FullyQualifiedName~CreateOrder"
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [Repository Pattern](../repository-pattern/SKILL.md) - Repository testing
- [Error Handling](../error-handling-patterns/SKILL.md) - Error testing
- [API Design](../api-design/SKILL.md) - Controller testing
- [Project Rules](../project-rules/SKILL.md) - Coding standards