13 KiB
13 KiB
name, description, compatibility, metadata
| name | description | compatibility | metadata | ||||
|---|---|---|---|---|---|---|---|
| testing-patterns | Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers. | .NET 10+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost |
|
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
/// <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
/// <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
/// <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
/// <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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// 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
result.Should().NotBeNull();
result.Should().BeEquivalentTo(expected);
orders.Should().HaveCount(5);
exception.Should().Throw<DomainException>().WithMessage("*empty*");
Test Commands
# 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 - Full code examples
- Repository Pattern - Repository testing
- Error Handling - Error testing
- API Design - Controller testing
- Project Rules - Coding standards