Migrate
This commit is contained in:
445
microservices/.agent/skills/testing-patterns/SKILL.md
Normal file
445
microservices/.agent/skills/testing-patterns/SKILL.md
Normal file
@@ -0,0 +1,445 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user