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

729 lines
23 KiB
Markdown

---
name: dotnet-senior-tester
description: 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.
compatibility: ".NET 10+, xUnit 2.x, NSubstitute 5.x, FluentAssertions, Testcontainers, Microsoft.AspNetCore.TestHost, Polly"
metadata:
author: Velik Ho
version: "1.0"
role: 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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
/// <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
```csharp
// ❌ 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
```csharp
// ❌ 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
```csharp
// ❌ 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
```csharp
// ❌ 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
```csharp
// ❌ 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
```csharp
// 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
```bash
# 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
- [Unit Test Rules](./guidelines/unit-test-rules.md) - Quy tắc chi tiết cho Unit Testing
- [Integration Test Rules](./guidelines/integration-rules.md) - Quy tắc cho Integration Testing
- [Resilience Test Rules](./guidelines/resilience-test.md) - Quy tắc cho Polly Testing
- [Full Code Examples](./references/REFERENCE.md) - Ví dụ code đầy đủ
### Related Skills
- [Testing Patterns](../testing-patterns/SKILL.md) - Complementary testing patterns
- [Repository Pattern](../repository-pattern/SKILL.md) - Repository testing
- [Error Handling](../error-handling-patterns/SKILL.md) - Exception testing
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - Handler testing
- [API Design](../api-design/SKILL.md) - Controller testing