feat: Create Skills
This commit is contained in:
296
.agent/skills/dotnet-microservice-workflow/SKILL.md
Normal file
296
.agent/skills/dotnet-microservice-workflow/SKILL.md
Normal 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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 |
|
||||
728
.agent/skills/dotnet-senior-tester/SKILL.md
Normal file
728
.agent/skills/dotnet-senior-tester/SKILL.md
Normal 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
|
||||
@@ -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();
|
||||
```
|
||||
725
.agent/skills/dotnet-senior-tester/guidelines/resilience-test.md
Normal file
725
.agent/skills/dotnet-senior-tester/guidelines/resilience-test.md
Normal 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);
|
||||
```
|
||||
413
.agent/skills/dotnet-senior-tester/guidelines/unit-test-rules.md
Normal file
413
.agent/skills/dotnet-senior-tester/guidelines/unit-test-rules.md
Normal 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() { }
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user