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

23 KiB

name, description, compatibility, metadata
name description compatibility metadata
dotnet-senior-tester Thực hiện kiểm thử toàn diện cho .NET Microservices (xUnit, NSubstitute, TestServer, Polly) theo mô hình Test Pyramid. Use for unit testing, integration testing, functional testing, resilience testing, và test coverage analysis. .NET 10+, xUnit 2.x, NSubstitute 5.x, FluentAssertions, Testcontainers, Microsoft.AspNetCore.TestHost, Polly
author version role
Velik Ho 1.0 Senior SDET (Software Development Engineer in Test)

Workflow Kiểm Thử .NET (Senior Tester)

Bạn là một Senior SDET chuyên về .NET. Nhiệm vụ của bạn là đảm bảo chất lượng code thông qua 4 tầng kiểm thử nghiêm ngặt theo mô hình Test Pyramid. Bạn KHÔNG ĐƯỢC viết test sơ sài hoặc bỏ qua edge cases.

When to Use This Skill / Khi Nào Sử Dụng

Use this skill when:

  • Writing unit tests for MediatR handlers / Viết unit tests cho MediatR handlers
  • Testing domain entities và aggregate roots / Kiểm thử domain entities và aggregate roots
  • Creating integration tests with database / Tạo integration tests với database
  • Setting up functional API tests / Cài đặt functional API tests
  • Testing Polly resilience policies / Kiểm thử Polly resilience policies
  • Conducting test review / Đánh giá code test

Core Concepts / Khái Niệm Cốt Lõi

Test Pyramid / Kim Tự Tháp Testing

           /\
          /  \      E2E/Functional Tests (ít test, chậm, đắt)
         /----\
        /      \    Integration Tests (trung bình)
       /--------\
      /          \  Unit Tests (nhiều test, nhanh, rẻ)
     --------------
Tầng Phạm vi Tốc độ Dependencies
Unit Single class/method Milliseconds Mocked
Integration Multiple components + DB Seconds Real/Containerized
Functional Full API workflow Seconds Real services
Resilience Fault tolerance Seconds Mocked failures

Test Project Naming Convention

tests/
├── ServiceName.UnitTests/           # Kiểm thử đơn vị
│   ├── Handlers/
│   ├── Domain/
│   └── Validators/
├── ServiceName.IntegrationTests/    # Kiểm thử tích hợp
│   ├── Fixtures/
│   └── Repositories/
├── ServiceName.FunctionalTests/     # Kiểm thử chức năng
│   └── ApiTests/
└── ServiceName.ResilienceTests/     # Kiểm thử khả năng phục hồi
    └── Policies/

Giai đoạn 1: Unit Testing (Tốc độ cao - Cô lập hoàn toàn)

Mục tiêu: Kiểm tra logic nghiệp vụ cốt lõi mà không phụ thuộc vào Database, API hay File System.

1.1 Domain Layer Testing

Kiểm thử Aggregate Root

/// <summary>
/// EN: Tests for Order aggregate root invariants.
/// VI: Kiểm thử các quy tắc bất biến của Order aggregate root.
/// </summary>
public class OrderAggregateTests
{
    [Fact]
    public void AddItem_ValidItem_IncreasesTotalAmount()
    {
        // Arrange
        var order = Order.Create("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);
        order.DomainEvents.Should().ContainSingle(e => e is OrderItemAddedEvent);
    }

    [Fact]
    public void Submit_EmptyOrder_ThrowsDomainException()
    {
        // Arrange
        var order = Order.Create("user-123", CreateAddress());

        // Act & Assert
        var act = () => order.Submit();
        act.Should().Throw<DomainException>()
            .WithMessage("*cannot submit empty*");
    }

    private static Address CreateAddress() =>
        Address.Create("123 Main St", "City", "State", "12345", "VN");
}

Kiểm thử Value Object

/// <summary>
/// EN: Tests for Money value object immutability and equality.
/// VI: Kiểm thử tính bất biến và so sánh bằng giá trị của Money value object.
/// </summary>
public class MoneyValueObjectTests
{
    [Fact]
    public void Add_SameCurrency_ReturnsNewInstance()
    {
        // Arrange
        var money1 = Money.Create(100, "VND");
        var money2 = Money.Create(50, "VND");

        // Act
        var result = money1.Add(money2);

        // Assert
        result.Amount.Should().Be(150);
        result.Should().NotBeSameAs(money1); // Immutability check
    }

