EngineeringSenior DeveloperTraining Sessions

Session 12 - Testing Strategy: Không Phải Viết Test Cho Vui

Nhiều team viết test vì "phải có coverage". Kết quả: test brittle, test chậm, test sai thứ. Session này giúp bạn test ĐÚNG THỨ, đúng mức.


Mục tiêu

  • Hiểu Test Pyramid và áp dụng thực tế
  • Biết test gì và KHÔNG test gì
  • Unit vs Integration: khi nào dùng gì
  • Test patterns cho .NET

1) Test Pyramid - Thực Tế

                 ┌──────┐
                 │ E2E  │  Ít nhất, chậm nhất, brittle nhất
                 │      │  → Chỉ happy path + critical paths
                ┌┴──────┴┐
                │ Integ- │  Trung bình, test component interactions
                │ ration │  → API endpoints, DB queries, message handlers
               ┌┴────────┴┐
               │   Unit    │  Nhiều nhất, nhanh nhất, stable nhất
               │           │  → Business logic, validation, calculations
               └───────────┘

Thực tế .NET project

Unit Tests:
  - Domain models (aggregate, value objects)
  - Validators (FluentValidation)
  - Pure functions (calculations, transformations)
  - MediatR handlers (mock dependencies)
  → Nhanh (<1ms per test), nhiều tests

Integration Tests:
  - API endpoints (WebApplicationFactory)
  - Database operations (EF Core + TestContainers)
  - Message consumers (MassTransit test harness)
  → Chậm hơn (100ms-2s), ít tests hơn

E2E Tests:
  - Critical user flows (đặt hàng, thanh toán)
  - UI testing (Playwright)
  → Chậm nhất (5-30s), ít nhất, dễ brittle

2) Test GÌ - Test KHÔNG Gì

SHOULD Test

csharp
// ✅ Business logic (CÓ GIÁ TRỊ CAO)
[Fact]
public void Order_ApplyDiscount_ShouldReduceTotal()
{
    var order = Order.Create(customerId, items);
    order.ApplyDiscount(Discount.Percentage(10));
    Assert.Equal(90m, order.Total.Amount);
}

// ✅ Edge cases (BUGS ẩn ở đây)
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MaxValue)]
public void Order_AddItem_InvalidQuantity_ShouldFail(int quantity)
{
    var order = Order.Create(customerId);
    var result = order.AddItem(productId, quantity);
    Assert.True(result.IsFailure);
}

// ✅ Validation rules (boundary protection)
[Fact]
public void CreateOrderValidator_EmptyItems_ShouldFail()
{
    var validator = new CreateOrderValidator();
    var result = validator.Validate(new CreateOrderCommand { Items = [] });
    Assert.False(result.IsValid);
}

SHOULD NOT Test

csharp
// ❌ Framework code (EF Core, ASP.NET đã test rồi)
[Fact]
public void DbContext_CanSaveAndRetrieve()
{
    db.Users.Add(new User { Name = "test" });
    db.SaveChanges();
    var user = db.Users.First();
    Assert.Equal("test", user.Name);
}
// → Test EF Core hoạt động? Microsoft đã test rồi!

// ❌ Trivial code
[Fact]
public void User_Name_ShouldReturnName()
{
    var user = new User { Name = "John" };
    Assert.Equal("John", user.Name); // 🤡 Test getter?
}

// ❌ Private methods (test qua public interface)
// ❌ Configuration (test integration, không unit)
// ❌ Third-party library internals

3) Unit Test - Focus Business Logic

csharp
// Pattern: Arrange → Act → Assert
[Fact]
public void Order_Submit_WithInsufficientStock_ShouldFail()
{
    // Arrange
    var order = OrderFactory.CreateDraft();
    var product = ProductFactory.CreateWithStock(5);
    order.AddItem(product.Id, quantity: 10); // Muốn mua 10, chỉ có 5

    // Act
    var result = order.Submit(stockChecker: new FakeStockChecker(product));

    // Assert
    Assert.True(result.IsFailure);
    Assert.Equal("Insufficient stock", result.Error);
    Assert.Equal(OrderStatus.Draft, order.Status); // Vẫn Draft, KHÔNG Submit
}

// Mock dependencies: chỉ mock EXTERNAL dependencies (DB, HTTP, Email)
// KHÔNG mock domain logic!
[Fact]
public async Task CreateOrderHandler_ShouldSaveAndPublishEvent()
{
    // Arrange
    var repo = new Mock<IOrderRepository>();
    var uow = new Mock<IUnitOfWork>();
    var handler = new CreateOrderHandler(repo.Object, uow.Object);

    // Act
    var result = await handler.Handle(new CreateOrderCommand { /* ... */ }, CancellationToken.None);

    // Assert
    Assert.True(result.IsSuccess);
    repo.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
    uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
}

4) Integration Test - Test Real Dependencies

csharp
// WebApplicationFactory: spin up real ASP.NET Core app in test
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                // Replace real DB with test DB
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb"));

                // Replace real email with fake
                services.RemoveAll<IEmailService>();
                services.AddSingleton<IEmailService, FakeEmailService>();
            });
        }).CreateClient();
    }

    [Fact]
    public async Task CreateOrder_ValidRequest_Returns201()
    {
        // Arrange
        var request = new { ProductId = Guid.NewGuid(), Quantity = 2 };

        // Act
        var response = await _client.PostAsJsonAsync("/api/orders", request);

        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        var order = await response.Content.ReadFromJsonAsync<OrderDto>();
        Assert.NotNull(order);
        Assert.NotEqual(Guid.Empty, order.Id);
    }

    [Fact]
    public async Task CreateOrder_Unauthorized_Returns401()
    {
        // No auth token
        var response = await _client.PostAsJsonAsync("/api/orders", new { });
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}

TestContainers (real database in Docker)

csharp
// Thay InMemoryDatabase bằng real SQL/PostgreSQL
public class OrdersApiTests : IAsyncLifetime
{
    private readonly MsSqlContainer _dbContainer = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public async Task InitializeAsync() => await _dbContainer.StartAsync();
    public async Task DisposeAsync() => await _dbContainer.DisposeAsync();

    // Tests dùng _dbContainer.GetConnectionString() → real SQL queries!
}

5) Test Naming & Organization

csharp
// Naming: MethodName_Scenario_ExpectedResult
[Fact]
public void Order_AddItem_NegativeQuantity_ShouldReturnFailure() { }

// Hoặc: GivenWhenThen
[Fact]
public void GivenEmptyOrder_WhenSubmitting_ThenShouldFail() { }

// File organization:
// Tests/
//   Unit/
//     Domain/
//       OrderTests.cs
//       MoneyTests.cs
//     Validators/
//       CreateOrderValidatorTests.cs
//   Integration/
//     Api/
//       OrdersApiTests.cs
//     Repositories/
//       OrderRepositoryTests.cs

Master Checklist ✅

□ Business logic có unit tests?
□ API endpoints có integration tests (happy + error paths)?
□ Validation rules tested (boundary values)?
□ Tests KHÔNG test framework internals?
□ Mock chỉ external dependencies (DB, HTTP)?
□ Tests chạy nhanh? (unit <10s, integration <60s)
□ Tests deterministic? (no random, no datetime.now)
□ Critical paths có E2E tests?

Tư duy master: Test tốt nhất là test mà khi FAIL, bạn biết NGAY bug ở đâu. Nếu test fail mà bạn phải debug 30 phút mới biết lỗi → test quá coarse hoặc sai level.