# Unit Test Rules / Quy Tắc Unit Testing Hướng dẫn chi tiết cho việc viết Unit Tests với xUnit và NSubstitute trong dự án .NET Microservices. ## 1. Test Structure / Cấu Trúc Test ### Mẫu AAA (Arrange-Act-Assert) ```csharp [Fact] public async Task MethodName_Condition_ExpectedResult() { // Arrange - Chuẩn bị dữ liệu và dependencies var mockRepository = Substitute.For(); var handler = new CreateOrderCommandHandler(mockRepository); var command = CreateValidCommand(); mockRepository.AddAsync(Arg.Any(), Arg.Any()) .Returns(x => x.Arg()); // Act - Thực thi hành động cần test var result = await handler.Handle(command, CancellationToken.None); // Assert - Kiểm tra kết quả result.Should().NotBeNull(); result.OrderId.Should().NotBeEmpty(); } ``` ### Naming Convention Tên method test theo format: `[UnitOfWork]_[StateUnderTest]_[ExpectedBehavior]` ```csharp // ✅ GOOD: Descriptive and clear CreateOrder_WithEmptyItems_ThrowsValidationException() AddItem_SameProduct_IncreasesQuantity() Submit_DraftOrder_ChangesStatusToSubmitted() // ❌ BAD: Vague or too generic Test1() CreateOrderTest() ItWorks() ``` ## 2. NSubstitute Patterns / Mẫu NSubstitute ### 2.1 Basic Substitutes ```csharp // EN: Create substitute for interface // VI: Tạo substitute cho interface var orderRepository = Substitute.For(); // EN: Create substitute for abstract class // VI: Tạo substitute cho abstract class var baseService = Substitute.For(); // EN: Substitute for multiple interfaces // VI: Substitute cho nhiều interfaces var multiInterface = Substitute.For(); ``` ### 2.2 Return Values ```csharp // EN: Simple return value // VI: Giá trị trả về đơn giản orderRepository.GetByIdAsync(Arg.Any()).Returns(order); // EN: Return based on input // VI: Trả về dựa trên input orderRepository.GetByIdAsync(Arg.Any()) .Returns(callInfo => { var id = callInfo.Arg(); return id == existingId ? order : null; }); // EN: Return sequence of values // VI: Trả về dãy giá trị orderRepository.GetByIdAsync(Arg.Any()) .Returns(null, order, order); // First call returns null, then order // EN: Async return // VI: Trả về async orderRepository.GetByIdAsync(Arg.Any()) .Returns(Task.FromResult(order)); ``` ### 2.3 Argument Matching ```csharp // EN: Match any value // VI: Khớp bất kỳ giá trị nào Arg.Any() Arg.Any() // EN: Match specific value // VI: Khớp giá trị cụ thể Arg.Is(orderId) // EN: Match with condition // VI: Khớp với điều kiện Arg.Is(o => o.UserId == "user-123") Arg.Is(d => d > 0) // EN: Capture argument for later assertion // VI: Capture argument để assert sau Order? capturedOrder = null; await repository.AddAsync(Arg.Do(o => capturedOrder = o), Arg.Any()); // After executing... capturedOrder.Should().NotBeNull(); capturedOrder!.UserId.Should().Be("user-123"); ``` ### 2.4 Verification ```csharp // EN: Verify method was called once // VI: Xác minh method được gọi 1 lần await repository.Received(1).AddAsync(Arg.Any(), Arg.Any()); // EN: Verify method was not called // VI: Xác minh method không được gọi await repository.DidNotReceive().DeleteAsync(Arg.Any()); // EN: Verify call order // VI: Xác minh thứ tự gọi Received.InOrder(async () => { await repository.AddAsync(Arg.Any(), Arg.Any()); await unitOfWork.SaveChangesAsync(Arg.Any()); }); ``` ## 3. FluentAssertions Patterns ### 3.1 Basic Assertions ```csharp // Object assertions result.Should().NotBeNull(); result.Should().BeOfType(); result.Should().BeEquivalentTo(expectedDto); // Numeric assertions order.TotalAmount.Should().Be(100.50m); order.ItemCount.Should().BePositive(); order.Discount.Should().BeInRange(0, 100); // String assertions order.Status.Should().Be("Submitted"); error.Message.Should().Contain("invalid"); name.Should().StartWith("Order-"); // Collection assertions orders.Should().NotBeEmpty(); orders.Should().HaveCount(5); orders.Should().Contain(o => o.UserId == "user-123"); orders.Should().BeInDescendingOrder(o => o.CreatedAt); orders.Should().OnlyContain(o => o.Status != "Deleted"); ``` ### 3.2 Exception Assertions ```csharp // Sync exception var act = () => order.AddItem(productId, -1, 10.00m); act.Should().Throw() .WithMessage("*quantity*positive*"); // Async exception var act = async () => await handler.Handle(invalidCommand, ct); await act.Should().ThrowAsync() .Where(e => e.Errors.ContainsKey("Items")); ``` ## 4. Test Categories / Phân Loại Test ### Domain Layer Tests Test các invariants của Aggregate Root và Value Objects: ```csharp /// /// EN: Domain entity tests focus on business rules. /// VI: Domain entity tests tập trung vào business rules. /// public class OrderDomainTests { [Fact] public void AddItem_NegativeQuantity_ThrowsDomainException() { var order = CreateOrder(); var act = () => order.AddItem(Guid.NewGuid(), -1, 10.00m); act.Should().Throw(); } [Fact] public void AddItem_RaisesOrderItemAddedEvent() { var order = CreateOrder(); order.AddItem(Guid.NewGuid(), 2, 10.00m); order.DomainEvents.Should().ContainSingle() .Which.Should().BeOfType(); } } ``` ### Application Layer Tests Test Command/Query Handlers với mocked dependencies: ```csharp /// /// EN: Handler tests focus on orchestration logic. /// VI: Handler tests tập trung vào logic điều phối. /// public class GetOrderQueryHandlerTests { private readonly IOrderRepository _repository; private readonly GetOrderQueryHandler _handler; public GetOrderQueryHandlerTests() { _repository = Substitute.For(); _handler = new GetOrderQueryHandler(_repository); } [Fact] public async Task Handle_ExistingOrder_ReturnsMappedDto() { // Arrange var orderId = Guid.NewGuid(); var order = CreateOrder(orderId); _repository.GetWithItemsAsync(orderId).Returns(order); // Act var result = await _handler.Handle(new GetOrderQuery(orderId), CancellationToken.None); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(orderId); } [Fact] public async Task Handle_NonExistingOrder_ReturnsNull() { // Arrange _repository.GetWithItemsAsync(Arg.Any()).Returns((Order?)null); // Act var result = await _handler.Handle(new GetOrderQuery(Guid.NewGuid()), CancellationToken.None); // Assert result.Should().BeNull(); } } ``` ## 5. Test Data Builders / Builders Dữ Liệu Test Sử dụng Builder pattern để tạo test data dễ đọc và maintain: ```csharp /// /// EN: Builder for creating test Order instances. /// VI: Builder để tạo Order instances cho test. /// public class OrderBuilder { private string _userId = "default-user"; private Address _address = Address.Create("123 St", "City", "State", "12345", "VN"); private readonly List<(Guid ProductId, int Quantity, decimal UnitPrice)> _items = new(); public OrderBuilder WithUserId(string userId) { _userId = userId; return this; } public OrderBuilder WithAddress(Address address) { _address = address; return this; } public OrderBuilder WithItem(Guid productId, int quantity, decimal unitPrice) { _items.Add((productId, quantity, unitPrice)); return this; } public Order Build() { var order = Order.Create(_userId, _address); foreach (var item in _items) order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); return order; } } // Usage var order = new OrderBuilder() .WithUserId("user-123") .WithItem(Guid.NewGuid(), 2, 10.00m) .WithItem(Guid.NewGuid(), 1, 25.00m) .Build(); ``` ## 6. Parameterized Tests / Tests Tham Số Hóa ```csharp /// /// EN: Parameterized tests with Theory and InlineData. /// VI: Tests tham số hóa với Theory và InlineData. /// public class MoneyValidationTests { [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(-100)] public void Create_NonPositiveAmount_ThrowsException(decimal amount) { var act = () => Money.Create(amount, "VND"); act.Should().Throw() .WithMessage("*positive*"); } [Theory] [InlineData(100, "VND", 200, "VND", 300)] [InlineData(50.5, "USD", 25.25, "USD", 75.75)] public void Add_SameCurrency_ReturnsSum( decimal amount1, string currency1, decimal amount2, string currency2, decimal expected) { var money1 = Money.Create(amount1, currency1); var money2 = Money.Create(amount2, currency2); var result = money1.Add(money2); result.Amount.Should().Be(expected); } [Theory] [MemberData(nameof(InvalidCurrencyPairs))] public void Add_DifferentCurrency_ThrowsException(Money money1, Money money2) { var act = () => money1.Add(money2); act.Should().Throw(); } public static IEnumerable InvalidCurrencyPairs => new List { new object[] { Money.Create(100, "VND"), Money.Create(10, "USD") }, new object[] { Money.Create(50, "EUR"), Money.Create(50, "VND") }, }; } ``` ## 7. Common Anti-Patterns / Anti-Patterns Thường Gặp ### ❌ Testing Private Methods ```csharp // ❌ BAD: Using reflection to test private methods var method = typeof(Order).GetMethod("CalculateTotal", BindingFlags.NonPublic | BindingFlags.Instance); var result = method?.Invoke(order, null); // ✅ GOOD: Test public behavior that uses private methods order.AddItem(productId, 2, 10.00m); order.TotalAmount.Should().Be(20.00m); ``` ### ❌ Over-Mocking ```csharp // ❌ BAD: Mock everything including Value Objects var mockAddress = Substitute.For(); mockAddress.Street.Returns("123 St"); // ✅ GOOD: Use real Value Objects var address = Address.Create("123 St", "City", "State", "12345", "VN"); ``` ### ❌ God Tests ```csharp // ❌ BAD: One test doing too many things [Fact] public async Task OrderWorkflow_Everything_Works() { // Create order, add items, validate, submit, pay, ship, complete... // 100+ lines of test code } // ✅ GOOD: Focused tests [Fact] public async Task CreateOrder_ValidData_ReturnsOrderId() { } [Fact] public async Task AddItem_ValidProduct_IncreasesTotal() { } [Fact] public async Task Submit_ValidOrder_ChangesStatus() { } ```