    [Fact]
    public void Add_DifferentCurrency_ThrowsException()
    {
        // Arrange
        var vnd = Money.Create(100, "VND");
        var usd = Money.Create(10, "USD");

        // Act & Assert
        var act = () => vnd.Add(usd);
        act.Should().Throw<InvalidOperationException>()
            .WithMessage("*currency mismatch*");
    }

    [Fact]
    public void Equals_SameValues_ReturnsTrue()
    {
        // Arrange & Act
        var money1 = Money.Create(100, "VND");
        var money2 = Money.Create(100, "VND");

        // Assert
        money1.Should().Be(money2);
        money1.GetHashCode().Should().Be(money2.GetHashCode());
    }
}

1.2 Application Layer Testing

Kiểm thử Command Handler

/// <summary>
/// EN: Unit test for CreateOrderCommandHandler using NSubstitute.
/// VI: Unit test cho CreateOrderCommandHandler sử dụng NSubstitute.
/// </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>>();
        
        _handler = new CreateOrderCommandHandler(_orderRepository, _logger);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var command = new CreateOrderCommand(
            UserId: "user-123",
            ShippingAddress: new AddressDto("123 Main St", "City", "State", "12345", "VN"),
            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();
        
        // Verify repository called exactly once
        await _orderRepository.Received(1).AddAsync(
            Arg.Is<Order>(o => o.UserId == "user-123"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_EmptyItems_ThrowsValidationException()
    {
        // Arrange
        var command = new CreateOrderCommand(
            UserId: "user-123",
            ShippingAddress: new AddressDto("123 Main St", "City", "State", "12345", "VN"),
            Items: new List<OrderItemDto>()); // Empty!

        // Act & Assert
        await Assert.ThrowsAsync<ValidationException>(() =>
            _handler.Handle(command, CancellationToken.None));

        // Verify repository was NOT called
        await _orderRepository.DidNotReceive().AddAsync(
            Arg.Any<Order>(), 
            Arg.Any<CancellationToken>());
    }
}

Giai đoạn 2: Integration Testing (Kiểm tra sự kết hợp)

Mục tiêu: Đảm bảo code tương tác đúng với Infrastructure (Database, EF Core, External Services).

2.1 Database Fixture với Testcontainers

/// <summary>
/// EN: PostgreSQL container fixture for integration tests.
/// VI: Fixture container PostgreSQL cho integration tests.
/// </summary>
public class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test123")
        .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();
    }
}

/// <summary>
/// EN: Collection definition for shared database fixture.
/// VI: Định nghĩa collection cho fixture database dùng chung.
/// </summary>
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<PostgresFixture>
{
    // This class has no code, just decorates for xUnit
}

2.2 Repository Integration Tests

/// <summary>
/// EN: Integration tests for OrderRepository with real PostgreSQL.
/// VI: Integration tests cho OrderRepository với PostgreSQL thật.
/// </summary>
[Collection("Database")]
public class OrderRepositoryIntegrationTests : IClassFixture<PostgresFixture>
{
    private readonly PostgresFixture _fixture;
    private readonly OrderRepository _repository;

    public OrderRepositoryIntegrationTests(PostgresFixture fixture)
    {
        _fixture = fixture;
        _repository = new OrderRepository(_fixture.DbContext);
    }

    [Fact]
    public async Task AddAsync_ValidOrder_PersistsWithAllRelatedEntities()
    {
        // Arrange
        var order = Order.Create("user-123", Address.Create("St", "City", "State", "12345", "VN"));
        order.AddItem(Guid.NewGuid(), 2, 10.00m);
        order.AddItem(Guid.NewGuid(), 1, 25.00m);

        // Act
        await _repository.AddAsync(order);
        await _repository.UnitOfWork.SaveChangesAsync();

        // Assert - Query directly to verify persistence
        var savedOrder = await _fixture.DbContext.Orders
            .Include(o => o.OrderItems)
            .FirstOrDefaultAsync(o => o.Id == order.Id);

        savedOrder.Should().NotBeNull();
        savedOrder!.OrderItems.Should().HaveCount(2);
        savedOrder.TotalAmount.Should().Be(45.00m);
    }

