feat: Create Skills

This commit is contained in:
Ho Ngoc Hai
2026-01-15 18:02:34 +07:00
parent fe910a13bb
commit 8e53fadb19
8 changed files with 3832 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
---
name: dotnet-microservice-workflow
description: Workflow phát triển .NET Microservices theo 4 lớp (Domain → Infrastructure → Application → API). Use for creating new features, new services, hoặc khi cần structured approach theo DDD + CQRS + Clean Architecture.
compatibility: ".NET 10+, EF Core 9+, MediatR 12+"
metadata:
author: Velik Ho
version: "1.0"
references: "eShopOnContainers, .NET Microservices Architecture Guide"
---
# .NET Microservice Development Workflow
Quy trình 4 giai đoạn để phát triển features hoặc services theo chuẩn DDD + CQRS + Clean Architecture.
## When to Use This Skill / Khi Nào Sử Dụng
Use this workflow when:
- Creating a new domain feature / Tạo feature mới trong domain
- Building a new microservice from scratch / Xây service mới từ đầu
- Refactoring existing code to DDD / Refactor code sang DDD
- Need structured approach / Cần approach có cấu trúc
**DO NOT use when:**
- Simple CRUD operations / Các thao tác CRUD đơn giản
- Prototyping / Làm prototype nhanh
- Small utilities / Các utility nhỏ
## Overview / Tổng Quan
```
┌──────────────────────────────────────────────────────────────────┐
│ WORKFLOW 4 GIAI ĐOẠN │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ PHASE 1 │────►│ PHASE 2 │ │
│ │ DOMAIN │ │ INFRASTRUCTURE │ │
│ │ │ │ │ │
│ │ - Aggregates│ │ - EF Config │ │
│ │ - Entities │ │ - Repositories │ │
│ │ - V.Objects │ │ - Migrations │ │
│ │ - D.Events │ │ - Idempotency │ │
│ └─────────────┘ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ PHASE 4 │◄────│ PHASE 3 │ │
│ │ API │ │ APPLICATION │ │
│ │ │ │ │ │
│ │ - Controls │ │ - Commands │ │
│ │ - Health │ │ - Queries │ │
│ │ - Resilien. │ │ - Handlers │ │
│ │ - OpenAPI │ │ - Behaviors │ │
│ └─────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
## Phase 1: Domain Layer / Lớp Nghiệp Vụ
**Goal**: Thiết kế Domain Model sạch, không phụ thuộc framework
**Key Skill**: [Domain-Driven Design](../domain-driven-design/SKILL.md)
### Checklist
1. **Xác định Aggregate Root**
- Mọi thay đổi dữ liệu phải đi qua Aggregate Root
- Đảm bảo tính nhất quán (Consistency)
2. **Thiết kế Entities**
- Properties với `private set`
- Thao tác qua methods có ý nghĩa nghiệp vụ
- Backing fields cho collections
3. **Tạo Value Objects**
- Immutable (bất biến)
- Equality by all properties
4. **Định nghĩa Domain Events**
- Implement `IDomainEvent`
- Raise trong aggregate methods
5. **Unit Tests**
- Test business rules trong domain
### Rules
```csharp
// ❌ SAI: Public setter, expose mutable collection
public class Order
{
public OrderStatus Status { get; set; }
public List<OrderItem> Items { get; set; }
}
// ✅ ĐÚNG: Private setter, ReadOnly collection
public class Order : Entity, IAggregateRoot
{
private readonly List<OrderItem> _items = new();
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public void AddItem(Guid productId, int quantity, decimal price)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify non-draft order");
_items.Add(new OrderItem(productId, quantity, price));
}
}
```
---
## Phase 2: Infrastructure Layer / Lớp Hạ Tầng
**Goal**: Triển khai persistence mà không làm ô nhiễm Domain
**Key Skill**: [Repository Pattern](../repository-pattern/SKILL.md)
### Checklist
1. **EF Core Configurations**
- Sử dụng `IEntityTypeConfiguration`
- Fluent API, không Data Annotations
2. **Repository cho Aggregate Root**
- Chỉ 1 repository per aggregate
- Implement `IUnitOfWork`
3. **Database Migrations**
- Tạo và apply migrations
4. **Idempotency (Optional)**
- `IdentifiedCommand` cho critical operations
### Rules
```csharp
// ❌ SAI: Repository cho từng table
public interface IOrderItemRepository { }
public interface IOrderPaymentRepository { }
// ✅ ĐÚNG: Chỉ repository cho Aggregate Root
public interface IOrderRepository : IRepository<Order>
{
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct);
}
```
---
## Phase 3: Application Layer (CQRS)
**Goal**: Tách biệt luồng Đọc (Query) và Ghi (Command)
**Key Skill**: [CQRS MediatR](../cqrs-mediatr/SKILL.md)
### Checklist
1. **Commands**
- Tạo Command record
- Implement CommandHandler
- Gọi domain methods, save qua Repository
2. **Queries**
- Bypass Domain Model
- Sử dụng Dapper cho performance
- Return lightweight DTOs
3. **Validators**
- FluentValidation cho input validation
- Register trong Pipeline Behaviors
4. **Pipeline Behaviors**
- LoggingBehavior
- ValidationBehavior
- TransactionBehavior (optional)
### Rules
```csharp
// ❌ SAI: Business logic trong Handler
public async Task<OrderResult> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
if (cmd.Items.Count == 0)
throw new Exception("Empty order"); // Logic nên ở Domain!
}
// ✅ ĐÚNG: Delegate logic cho Domain
public async Task<OrderResult> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = new Order(cmd.UserId, cmd.Address);
foreach (var item in cmd.Items)
order.AddItem(item.ProductId, item.Quantity, item.Price); // Domain validates
await _repository.AddAsync(order, ct);
await _repository.UnitOfWork.SaveChangesAsync(ct);
return new OrderResult(order.Id);
}
```
---
## Phase 4: API Layer / Lớp API
**Goal**: Expose API đúng chuẩn với cross-cutting concerns
**Key Skills**:
- [API Design](../api-design/SKILL.md)
- [Error Handling](../error-handling-patterns/SKILL.md)
- [Inter-Service Communication](../inter-service-communication/SKILL.md)
### Checklist
1. **Slim Controllers**
- Chỉ nhận request, gọi MediatR
- ApiResponse wrapper
2. **Health Checks**
- Endpoint `/hc` hoặc `/health`
- Check DB, Redis, RabbitMQ connections
3. **Resilience (Polly)**
- Retry với Exponential Backoff
- Circuit Breaker cho external calls
4. **OpenAPI Documentation**
- SwaggerOperation attributes
- SwaggerResponse cho từng status code
5. **DI Registration**
- Register MediatR, Validators
- Register Repositories, Services
### Rules
```csharp
// ❌ SAI: Fat controller với logic
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var order = new Order(request.UserId, request.Address);
foreach (var item in request.Items)
order.AddItem(item.ProductId, item.Quantity, item.Price);
await _repository.AddAsync(order);
await _context.SaveChangesAsync();
return Ok(order.Id);
}
// ✅ ĐÚNG: Slim controller
[HttpPost]
public async Task<ActionResult<ApiResponse<OrderResult>>> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var command = request.ToCommand(GetUserId());
var result = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetOrder), new { id = result.OrderId },
ApiResponse<OrderResult>.Ok(result));
}
```
---
## Common Mistakes / Lỗi Thường Gặp
| Mistake | Problem | Solution |
|---------|---------|----------|
| Skip Domain Layer | Anemic model, logic scattered | Always start with Domain |
| Logic in Handler | Hard to test, duplicate | Move to Domain methods |
| Fat Controllers | Violates SRP | Use MediatR pattern |
| Query via EF Core | N+1, over-fetching | Use Dapper for reads |
| Repository per table | Breaks aggregate rules | Only for Aggregate Root |
## Quick Reference / Tham Chiếu Nhanh
| Phase | Layer | Location | Key Patterns |
|-------|-------|----------|--------------|
| 1 | Domain | `ServiceName.Domain/` | Aggregate, Entity, VO |
| 2 | Infrastructure | `ServiceName.Infrastructure/` | Repository, UoW, EF |
| 3 | Application | `ServiceName.API/Application/` | Command, Query, Handler |
| 4 | API | `ServiceName.API/Controllers/` | Controller, HealthCheck |
## Resources / Tài Nguyên
- [Detailed Checklist](./references/checklist.md) - Checklist từng giai đoạn
- [Technical Reference](./references/REFERENCE.md) - Chi tiết kỹ thuật
- [Domain-Driven Design](../domain-driven-design/SKILL.md) - DDD patterns
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - Command/Query handlers
- [API Design](../api-design/SKILL.md) - Controller patterns
- [Error Handling](../error-handling-patterns/SKILL.md) - Exception handling
- [Testing Patterns](../testing-patterns/SKILL.md) - Unit/Integration tests
- [Project Rules](../project-rules/SKILL.md) - Coding standards

View File

@@ -0,0 +1,663 @@
# Technical Reference: .NET Microservice Workflow
Chi tiết kỹ thuật cho từng giai đoạn phát triển .NET Microservices.
---
## Phase 1: Domain Layer Technical Details
### 1.1 Entity Base Class
```csharp
/// <summary>
/// EN: Base class for all entities with domain events support.
/// VI: Base class cho entities với hỗ trợ domain events.
/// </summary>
public abstract class Entity
{
private int? _requestedHashCode;
private List<IDomainEvent>? _domainEvents;
public virtual Guid Id { get; protected set; }
public IReadOnlyCollection<IDomainEvent> DomainEvents
=> _domainEvents?.AsReadOnly() ?? Array.Empty<IDomainEvent>().AsReadOnly();
public void AddDomainEvent(IDomainEvent eventItem)
{
_domainEvents ??= new List<IDomainEvent>();
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents() => _domainEvents?.Clear();
public bool IsTransient() => Id == default;
public override bool Equals(object? obj)
{
if (obj is not Entity other) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
if (IsTransient() || other.IsTransient()) return false;
return Id.Equals(other.Id);
}
public override int GetHashCode()
{
if (!IsTransient())
{
_requestedHashCode ??= Id.GetHashCode() ^ 31;
return _requestedHashCode.Value;
}
return base.GetHashCode();
}
}
```
### 1.2 Aggregate Root Interface
```csharp
/// <summary>
/// EN: Marker interface for aggregate roots.
/// VI: Interface đánh dấu aggregate roots.
/// </summary>
public interface IAggregateRoot { }
```
### 1.3 Value Object Base Class
```csharp
/// <summary>
/// EN: Base class for value objects.
/// VI: Base class cho value objects.
/// </summary>
public abstract class ValueObject
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType())
return false;
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public static bool operator ==(ValueObject? left, ValueObject? right)
=> left?.Equals(right) ?? right is null;
public static bool operator !=(ValueObject? left, ValueObject? right)
=> !(left == right);
}
```
### 1.4 Domain Event Interface
```csharp
/// <summary>
/// EN: Domain event interface, integrates with MediatR.
/// VI: Interface domain event, tích hợp với MediatR.
/// </summary>
public interface IDomainEvent : INotification
{
Guid Id { get; }
DateTime OccurredOn { get; }
}
```
### 1.5 Domain Exception
```csharp
/// <summary>
/// EN: Exception for domain rule violations.
/// VI: Exception cho vi phạm quy tắc domain.
/// </summary>
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception innerException)
: base(message, innerException) { }
}
```
---
## Phase 2: Infrastructure Layer Technical Details
### 2.1 Repository Interface
```csharp
/// <summary>
/// EN: Base repository interface for aggregate roots.
/// VI: Interface repository cơ bản cho aggregate roots.
/// </summary>
public interface IRepository<T> where T : class, IAggregateRoot
{
IUnitOfWork UnitOfWork { get; }
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<T> AddAsync(T entity, CancellationToken ct = default);
void Update(T entity);
void Delete(T entity);
}
```
### 2.2 Unit of Work Interface
```csharp
/// <summary>
/// EN: Unit of Work interface - DbContext implements this.
/// VI: Interface Unit of Work - DbContext implement interface này.
/// </summary>
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
```
### 2.3 EF Core Configuration Example
```csharp
/// <summary>
/// EN: EF Core configuration for Order aggregate.
/// VI: Cấu hình EF Core cho Order aggregate.
/// </summary>
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2);
// EN: Configure owned entity (Value Object)
// VI: Cấu hình owned entity (Value Object)
builder.OwnsOne(o => o.ShippingAddress, a =>
{
a.Property(p => p.Street).HasMaxLength(200);
a.Property(p => p.City).HasMaxLength(100);
a.Property(p => p.PostalCode).HasMaxLength(20);
});
// EN: Configure child entities
// VI: Cấu hình entities con
builder.HasMany(o => o.OrderItems)
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
// EN: Use backing field for collection
// VI: Dùng backing field cho collection
builder.Navigation(o => o.OrderItems)
.UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
```
### 2.4 Repository Implementation
```csharp
/// <summary>
/// EN: EF Core repository implementation.
/// VI: Triển khai repository với EF Core.
/// </summary>
public class OrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _context;
public IUnitOfWork UnitOfWork => _context;
public OrderRepository(ApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Orders.FindAsync(new object[] { id }, ct);
}
public async Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<Order> AddAsync(Order order, CancellationToken ct = default)
{
var entry = await _context.Orders.AddAsync(order, ct);
return entry.Entity;
}
public void Update(Order order)
{
_context.Entry(order).State = EntityState.Modified;
}
public void Delete(Order order)
{
_context.Orders.Remove(order);
}
}
```
---
## Phase 3: Application Layer Technical Details
### 3.1 Command Definition
```csharp
/// <summary>
/// EN: Command to create a new order.
/// VI: Command tạo order mới.
/// </summary>
public record CreateOrderCommand(
string UserId,
Address ShippingAddress,
List<OrderItemDto> Items) : IRequest<OrderResult>;
public record OrderResult(Guid OrderId);
```
### 3.2 Command Handler
```csharp
/// <summary>
/// EN: Handler for CreateOrderCommand.
/// VI: Handler cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
ILogger<CreateOrderCommandHandler> logger)
{
_orderRepository = orderRepository;
_logger = logger;
}
public async Task<OrderResult> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Create order through domain model
// VI: Tạo order qua domain model
var order = new Order(request.UserId, request.ShippingAddress);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
_logger.LogInformation(
"EN: Order created / VI: Order đã tạo: {OrderId}",
order.Id);
return new OrderResult(order.Id);
}
}
```
### 3.3 Query with Dapper
```csharp
/// <summary>
/// EN: Query handler using Dapper for optimized reads.
/// VI: Query handler dùng Dapper cho đọc tối ưu.
/// </summary>
public class GetUserOrdersQueryHandler
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
{
private readonly IDbConnection _connection;
public GetUserOrdersQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetUserOrdersQuery request,
CancellationToken ct)
{
const string countSql = "SELECT COUNT(*) FROM Orders WHERE UserId = @UserId";
var total = await _connection.ExecuteScalarAsync<int>(countSql, new { request.UserId });
const string sql = @"
SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt,
COUNT(oi.Id) as ItemCount
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
WHERE o.UserId = @UserId
GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt
ORDER BY o.CreatedAt DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
var orders = await _connection.QueryAsync<OrderSummaryDto>(sql, new
{
request.UserId,
request.Skip,
request.Take
});
return new PagedResult<OrderSummaryDto>(orders.ToList(), total);
}
}
```
### 3.4 Validation Behavior
```csharp
/// <summary>
/// EN: Pipeline behavior for FluentValidation.
/// VI: Pipeline behavior cho FluentValidation.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
```
---
## Phase 4: API Layer Technical Details
### 4.1 Slim Controller
```csharp
/// <summary>
/// EN: Slim controller using MediatR.
/// VI: Controller gọn nhẹ với MediatR.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
[SwaggerTag("Order Management")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// EN: Create a new order.
/// VI: Tạo order mới.
/// </summary>
[HttpPost]
[SwaggerOperation(Summary = "Create order")]
[SwaggerResponse(201, "Created")]
[SwaggerResponse(400, "Validation error")]
public async Task<ActionResult<ApiResponse<OrderResult>>> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var command = new CreateOrderCommand(
GetUserId(),
request.ShippingAddress,
request.Items);
var result = await _mediator.Send(command, ct);
return CreatedAtAction(
nameof(GetOrder),
new { orderId = result.OrderId },
ApiResponse<OrderResult>.Ok(result));
}
private string GetUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException();
}
```
### 4.2 Health Checks
```csharp
/// <summary>
/// EN: Health check configuration in Program.cs.
/// VI: Cấu hình health check trong Program.cs.
/// </summary>
// EN: Register health checks
// VI: Đăng ký health checks
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "postgresql")
.AddRedis(redisConnectionString, name: "redis")
.AddRabbitMQ(rabbitConnectionString, name: "rabbitmq");
// EN: Map health check endpoint
// VI: Map endpoint health check
app.MapHealthChecks("/hc", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
```
### 4.3 Resilience with Polly
```csharp
/// <summary>
/// EN: Configure HTTP client with Polly resilience.
/// VI: Cấu hình HTTP client với Polly resilience.
/// </summary>
builder.Services.AddHttpClient<ICatalogService, CatalogService>(client =>
{
client.BaseAddress = new Uri(catalogServiceUrl);
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
// Log retry attempt
});
}
static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30));
}
```
### 4.4 DI Registration
```csharp
/// <summary>
/// EN: DI registration in Program.cs.
/// VI: Đăng ký DI trong Program.cs.
/// </summary>
// EN: Register MediatR with behaviors
// VI: Đăng ký MediatR với behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// EN: Register FluentValidation
// VI: Đăng ký FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
// EN: Register repositories
// VI: Đăng ký repositories
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// EN: Register DbContext
// VI: Đăng ký DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
// EN: Register Dapper connection for queries
// VI: Đăng ký Dapper connection cho queries
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection(connectionString));
```
---
## Anti-Patterns to Avoid
### 1. Anemic Domain Model
```csharp
// ❌ AVOID: No behavior, just data
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
// Then in service:
order.Status = "Submitted"; // No validation!
```
### 2. God Service
```csharp
// ❌ AVOID: All logic in one service
public class OrderService
{
public void CreateOrder() { /* 500 lines */ }
public void UpdateOrder() { /* 300 lines */ }
public void ProcessPayment() { /* 400 lines */ }
// ...
}
```
### 3. Direct Database Access in Controller
```csharp
// ❌ AVOID: DbContext in controller
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var order = new Order { ... };
_context.Orders.Add(order);
await _context.SaveChangesAsync();
return Ok();
}
```
### 4. Shared Database Between Services
```csharp
// ❌ AVOID: Multiple services accessing same database
// OrderService -> SharedDb.Orders
// InventoryService -> SharedDb.Orders (reading Order table!)
```
---
## File Structure Summary
```
src/
├── ServiceName.API/
│ ├── Controllers/
│ │ └── OrdersController.cs
│ ├── Application/
│ │ ├── Commands/
│ │ │ ├── CreateOrderCommand.cs
│ │ │ └── CreateOrderCommandHandler.cs
│ │ ├── Queries/
│ │ │ ├── GetOrderQuery.cs
│ │ │ └── GetOrderQueryHandler.cs
│ │ ├── Validators/
│ │ │ └── CreateOrderCommandValidator.cs
│ │ └── Behaviors/
│ │ ├── LoggingBehavior.cs
│ │ └── ValidationBehavior.cs
│ └── Program.cs
├── ServiceName.Domain/
│ ├── AggregatesModel/
│ │ └── OrderAggregate/
│ │ ├── Order.cs
│ │ ├── OrderItem.cs
│ │ ├── OrderStatus.cs
│ │ └── IOrderRepository.cs
│ ├── Events/
│ │ └── OrderCreatedDomainEvent.cs
│ ├── Exceptions/
│ │ └── DomainException.cs
│ └── SeedWork/
│ ├── Entity.cs
│ ├── ValueObject.cs
│ └── IAggregateRoot.cs
└── ServiceName.Infrastructure/
├── Data/
│ ├── ApplicationDbContext.cs
│ └── Configurations/
│ └── OrderConfiguration.cs
└── Repositories/
└── OrderRepository.cs
```

View File

@@ -0,0 +1,340 @@
# Checklist: .NET Microservice Development
Checklist chi tiết cho từng giai đoạn phát triển .NET Microservices.
---
## Pre-Development Checklist
Trước khi bắt đầu code, hãy đảm bảo:
- [ ] Đã hiểu rõ yêu cầu nghiệp vụ (business requirements)
- [ ] Đã xác định được Bounded Context
- [ ] Đã xác định các Aggregates và mối quan hệ
- [ ] Đã quyết định service sẽ own database riêng
- [ ] Đã review các skills liên quan:
- [ ] [Domain-Driven Design](../domain-driven-design/SKILL.md)
- [ ] [Repository Pattern](../repository-pattern/SKILL.md)
- [ ] [CQRS MediatR](../cqrs-mediatr/SKILL.md)
- [ ] [API Design](../api-design/SKILL.md)
---
## Phase 1: Domain Layer Checklist
### 1.1 Project Setup
- [ ] Tạo project `ServiceName.Domain`
- [ ] Thêm folder structure:
```
ServiceName.Domain/
├── AggregatesModel/
│ └── {Aggregate}Aggregate/
├── Events/
├── Exceptions/
└── SeedWork/
```
### 1.2 Base Classes (trong SeedWork/)
- [ ] `Entity.cs` - Base class cho entities
- [ ] Id property với protected set
- [ ] Domain events collection
- [ ] Equality by Id
- [ ] `ValueObject.cs` - Base class cho value objects
- [ ] Immutable
- [ ] Equality by all properties
- [ ] `IAggregateRoot.cs` - Marker interface
- [ ] `IDomainEvent.cs` - Interface cho domain events
### 1.3 Domain Exceptions (trong Exceptions/)
- [ ] `DomainException.cs` - Base exception cho domain violations
### 1.4 Aggregate Design
- [ ] Xác định Aggregate Root
- [ ] Properties với `private set`
- [ ] Collections dùng backing fields + `IReadOnlyCollection<T>`
- [ ] Business methods thay vì public setters
- [ ] Domain events trong aggregate methods
### 1.5 Value Objects
- [ ] Immutable (chỉ có constructor, không có setters)
- [ ] Override `GetEqualityComponents()`
- [ ] Examples: Address, Money, Email, etc.
### 1.6 Domain Events
- [ ] Implement `IDomainEvent`
- [ ] Đặt trong folder `Events/`
- [ ] Raise trong aggregate methods
### 1.7 Repository Interface
- [ ] Đặt trong folder `AggregatesModel/{Aggregate}Aggregate/`
- [ ] Chỉ cho Aggregate Root
- [ ] Include `IUnitOfWork UnitOfWork { get; }`
### Phase 1 Validation
```csharp
// ✅ Kiểm tra domain model đúng chuẩn:
// 1. Không có public setters
// 2. Collections là IReadOnlyCollection<T>
// 3. Business logic trong aggregate methods
// 4. Domain events được raise khi state change
```
---
## Phase 2: Infrastructure Layer Checklist
### 2.1 Project Setup
- [ ] Tạo project `ServiceName.Infrastructure`
- [ ] Thêm folder structure:
```
ServiceName.Infrastructure/
├── Data/
│ ├── Configurations/
│ └── ApplicationDbContext.cs
├── Repositories/
└── Services/
```
### 2.2 NuGet Packages
- [ ] `Microsoft.EntityFrameworkCore`
- [ ] `Npgsql.EntityFrameworkCore.PostgreSQL` hoặc provider khác
- [ ] `Dapper` (cho queries)
### 2.3 DbContext
- [ ] Implement `IUnitOfWork`
- [ ] Register tất cả aggregate roots
- [ ] Apply configurations từ assembly
```csharp
public class ApplicationDbContext : DbContext, IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}
```
### 2.4 Entity Configurations
- [ ] Một file configuration per aggregate root
- [ ] Sử dụng Fluent API, KHÔNG dùng Data Annotations
- [ ] Configure owned entities (Value Objects)
- [ ] Configure navigation properties với `UsePropertyAccessMode`
### 2.5 Repository Implementation
- [ ] Implement interface từ Domain layer
- [ ] Expose `IUnitOfWork` từ DbContext
- [ ] KHÔNG gọi `SaveChangesAsync` trong repository
### 2.6 Database Migrations
- [ ] Tạo initial migration
- [ ] Test migration script
```bash
# Tạo migration
dotnet ef migrations add InitialCreate --project src/ServiceName.Infrastructure
# Apply migration
dotnet ef database update --project src/ServiceName.Infrastructure
```
### Phase 2 Validation
```csharp
// ✅ Kiểm tra infrastructure đúng chuẩn:
// 1. Domain không reference Infrastructure
// 2. Không có Data Annotations trên Domain entities
// 3. SaveChangesAsync chỉ được gọi từ Handler
// 4. Repository chỉ cho Aggregate Root
```
---
## Phase 3: Application Layer Checklist
### 3.1 Project Setup
- [ ] Commands trong `ServiceName.API/Application/Commands/`
- [ ] Queries trong `ServiceName.API/Application/Queries/`
- [ ] Validators trong `ServiceName.API/Application/Validators/`
- [ ] Behaviors trong `ServiceName.API/Application/Behaviors/`
### 3.2 NuGet Packages
- [ ] `MediatR`
- [ ] `FluentValidation`
- [ ] `FluentValidation.DependencyInjectionExtensions`
- [ ] `Dapper` (đã có từ Infrastructure)
### 3.3 Commands
- [ ] Tạo Command record với `IRequest<TResult>`
- [ ] Tạo CommandHandler với `IRequestHandler<TCommand, TResult>`
- [ ] Logic chỉ orchestration: load aggregate, call domain method, save
### 3.4 Queries
- [ ] Tạo Query record với `IRequest<TResult>`
- [ ] Tạo QueryHandler với Dapper
- [ ] Return lightweight DTOs, không Domain entities
### 3.5 Validators
- [ ] Tạo validator cho mỗi Command
- [ ] Sử dụng FluentValidation rules
- [ ] Input validation, KHÔNG phải business rules
### 3.6 Pipeline Behaviors
- [ ] `LoggingBehavior` - Log request/response
- [ ] `ValidationBehavior` - Run validators
- [ ] (Optional) `TransactionBehavior` - Wrap in transaction
### 3.7 DI Registration (trong Program.cs)
```csharp
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
```
### Phase 3 Validation
```csharp
// ✅ Kiểm tra application layer đúng chuẩn:
// 1. Business logic trong Domain, KHÔNG trong Handler
// 2. Queries dùng Dapper, KHÔNG dùng EF Core
// 3. Validators là input validation, KHÔNG phải business rules
// 4. Pipeline behaviors được register đúng thứ tự
```
---
## Phase 4: API Layer Checklist
### 4.1 Project Setup
- [ ] Controllers trong `ServiceName.API/Controllers/`
- [ ] DTOs trong `ServiceName.API/DTOs/` hoặc `Application/DTOs/`
### 4.2 NuGet Packages
- [ ] `Swashbuckle.AspNetCore`
- [ ] `AspNetCore.HealthChecks.UI.Client`
- [ ] `AspNetCore.HealthChecks.NpgSql` (hoặc provider khác)
- [ ] `Microsoft.Extensions.Http.Polly`
### 4.3 Controllers
- [ ] Slim controllers: chỉ nhận request, gọi MediatR
- [ ] `[ApiController]` attribute
- [ ] `[ApiVersion]` attribute
- [ ] `[Route("api/v{version:apiVersion}/...")]`
- [ ] `[SwaggerOperation]` và `[SwaggerResponse]` attributes
- [ ] Return `ApiResponse<T>` wrapper
### 4.4 Health Checks
- [ ] Register health checks cho DB, Redis, RabbitMQ
- [ ] Map endpoint `/hc` hoặc `/health`
```csharp
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "postgresql");
app.MapHealthChecks("/hc");
```
### 4.5 Resilience (Polly)
- [ ] Configure retry policy với exponential backoff
- [ ] Configure circuit breaker
- [ ] Apply cho tất cả HTTP clients
### 4.6 OpenAPI/Swagger
- [ ] Configure Swagger UI
- [ ] Add XML documentation
- [ ] Configure API versioning
### 4.7 Error Handling
- [ ] Global exception handler middleware
- [ ] Map domain exceptions to HTTP status codes
- [ ] Standardized error response format
### 4.8 DI Registration Complete
- [ ] MediatR + Behaviors
- [ ] Validators
- [ ] Repositories
- [ ] DbContext
- [ ] Dapper connection
- [ ] HTTP clients với Polly
- [ ] Health checks
### Phase 4 Validation
```csharp
// ✅ Kiểm tra API layer đúng chuẩn:
// 1. Controllers slim, chỉ gọi MediatR
// 2. Health checks hoạt động
// 3. API versioning đúng
// 4. Error responses nhất quán
// 5. OpenAPI documentation đầy đủ
```
---
## Post-Development Checklist
### Testing
- [ ] Unit tests cho Domain layer
- [ ] Unit tests cho Handlers (mock repositories)
- [ ] Integration tests với TestServer
- [ ] Test health check endpoints
### Documentation
- [ ] README.md với setup instructions
- [ ] API documentation qua Swagger
- [ ] Architecture decision records (nếu cần)
### Deployment
- [ ] Dockerfile hoạt động
- [ ] docker-compose.yml cho local dev
- [ ] Health check endpoint cho K8s probes
---
## Quick Summary
| Phase | Focus | Key Deliverables |
|-------|-------|------------------|
| 1 | Domain | Aggregates, Entities, VOs, Events |
| 2 | Infrastructure | DbContext, Configs, Repositories |
| 3 | Application | Commands, Queries, Handlers, Validators |
| 4 | API | Controllers, Health, Resilience, Docs |

View File

@@ -0,0 +1,728 @@
---
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

View File

@@ -0,0 +1,666 @@
# Integration Test Rules / Quy Tắc Integration Testing
Hướng dẫn chi tiết cho việc viết Integration Tests với TestServer, EF Core, và Testcontainers.
## 1. Database Testing Strategies / Chiến Lược Test Database
### 1.1 In-Memory Database (Nhanh nhưng Hạn chế)
```csharp
/// <summary>
/// EN: In-memory database fixture for fast tests.
/// VI: In-memory database fixture cho tests nhanh.
/// </summary>
public class InMemoryDbFixture : IDisposable
{
public ApplicationDbContext DbContext { get; }
public InMemoryDbFixture()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
.Options;
DbContext = new ApplicationDbContext(options);
DbContext.Database.EnsureCreated();
}
public void Dispose() => DbContext.Dispose();
}
```
**Khi nào dùng In-Memory:**
- ✅ Chạy quick tests trong CI/CD
- ✅ Test LINQ queries đơn giản
- ❌ KHÔNG dùng cho constraint testing (FK, unique)
- ❌ KHÔNG dùng cho stored procedures, raw SQL
### 1.2 Testcontainers (Chính xác cao)
```csharp
/// <summary>
/// EN: PostgreSQL container fixture using Testcontainers.
/// VI: Fixture container PostgreSQL dùng Testcontainers.
/// </summary>
public class PostgresTestFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("integration_tests")
.WithUsername("test")
.WithPassword("test123")
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(5432))
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
```
**Khi nào dùng Testcontainers:**
- ✅ Test database constraints (FK, unique, check)
- ✅ Test migrations
- ✅ Test stored procedures, raw SQL
- ✅ Production-like environment
### 1.3 SQL Server Container
```csharp
/// <summary>
/// EN: SQL Server container fixture.
/// VI: Fixture container SQL Server.
/// </summary>
public class SqlServerTestFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("YourStrong@Password123")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
```
## 2. Test Data Management / Quản Lý Dữ Liệu Test
### 2.1 Seeding Test Data
```csharp
/// <summary>
/// EN: Database seeder for integration tests.
/// VI: Database seeder cho integration tests.
/// </summary>
public static class TestDataSeeder
{
public static async Task SeedOrdersAsync(ApplicationDbContext context)
{
var orders = new[]
{
CreateOrder("user-1", OrderStatus.Draft),
CreateOrder("user-1", OrderStatus.Submitted),
CreateOrder("user-2", OrderStatus.Completed)
};
await context.Orders.AddRangeAsync(orders);
await context.SaveChangesAsync();
}
private static Order CreateOrder(string userId, OrderStatus status)
{
var order = Order.Create(userId, CreateAddress());
order.AddItem(Guid.NewGuid(), 2, 10.00m);
if (status >= OrderStatus.Submitted)
order.Submit();
if (status >= OrderStatus.Completed)
order.Complete();
return order;
}
private static Address CreateAddress() =>
Address.Create("123 St", "City", "State", "12345", "VN");
}
```
### 2.2 Transaction Rollback Pattern
```csharp
/// <summary>
/// EN: Transaction rollback for test isolation.
/// VI: Transaction rollback để cô lập test.
/// </summary>
public class TransactionalTestBase : IAsyncLifetime
{
protected readonly ApplicationDbContext DbContext;
private IDbContextTransaction? _transaction;
public TransactionalTestBase(PostgresTestFixture fixture)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(fixture.ConnectionString)
.Options;
DbContext = new ApplicationDbContext(options);
}
public async Task InitializeAsync()
{
_transaction = await DbContext.Database.BeginTransactionAsync();
}
public async Task DisposeAsync()
{
if (_transaction != null)
{
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
}
await DbContext.DisposeAsync();
}
}
```
## 3. EF Core Integration Tests
### 3.1 Repository Testing
```csharp
/// <summary>
/// EN: Integration tests for OrderRepository.
/// VI: Integration tests cho OrderRepository.
/// </summary>
[Collection("Database")]
public class OrderRepositoryTests : IClassFixture<PostgresTestFixture>
{
private readonly PostgresTestFixture _fixture;
private readonly ApplicationDbContext _context;
private readonly OrderRepository _repository;
public OrderRepositoryTests(PostgresTestFixture fixture)
{
_fixture = fixture;
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(fixture.ConnectionString)
.Options;
_context = new ApplicationDbContext(options);
_repository = new OrderRepository(_context);
}
[Fact]
public async Task AddAsync_ValidOrder_PersistsToDatabase()
{
// Arrange
var order = Order.Create("user-123", CreateAddress());
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
// Assert - Fresh context to verify persistence
await using var verifyContext = new ApplicationDbContext(GetDbOptions());
var savedOrder = await verifyContext.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == order.Id);
savedOrder.Should().NotBeNull();
savedOrder!.OrderItems.Should().HaveCount(1);
savedOrder.TotalAmount.Should().Be(20.00m);
}
[Fact]
public async Task GetByUserIdAsync_ExistingOrders_ReturnsAllUserOrders()
{
// Arrange
var userId = $"user-{Guid.NewGuid()}";
var order1 = Order.Create(userId, CreateAddress());
var order2 = Order.Create(userId, CreateAddress());
var otherOrder = Order.Create("other-user", CreateAddress());
await _context.Orders.AddRangeAsync(order1, order2, otherOrder);
await _context.SaveChangesAsync();
// Act
var orders = await _repository.GetByUserIdAsync(userId);
// Assert
orders.Should().HaveCount(2);
orders.Should().OnlyContain(o => o.UserId == userId);
}
private DbContextOptions<ApplicationDbContext> GetDbOptions() =>
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options;
private static Address CreateAddress() =>
Address.Create("123 St", "City", "State", "12345", "VN");
}
```
### 3.2 Testing Migrations
```csharp
/// <summary>
/// EN: Tests for database migrations.
/// VI: Tests cho database migrations.
/// </summary>
[Collection("Database")]
public class MigrationTests : IClassFixture<PostgresTestFixture>
{
private readonly PostgresTestFixture _fixture;
public MigrationTests(PostgresTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Migrations_AllApplied_NoErrors()
{
// Arrange
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options;
await using var context = new ApplicationDbContext(options);
// Act & Assert - Should not throw
await context.Database.MigrateAsync();
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
pendingMigrations.Should().BeEmpty();
}
[Fact]
public async Task Migration_UniqueConstraint_EnforcedProperly()
{
// Arrange
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options;
await using var context = new ApplicationDbContext(options);
await context.Database.MigrateAsync();
// Create user with unique email
var user1 = new User { Email = "test@example.com", Name = "User 1" };
var user2 = new User { Email = "test@example.com", Name = "User 2" }; // Duplicate!
context.Users.Add(user1);
await context.SaveChangesAsync();
context.Users.Add(user2);
// Act & Assert
var act = async () => await context.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
}
```
## 4. TestServer / WebApplicationFactory
### 4.1 Custom WebApplicationFactory
```csharp
/// <summary>
/// EN: Custom WebApplicationFactory for integration tests.
/// VI: Custom WebApplicationFactory cho integration tests.
/// </summary>
public class IntegrationTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("integration_tests")
.Build();
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
}
public new async Task DisposeAsync()
{
await _dbContainer.DisposeAsync();
await base.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove real DbContext
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// Add test DbContext with real PostgreSQL
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(_dbContainer.GetConnectionString()));
// Replace external services with mocks
services.RemoveAll<IEmailService>();
services.AddSingleton(Substitute.For<IEmailService>());
// Apply migrations
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate();
});
builder.ConfigureTestServices(services =>
{
// Override authentication for testing
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });
});
}
}
```
### 4.2 Test Authentication Handler
```csharp
/// <summary>
/// EN: Test authentication handler for integration tests.
/// VI: Test authentication handler cho integration tests.
/// </summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestScheme";
public const string TestUserId = "test-user-id";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, TestUserId),
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Email, "test@example.com"),
new Claim(ClaimTypes.Role, "User")
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
```
### 4.3 API Integration Tests
```csharp
/// <summary>
/// EN: API integration tests with real database.
/// VI: API integration tests với database thật.
/// </summary>
public class OrdersApiIntegrationTests : IClassFixture<IntegrationTestFactory>
{
private readonly HttpClient _client;
private readonly IntegrationTestFactory _factory;
public OrdersApiIntegrationTests(IntegrationTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ValidRequest_PersistsToDatabase()
{
// Arrange
var request = new CreateOrderRequest
{
ShippingAddress = new AddressDto("123 St", "City", "State", "12345", "VN"),
Items = new[]
{
new OrderItemDto { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert - Response
response.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await response.Content.ReadFromJsonAsync<OrderCreatedResponse>();
created!.OrderId.Should().NotBeEmpty();
// Assert - Database persistence
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var order = await db.Orders.FindAsync(created.OrderId);
order.Should().NotBeNull();
order!.UserId.Should().Be(TestAuthHandler.TestUserId);
}
[Fact]
public async Task GetOrder_ExistingOrder_ReturnsWithItems()
{
// Arrange - Seed data
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var order = Order.Create(TestAuthHandler.TestUserId,
Address.Create("123 St", "City", "State", "12345", "VN"));
order.AddItem(Guid.NewGuid(), 2, 10.00m);
db.Orders.Add(order);
await db.SaveChangesAsync();
}
// Act
var response = await _client.GetAsync("/api/v1/orders");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var orders = await response.Content.ReadFromJsonAsync<List<OrderDto>>();
orders.Should().NotBeEmpty();
orders!.First().Items.Should().NotBeEmpty();
}
}
```
## 5. External Service Integration
### 5.1 Testing with WireMock
```csharp
/// <summary>
/// EN: Tests using WireMock for external API simulation.
/// VI: Tests dùng WireMock để giả lập external API.
/// </summary>
public class PaymentServiceIntegrationTests : IDisposable
{
private readonly WireMockServer _wireMock;
private readonly HttpClient _httpClient;
public PaymentServiceIntegrationTests()
{
_wireMock = WireMockServer.Start();
_httpClient = new HttpClient { BaseAddress = new Uri(_wireMock.Urls[0]) };
}
[Fact]
public async Task ProcessPayment_SuccessResponse_ReturnsPaymentId()
{
// Arrange - Setup WireMock
_wireMock.Given(
Request.Create()
.WithPath("/payments")
.UsingPost())
.RespondWith(
Response.Create()
.WithStatusCode(201)
.WithBodyAsJson(new { paymentId = "pay-123", status = "completed" }));
var service = new PaymentService(_httpClient);
// Act
var result = await service.ProcessPaymentAsync(new PaymentRequest
{
Amount = 100.00m,
Currency = "VND"
});
// Assert
result.PaymentId.Should().Be("pay-123");
result.Status.Should().Be("completed");
}
[Fact]
public async Task ProcessPayment_Timeout_ThrowsPaymentException()
{
// Arrange - Simulate timeout
_wireMock.Given(
Request.Create()
.WithPath("/payments")
.UsingPost())
.RespondWith(
Response.Create()
.WithDelay(TimeSpan.FromSeconds(30)));
var httpClient = new HttpClient(new HttpClientHandler())
{
BaseAddress = new Uri(_wireMock.Urls[0]),
Timeout = TimeSpan.FromSeconds(1)
};
var service = new PaymentService(httpClient);
// Act & Assert
var act = async () => await service.ProcessPaymentAsync(new PaymentRequest());
await act.Should().ThrowAsync<PaymentException>()
.WithMessage("*timeout*");
}
public void Dispose() => _wireMock.Stop();
}
```
## 6. Best Practices / Thực Hành Tốt
### Collection Fixtures
Dùng Collection Fixtures để chia sẻ database container giữa nhiều test classes:
```csharp
// Define collection
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<PostgresTestFixture>
{
}
// Use in test classes
[Collection("Database")]
public class OrderRepositoryTests { }
[Collection("Database")]
public class UserRepositoryTests { }
```
### Test Isolation
```csharp
/// <summary>
/// EN: Ensure each test has fresh data.
/// VI: Đảm bảo mỗi test có dữ liệu riêng.
/// </summary>
public class IsolatedTestBase : IAsyncLifetime
{
protected ApplicationDbContext DbContext { get; private set; } = null!;
public async Task InitializeAsync()
{
// Create fresh database per test
var dbName = $"test_{Guid.NewGuid():N}";
// ... setup code
}
public async Task DisposeAsync()
{
// Cleanup
await DbContext.Database.EnsureDeletedAsync();
await DbContext.DisposeAsync();
}
}
```
### Parallel Test Execution
```csharp
// xunit.runner.json - Control parallelism
{
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}
```
## 7. Common Pitfalls / Lỗi Thường Gặp
### ❌ Shared State Between Tests
```csharp
// ❌ BAD: Tests affect each other
private static readonly ApplicationDbContext _sharedContext = CreateContext();
// ✅ GOOD: Fresh context per test
private ApplicationDbContext CreateContext() => new(GetOptions());
```
### ❌ Not Clearing Tracker
```csharp
// ❌ BAD: Cached entities affect assertions
var saved = await repository.GetByIdAsync(id);
// Context still tracking original entity!
// ✅ GOOD: Clear tracker or use new context
context.ChangeTracker.Clear();
var saved = await repository.GetByIdAsync(id);
```
### ❌ Testing Against Production Database
```csharp
// ❌ BAD: Connection string from production
var conn = Environment.GetEnvironmentVariable("PROD_DB_CONN");
// ✅ GOOD: Always use test containers or in-memory
var conn = _testContainer.GetConnectionString();
```

View File

@@ -0,0 +1,725 @@
# Resilience Test Rules / Quy Tắc Resilience Testing
Hướng dẫn chi tiết cho việc kiểm thử khả năng phục hồi với Polly policies trong .NET Microservices.
## 1. Overview / Tổng Quan
Resilience testing đảm bảo hệ thống hoạt động ổn định khi gặp:
- **Transient failures**: Lỗi tạm thời (network timeout, 503 errors)
- **Partial outages**: Một phần hệ thống ngừng hoạt động
- **Cascading failures**: Lỗi lan truyền giữa các services
### Polly Policies Cần Test
| Policy | Mục đích | Scenario |
|--------|----------|----------|
| **Retry** | Thử lại khi gặp lỗi tạm thời | 500, 503, timeout |
| **Circuit Breaker** | Ngắt mạch khi lỗi liên tục | Nhiều request thất bại |
| **Timeout** | Giới hạn thời gian chờ | Slow responses |
| **Bulkhead** | Cô lập resources | Concurrent request limit |
| **Fallback** | Giá trị dự phòng | Graceful degradation |
## 2. Mock HTTP Handler / Handler HTTP Giả Lập
### 2.1 Basic Mock Handler
```csharp
/// <summary>
/// EN: Mock HTTP handler for injecting specific responses.
/// VI: Mock HTTP handler để inject responses cụ thể.
/// </summary>
public class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> _handler;
public List<HttpRequestMessage> ReceivedRequests { get; } = new();
public MockHttpMessageHandler(
Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
ReceivedRequests.Add(request);
return Task.FromResult(_handler(request, cancellationToken));
}
}
```
### 2.2 Configurable Mock Handler
```csharp
/// <summary>
/// EN: Handler that returns different responses based on call count.
/// VI: Handler trả về responses khác nhau dựa trên số lần gọi.
/// </summary>
public class SequentialResponseHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
private int _callCount = 0;
public int CallCount => _callCount;
public SequentialResponseHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _callCount);
if (_responses.Count > 0)
return Task.FromResult(_responses.Dequeue());
// Default: return 200 OK when queue is empty
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
```
### 2.3 Exception Throwing Handler
```csharp
/// <summary>
/// EN: Handler that throws exceptions for testing error handling.
/// VI: Handler throw exceptions để test error handling.
/// </summary>
public class ExceptionThrowingHandler : HttpMessageHandler
{
private readonly Func<int, Exception?> _exceptionFactory;
private int _callCount = 0;
public int CallCount => _callCount;
public ExceptionThrowingHandler(Func<int, Exception?> exceptionFactory)
{
_exceptionFactory = exceptionFactory;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
_callCount++;
var exception = _exceptionFactory(_callCount);
if (exception != null)
throw exception;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
// Usage: Throw exception on first 2 calls, succeed on 3rd
var handler = new ExceptionThrowingHandler(callCount =>
callCount <= 2 ? new HttpRequestException("Network error") : null);
```
## 3. Retry Policy Testing / Test Retry Policy
### 3.1 Basic Retry Test
```csharp
/// <summary>
/// EN: Tests for retry policy behavior.
/// VI: Kiểm thử hành vi của retry policy.
/// </summary>
public class RetryPolicyTests
{
[Fact]
public async Task RetryPolicy_TransientFailure_RetriesAndSucceeds()
{
// Arrange
var handler = new SequentialResponseHandler(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.OK)
);
var retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(10));
var httpClient = new HttpClient(handler);
// Act
var response = await retryPolicy.ExecuteAsync(() =>
httpClient.GetAsync("http://test-api/orders"));
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
handler.CallCount.Should().Be(3); // 2 retries + 1 success
}
[Fact]
public async Task RetryPolicy_AllRetriesFail_ThrowsException()
{
// Arrange
var handler = new SequentialResponseHandler(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
);
var retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(10));
var httpClient = new HttpClient(handler);
// Act
var response = await retryPolicy.ExecuteAsync(() =>
httpClient.GetAsync("http://test-api/orders"));
// Assert - After all retries, returns last failed response
response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
handler.CallCount.Should().Be(4); // 1 initial + 3 retries
}
}
```
### 3.2 Testing Retry with Jitter
```csharp
/// <summary>
/// EN: Tests for retry policy with exponential backoff and jitter.
/// VI: Kiểm thử retry policy với exponential backoff và jitter.
/// </summary>
public class RetryWithJitterTests
{
[Fact]
public async Task RetryPolicy_WithJitter_DelaysIncreaseExponentially()
{
// Arrange
var delays = new List<TimeSpan>();
var retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: (attempt, context) =>
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100));
delays.Add(delay);
return delay;
});
var handler = new SequentialResponseHandler(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
new HttpResponseMessage(HttpStatusCode.OK)
);
var httpClient = new HttpClient(handler);
// Act
await retryPolicy.ExecuteAsync(() => httpClient.GetAsync("http://test-api/orders"));
// Assert
delays.Should().HaveCount(3);
delays[1].Should().BeGreaterThan(delays[0]); // Exponential increase
delays[2].Should().BeGreaterThan(delays[1]);
}
}
```
## 4. Circuit Breaker Testing / Test Circuit Breaker
### 4.1 Basic Circuit Breaker Test
```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_OpensCircuit()
{
// 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
}
[Fact]
public async Task CircuitBreaker_Success_ResetsFailureCount()
{
// Arrange
var callCount = 0;
var shouldFail = true;
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30));
var action = new Func<Task>(() =>
{
callCount++;
if (shouldFail)
throw new HttpRequestException("Failed");
return Task.CompletedTask;
});
// Act
// First failure
await Assert.ThrowsAsync<HttpRequestException>(() =>
circuitBreaker.ExecuteAsync(action));
// Successful call
shouldFail = false;
await circuitBreaker.ExecuteAsync(action);
// Another failure - should not open circuit (counter reset)
shouldFail = true;
await Assert.ThrowsAsync<HttpRequestException>(() =>
circuitBreaker.ExecuteAsync(action));
// Assert
circuitBreaker.CircuitState.Should().Be(CircuitState.Closed);
}
}
```
### 4.2 Testing Circuit Half-Open State
```csharp
/// <summary>
/// EN: Tests for circuit breaker half-open state behavior.
/// VI: Kiểm thử hành vi trạng thái half-open của circuit breaker.
/// </summary>
public class CircuitBreakerHalfOpenTests
{
[Fact]
public async Task CircuitBreaker_AfterBreakDuration_TransitionsToHalfOpen()
{
// Arrange
var breakDuration = TimeSpan.FromMilliseconds(100);
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 1,
durationOfBreak: breakDuration);
// Act - Open the circuit
await Assert.ThrowsAsync<HttpRequestException>(() =>
circuitBreaker.ExecuteAsync(() => throw new HttpRequestException("Fail")));
circuitBreaker.CircuitState.Should().Be(CircuitState.Open);
// Wait for break duration
await Task.Delay(breakDuration + TimeSpan.FromMilliseconds(50));
// Assert - Circuit should now be Half-Open
circuitBreaker.CircuitState.Should().Be(CircuitState.HalfOpen);
}
[Fact]
public async Task CircuitBreaker_HalfOpenSuccess_ClosesCircuit()
{
// Arrange
var breakDuration = TimeSpan.FromMilliseconds(100);
var shouldFail = true;
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 1,
durationOfBreak: breakDuration);
var action = new Func<Task>(() =>
{
if (shouldFail) throw new HttpRequestException("Fail");
return Task.CompletedTask;
});
// Open the circuit
await Assert.ThrowsAsync<HttpRequestException>(() =>
circuitBreaker.ExecuteAsync(action));
// Wait for half-open
await Task.Delay(breakDuration + TimeSpan.FromMilliseconds(50));
// Act - Success in half-open state
shouldFail = false;
await circuitBreaker.ExecuteAsync(action);
// Assert
circuitBreaker.CircuitState.Should().Be(CircuitState.Closed);
}
}
```
## 5. Timeout Policy Testing / Test Timeout Policy
### 5.1 Basic Timeout Test
```csharp
/// <summary>
/// EN: Tests for timeout policy behavior.
/// VI: Kiểm thử hành vi của timeout policy.
/// </summary>
public class TimeoutPolicyTests
{
[Fact]
public async Task TimeoutPolicy_SlowOperation_ThrowsTimeoutRejectedException()
{
// Arrange
var timeoutPolicy = Policy
.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromMilliseconds(100));
var slowOperation = new Func<CancellationToken, Task<HttpResponseMessage>>(async ct =>
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
// Act & Assert
await Assert.ThrowsAsync<TimeoutRejectedException>(() =>
timeoutPolicy.ExecuteAsync(slowOperation, CancellationToken.None));
}
[Fact]
public async Task TimeoutPolicy_FastOperation_Succeeds()
{
// Arrange
var timeoutPolicy = Policy
.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(5));
var fastOperation = new Func<CancellationToken, Task<HttpResponseMessage>>(async ct =>
{
await Task.Delay(TimeSpan.FromMilliseconds(50), ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
// Act
var response = await timeoutPolicy.ExecuteAsync(fastOperation, CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task TimeoutPolicy_OptimisticTimeout_CancelsToken()
{
// Arrange
var tokenWasCancelled = false;
var timeoutPolicy = Policy
.TimeoutAsync(TimeSpan.FromMilliseconds(100), TimeoutStrategy.Optimistic);
var operation = new Func<CancellationToken, Task>(async ct =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
catch (OperationCanceledException)
{
tokenWasCancelled = true;
throw;
}
});
// Act
try
{
await timeoutPolicy.ExecuteAsync(operation, CancellationToken.None);
}
catch (TimeoutRejectedException)
{
// Expected
}
// Assert
tokenWasCancelled.Should().BeTrue();
}
}
```
## 6. Fallback Policy Testing / Test Fallback Policy
### 6.1 Basic Fallback Test
```csharp
/// <summary>
/// EN: Tests for fallback policy behavior.
/// VI: Kiểm thử hành vi của fallback policy.
/// </summary>
public class FallbackPolicyTests
{
[Fact]
public async Task FallbackPolicy_OnException_ReturnsFallbackValue()
{
// Arrange
var fallbackOrder = new OrderDto { Id = Guid.Empty, Status = "Fallback" };
var fallbackPolicy = Policy<OrderDto?>
.Handle<HttpRequestException>()
.FallbackAsync(fallbackOrder);
var failingOperation = new Func<Task<OrderDto?>>(() =>
throw new HttpRequestException("Service unavailable"));
// Act
var result = await fallbackPolicy.ExecuteAsync(failingOperation);
// Assert
result.Should().BeEquivalentTo(fallbackOrder);
}
[Fact]
public async Task FallbackPolicy_WithFallbackAction_ExecutesAction()
{
// Arrange
var fallbackWasCalled = false;
var fallbackPolicy = Policy<OrderDto?>
.Handle<HttpRequestException>()
.FallbackAsync(
fallbackAction: async (ctx, ct) =>
{
fallbackWasCalled = true;
return await Task.FromResult<OrderDto?>(new OrderDto { Status = "Cached" });
});
// Act
var result = await fallbackPolicy.ExecuteAsync(() =>
throw new HttpRequestException("Failed"));
// Assert
fallbackWasCalled.Should().BeTrue();
result!.Status.Should().Be("Cached");
}
}
```
## 7. Combined Policies Testing / Test Policies Kết Hợp
### 7.1 PolicyWrap Testing
```csharp
/// <summary>
/// EN: Tests for combined policies (PolicyWrap).
/// VI: Kiểm thử các policies kết hợp (PolicyWrap).
/// </summary>
public class PolicyWrapTests
{
[Fact]
public async Task PolicyWrap_CombinedPolicies_AppliesInCorrectOrder()
{
// Arrange
var callCount = 0;
var executionLog = new List<string>();
// Outer policy: Circuit Breaker
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (_, _) => executionLog.Add("CircuitBreak"),
onReset: () => executionLog.Add("CircuitReset"));
// Inner policy: Retry
var retry = Policy
.Handle<HttpRequestException>()
.RetryAsync(3, onRetry: (_, retryCount) =>
{
executionLog.Add($"Retry-{retryCount}");
});
// Wrap: Retry inside CircuitBreaker
var policyWrap = Policy.WrapAsync(circuitBreaker, retry);
var handler = new SequentialResponseHandler(
CreateFailure(), CreateFailure(), CreateFailure(),
new HttpResponseMessage(HttpStatusCode.OK)
);
var httpClient = new HttpClient(handler);
var operation = new Func<Task<HttpResponseMessage>>(async () =>
{
callCount++;
var response = await httpClient.GetAsync("http://test/api");
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Status: {response.StatusCode}");
return response;
});
// Act
var result = await policyWrap.ExecuteAsync(operation);
// Assert
result.StatusCode.Should().Be(HttpStatusCode.OK);
executionLog.Should().Contain("Retry-1");
executionLog.Should().Contain("Retry-2");
executionLog.Should().Contain("Retry-3");
}
private static HttpResponseMessage CreateFailure() =>
new(HttpStatusCode.ServiceUnavailable);
}
```
## 8. Best Practices / Thực Hành Tốt
### Test Time Sensitivity
```csharp
/// <summary>
/// EN: Use short timeouts in tests to avoid slow test suites.
/// VI: Dùng timeout ngắn trong tests để tránh test suite chậm.
/// </summary>
public class TimeoutBestPracticeTests
{
// ❌ BAD: Long timeouts slow down tests
private static readonly TimeSpan BadTimeout = TimeSpan.FromSeconds(30);
// ✅ GOOD: Short timeouts for fast feedback
private static readonly TimeSpan GoodTimeout = TimeSpan.FromMilliseconds(100);
}
```
### Policy Factory for Testing
```csharp
/// <summary>
/// EN: Factory to create policies with testable parameters.
/// VI: Factory tạo policies với parameters có thể test.
/// </summary>
public class ResiliencePolicyFactory
{
private readonly ILogger _logger;
public ResiliencePolicyFactory(ILogger logger)
{
_logger = logger;
}
public IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(
int retryCount = 3,
TimeSpan? delay = null)
{
return Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.WaitAndRetryAsync(
retryCount,
attempt => delay ?? TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
_logger.LogWarning(
"Retry {RetryAttempt} after {Delay}ms due to {Reason}",
retryAttempt, timespan.TotalMilliseconds,
outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString());
});
}
}
```
### Verifying Retry Delays
```csharp
/// <summary>
/// EN: Custom policy with observable delays for testing.
/// VI: Custom policy với delays có thể quan sát để test.
/// </summary>
public class ObservableRetryPolicy
{
public List<TimeSpan> ObservedDelays { get; } = new();
public IAsyncPolicy<HttpResponseMessage> CreatePolicy(int retryCount)
{
return Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
retryCount,
sleepDurationProvider: attempt =>
{
var delay = TimeSpan.FromMilliseconds(100 * attempt);
ObservedDelays.Add(delay);
return delay;
});
}
}
```
## 9. Common Pitfalls / Lỗi Thường Gặp
### ❌ Testing Against Real Services
```csharp
// ❌ BAD: Flaky tests due to real service availability
var client = new HttpClient { BaseAddress = new Uri("https://real-api.com") };
await policy.ExecuteAsync(() => client.GetAsync("/orders"));
// ✅ GOOD: Mock handler for deterministic behavior
var mockHandler = new MockHttpMessageHandler(...)
var client = new HttpClient(mockHandler);
await policy.ExecuteAsync(() => client.GetAsync("/orders"));
```
### ❌ Not Testing Circuit State Transitions
```csharp
// ❌ BAD: Only test open state
circuitBreaker.CircuitState.Should().Be(CircuitState.Open);
// ✅ GOOD: Test all state transitions
circuitBreaker.CircuitState.Should().Be(CircuitState.Closed);
// ... trigger failures
circuitBreaker.CircuitState.Should().Be(CircuitState.Open);
// ... wait for break duration
circuitBreaker.CircuitState.Should().Be(CircuitState.HalfOpen);
// ... success
circuitBreaker.CircuitState.Should().Be(CircuitState.Closed);
```
### ❌ Ignoring Policy Order in Wrap
```csharp
// ❌ BAD: Wrong order - retry outside circuit breaker
var wrong = Policy.WrapAsync(retry, circuitBreaker);
// ✅ GOOD: Retry should be inside circuit breaker
// Circuit breaker tracks failures INCLUDING retried calls
var correct = Policy.WrapAsync(circuitBreaker, retry);
```

View File

@@ -0,0 +1,413 @@
# 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)
```csharp
[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]`
```csharp
// ✅ 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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:
```csharp
/// <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:
```csharp
/// <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:
```csharp
/// <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
```csharp
/// <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
```csharp
// ❌ 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
```csharp
// ❌ 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
```csharp
// ❌ 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() { }
```

View File

@@ -311,6 +311,7 @@ docker-compose -f deployments/local/docker-compose.yml logs -f my-service-net
## Resources / Tài Nguyên
- [.NET Microservice Workflow](../dotnet-microservice-workflow/SKILL.md) - 4-phase development workflow
- [API Design](../api-design/SKILL.md) - API patterns
- [Error Handling Patterns](../error-handling-patterns/SKILL.md) - Error handling
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access