11 KiB
11 KiB
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)
[Fact]
public async Task MethodName_Condition_ExpectedResult()
{
// Arrange - Chuẩn bị dữ liệu và dependencies
var mockRepository = Substitute.For<IOrderRepository>();
var handler = new CreateOrderCommandHandler(mockRepository);
var command = CreateValidCommand();
mockRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(x => x.Arg<Order>());
// 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]
// ✅ 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
// EN: Create substitute for interface
// VI: Tạo substitute cho interface
var orderRepository = Substitute.For<IOrderRepository>();
// EN: Create substitute for abstract class
// VI: Tạo substitute cho abstract class
var baseService = Substitute.For<BaseOrderService>();
// EN: Substitute for multiple interfaces
// VI: Substitute cho nhiều interfaces
var multiInterface = Substitute.For<IOrderRepository, IDisposable>();
2.2 Return Values
// EN: Simple return value
// VI: Giá trị trả về đơn giản
orderRepository.GetByIdAsync(Arg.Any<Guid>()).Returns(order);
// EN: Return based on input
// VI: Trả về dựa trên input
orderRepository.GetByIdAsync(Arg.Any<Guid>())
.Returns(callInfo =>
{
var id = callInfo.Arg<Guid>();
return id == existingId ? order : null;
});
// EN: Return sequence of values
// VI: Trả về dãy giá trị
orderRepository.GetByIdAsync(Arg.Any<Guid>())
.Returns(null, order, order); // First call returns null, then order
// EN: Async return
// VI: Trả về async
orderRepository.GetByIdAsync(Arg.Any<Guid>())
.Returns(Task.FromResult<Order?>(order));
2.3 Argument Matching
// EN: Match any value
// VI: Khớp bất kỳ giá trị nào
Arg.Any<Guid>()
Arg.Any<Order>()
// 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<Order>(o => o.UserId == "user-123")
Arg.Is<decimal>(d => d > 0)
// EN: Capture argument for later assertion
// VI: Capture argument để assert sau
Order? capturedOrder = null;
await repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>());
// After executing...
capturedOrder.Should().NotBeNull();
capturedOrder!.UserId.Should().Be("user-123");
2.4 Verification
// EN: Verify method was called once
// VI: Xác minh method được gọi 1 lần
await repository.Received(1).AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
// EN: Verify method was not called
// VI: Xác minh method không được gọi
await repository.DidNotReceive().DeleteAsync(Arg.Any<Guid>());
// EN: Verify call order
// VI: Xác minh thứ tự gọi
Received.InOrder(async () =>
{
await repository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
await unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>());
});
3. FluentAssertions Patterns
3.1 Basic Assertions
// Object assertions
result.Should().NotBeNull();
result.Should().BeOfType<OrderDto>();
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
// Sync exception
var act = () => order.AddItem(productId, -1, 10.00m);
act.Should().Throw<DomainException>()
.WithMessage("*quantity*positive*");
// Async exception
var act = async () => await handler.Handle(invalidCommand, ct);
await act.Should().ThrowAsync<ValidationException>()
.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:
/// <summary>
/// EN: Domain entity tests focus on business rules.
/// VI: Domain entity tests tập trung vào business rules.
/// </summary>
public class OrderDomainTests
{
[Fact]
public void AddItem_NegativeQuantity_ThrowsDomainException()
{
var order = CreateOrder();
var act = () => order.AddItem(Guid.NewGuid(), -1, 10.00m);
act.Should().Throw<DomainException>();
}
[Fact]
public void AddItem_RaisesOrderItemAddedEvent()
{
var order = CreateOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
order.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<OrderItemAddedEvent>();
}
}
Application Layer Tests
Test Command/Query Handlers với mocked dependencies:
/// <summary>
/// EN: Handler tests focus on orchestration logic.
/// VI: Handler tests tập trung vào logic điều phối.
/// </summary>
public class GetOrderQueryHandlerTests
{
private readonly IOrderRepository _repository;
private readonly GetOrderQueryHandler _handler;
public GetOrderQueryHandlerTests()
{
_repository = Substitute.For<IOrderRepository>();
_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<Guid>()).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:
/// <summary>
/// EN: Builder for creating test Order instances.
/// VI: Builder để tạo Order instances cho test.
/// </summary>
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
/// <summary>
/// EN: Parameterized tests with Theory and InlineData.
/// VI: Tests tham số hóa với Theory và InlineData.
/// </summary>
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<DomainException>()
.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<InvalidOperationException>();
}
public static IEnumerable<object[]> InvalidCurrencyPairs =>
new List<object[]>
{
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
// ❌ 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
// ❌ BAD: Mock everything including Value Objects
var mockAddress = Substitute.For<IAddress>();
mockAddress.Street.Returns("123 St");
// ✅ GOOD: Use real Value Objects
var address = Address.Create("123 St", "City", "State", "12345", "VN");
❌ God Tests
// ❌ 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() { }