--- 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 /// /// EN: Unit test for command handler. /// VI: Unit test cho command handler. /// public class CreateOrderCommandHandlerTests { private readonly IOrderRepository _orderRepository; private readonly ILogger _logger; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { // EN: Create mocks with NSubstitute // VI: Tạo mocks với NSubstitute _orderRepository = Substitute.For(); _logger = Substitute.For>(); // 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 { new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 10.00m) }); _orderRepository.AddAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => callInfo.Arg()); _orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any()) .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(o => o.UserId == "user-123"), Arg.Any()); } [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()); // Act & Assert await Assert.ThrowsAsync(() => _handler.Handle(command, CancellationToken.None)); await _orderRepository.DidNotReceive().AddAsync( Arg.Any(), Arg.Any()); } } ``` ### Domain Entity Tests / Test Domain Entity ```csharp /// /// EN: Tests for Order aggregate root. /// VI: Tests cho Order aggregate root. /// 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() .WithMessage("Cannot submit empty order"); } private static Address CreateAddress() => new("123 Main St", "City", "State", "12345", "Country"); } ``` ### Integration Tests với Testcontainers ```csharp /// /// EN: Database fixture using Testcontainers. /// VI: Database fixture dùng Testcontainers. /// 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() .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 { 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 /// /// EN: Web application factory for functional tests. /// VI: Web application factory cho functional tests. /// public class CustomWebApplicationFactory : WebApplicationFactory { 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)); if (descriptor != null) services.Remove(descriptor); // EN: Add in-memory database for tests // VI: Thêm in-memory database cho tests services.AddDbContext(options => options.UseInMemoryDatabase("TestDb")); }); } } public class OrdersApiTests : IClassFixture { 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(() => _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(); // EN: Setup return value / VI: Thiết lập giá trị trả về service.GetByIdAsync(Arg.Any()).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(o => capturedOrder = o)); ``` ### FluentAssertions ```csharp result.Should().NotBeNull(); result.Should().BeEquivalentTo(expected); orders.Should().HaveCount(5); exception.Should().Throw().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