    [Fact]
    public async Task GetWithItemsAsync_ExistingOrder_LoadsEagerRelationships()
    {
        // Arrange - Seed data
        var order = Order.Create("user-456", Address.Create("St", "City", "State", "12345", "VN"));
        order.AddItem(Guid.NewGuid(), 3, 15.00m);
        await _repository.AddAsync(order);
        await _repository.UnitOfWork.SaveChangesAsync();

        // Act - Clear tracking to force fresh load
        _fixture.DbContext.ChangeTracker.Clear();
        var loadedOrder = await _repository.GetWithItemsAsync(order.Id);

        // Assert
        loadedOrder.Should().NotBeNull();
        loadedOrder!.OrderItems.Should().NotBeEmpty();
    }
}

Giai đoạn 3: Functional / API Testing (End-to-End)

Mục tiêu: Kiểm tra luồng hoạt động từ góc độ người dùng (API Consumer).

3.1 Custom WebApplicationFactory

/// <summary>
/// EN: Web application factory for functional API tests.
/// VI: Web application factory cho functional API 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_{Guid.NewGuid()}"));

            // EN: Seed test data
            // VI: Seed dữ liệu test
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            db.Database.EnsureCreated();
            SeedTestData(db);
        });
    }

    private static void SeedTestData(ApplicationDbContext db)
    {
        // Seed any required test data here
    }
}

3.2 API Scenario Tests

/// <summary>
/// EN: Functional tests for Orders API endpoints.
/// VI: Functional tests cho Orders API endpoints.
/// </summary>
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public OrdersApiTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateOrder_ValidRequest_Returns201WithLocationHeader()
    {
        // Arrange
        var request = new
        {
            UserId = "user-123",
            ShippingAddress = new { Street = "123 St", City = "City", State = "ST", PostalCode = "12345", Country = "VN" },
            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);
        response.Headers.Location.Should().NotBeNull();
        
        var content = await response.Content.ReadFromJsonAsync<OrderCreatedResponse>();
        content!.OrderId.Should().NotBeEmpty();
    }

    [Fact]
    public async Task FullOrderWorkflow_CreateUpdateGet_Success()
    {
        // Step 1: Create order
        var createRequest = new
        {
            UserId = "user-workflow",
            ShippingAddress = new { Street = "123 St", City = "City", State = "ST", PostalCode = "12345", Country = "VN" },
            Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m } }
        };
        var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", createRequest);
        createResponse.EnsureSuccessStatusCode();
        var created = await createResponse.Content.ReadFromJsonAsync<OrderCreatedResponse>();

        // Step 2: Get order
        var getResponse = await _client.GetAsync($"/api/v1/orders/{created!.OrderId}");
        getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
        var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
        order!.Status.Should().Be("Draft");

        // Step 3: Submit order
        var submitResponse = await _client.PostAsync(
            $"/api/v1/orders/{created.OrderId}/submit", null);
        submitResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        // Step 4: Verify status changed
        var verifyResponse = await _client.GetAsync($"/api/v1/orders/{created.OrderId}");
        var updatedOrder = await verifyResponse.Content.ReadFromJsonAsync<OrderDto>();
        updatedOrder!.Status.Should().Be("Submitted");
    }

    [Fact]
    public async Task GetOrder_NotFound_Returns404WithProblemDetails()
    {
        // Act
        var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        problem!.Title.Should().Contain("Not Found");
    }
}

Giai đoạn 4: Resilience Testing (Khả năng phục hồi)

Mục tiêu: Đảm bảo hệ thống không sập khi external services gặp lỗi.

4.1 Testing Retry Policy

/// <summary>
/// EN: Tests for HTTP retry policy behavior.
/// VI: Kiểm thử hành vi của retry policy cho HTTP.
/// </summary>
public class HttpRetryPolicyTests
{
    [Fact]
    public async Task RetryPolicy_TransientFailure_RetriesAndSucceeds()
    {
        // Arrange
        var callCount = 0;
        var mockHandler = new MockHttpMessageHandler(request =>
        {
            callCount++;
            if (callCount < 3)
                return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
            return new HttpResponseMessage(HttpStatusCode.OK);
        });

        var retryPolicy = Policy<HttpResponseMessage>
            .Handle<HttpRequestException>()
            .OrResult(r => (int)r.StatusCode >= 500)
            .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromMilliseconds(100));

        var httpClient = new HttpClient(mockHandler);

        // Act
        var response = await retryPolicy.ExecuteAsync(() =>
            httpClient.GetAsync("http://test-api/orders"));

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        callCount.Should().Be(3); // 2 failures + 1 success
    }
}

