Files
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

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
author version
Velik Ho 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

/// <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