Files
pos-system/microservices/.agent/skills/dotnet-senior-tester/guidelines/unit-test-rules.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

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() { }