/// <summary>
/// EN: Mock HTTP handler for testing.
/// VI: Mock HTTP handler cho testing.
/// </summary>
public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;

    public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
    {
        _handler = handler;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return Task.FromResult(_handler(request));
    }
}

4.2 Testing Circuit Breaker

/// <summary>
/// EN: Tests for circuit breaker policy behavior.
/// VI: Kiểm thử hành vi của circuit breaker policy.
/// </summary>
public class CircuitBreakerPolicyTests
{
    [Fact]
    public async Task CircuitBreaker_ConsecutiveFailures_OpenCircuit()
    {
        // Arrange
        var callCount = 0;
        var circuitBreaker = Policy
            .Handle<HttpRequestException>()
            .CircuitBreakerAsync(
                exceptionsAllowedBeforeBreaking: 2,
                durationOfBreak: TimeSpan.FromSeconds(30));

        var failingAction = new Func<Task>(() =>
        {
            callCount++;
            throw new HttpRequestException("Service unavailable");
        });

        // Act - Trigger failures to open circuit
        await Assert.ThrowsAsync<HttpRequestException>(() => 
            circuitBreaker.ExecuteAsync(failingAction));
        await Assert.ThrowsAsync<HttpRequestException>(() => 
            circuitBreaker.ExecuteAsync(failingAction));

        // Assert - Circuit should now be open
        circuitBreaker.CircuitState.Should().Be(CircuitState.Open);
        callCount.Should().Be(2);

        // Further calls should fail immediately with BrokenCircuitException
        await Assert.ThrowsAsync<BrokenCircuitException>(() =>
            circuitBreaker.ExecuteAsync(failingAction));
        callCount.Should().Be(2); // No additional calls made
    }
}

Common Mistakes / Lỗi Thường Gặp

1. Testing Implementation Details

// ❌ BAD: Testing internal state via reflection
order.GetType().GetField("_status", BindingFlags.NonPublic | BindingFlags.Instance)
    ?.GetValue(order).Should().Be(OrderStatus.Draft);

// ✅ GOOD: Testing public behavior
order.Status.Should().Be(OrderStatus.Draft);
order.Submit();
order.Status.Should().Be(OrderStatus.Submitted);

2. Sharing State Between Tests

// ❌ BAD: Static shared state causes flaky tests
private static Order _sharedOrder = new Order(...);

// ✅ GOOD: Fresh instance per test (factory method)
private Order CreateTestOrder() => 
    Order.Create("user-123", CreateAddress());

3. Ignoring Async Best Practices

// ❌ BAD: Blocking call causes deadlocks
var result = _handler.Handle(command, ct).Result;

// ✅ GOOD: Proper async/await
var result = await _handler.Handle(command, ct);

4. Missing Cancellation Token Testing

// ❌ BAD: Never test cancellation
await _handler.Handle(command, CancellationToken.None);

// ✅ GOOD: Test cancellation handling
var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
    _handler.Handle(command, cts.Token));

5. Overly Specific Mocks

// ❌ BAD: Brittle - will break if ID generation changes
_repo.GetByIdAsync(Arg.Is<Guid>(g => g == new Guid("12345..."))).Returns(order);

// ✅ GOOD: Flexible matching
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns(order);

Quick Reference / Tham Chiếu Nhanh

Test Attributes

Attribute Purpose
[Fact] Single test case
[Theory] Parameterized test
[InlineData] Inline parameters
[MemberData] Complex parameters from method
[ClassData] Complex parameters from class
[Collection] Shared fixture across classes

NSubstitute Patterns

// Create substitute
var service = Substitute.For<IOrderService>();

// Setup return value
service.GetByIdAsync(Arg.Any<Guid>()).Returns(order);

// Setup for any args with condition
service.GetByIdAsync(Arg.Is<Guid>(id => id != Guid.Empty)).Returns(order);

// Verify call count
await service.Received(1).GetByIdAsync(orderId);
await service.DidNotReceive().DeleteAsync(Arg.Any<Guid>());

// Capture arguments
Order? capturedOrder = null;
await repo.AddAsync(Arg.Do<Order>(o => capturedOrder = o));

Test Commands

# Run all tests
dotnet test

# Run specific project
dotnet test tests/Service.UnitTests

# Run with coverage
dotnet test --collect:"XPlat Code Coverage"

# Run tests matching filter
dotnet test --filter "FullyQualifiedName~CreateOrder"

# Run with detailed output
dotnet test --logger "console;verbosity=detailed"

Resources / Tài Nguyên