docs(note): Add new Agent Skills proposals for GoodGo Platform

- Expanded the note.md file to include a comprehensive list of proposed new skills for the GoodGo Platform, detailing descriptions and reasons for each skill.
- Organized skills into priority levels to guide implementation focus, enhancing clarity on core development patterns and infrastructure needs.
- This update aims to improve documentation and facilitate better planning for future skill development.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 11:28:53 +07:00
parent 8d9da25aaa
commit 0138fc75f9
7 changed files with 3856 additions and 1 deletions

View File

@@ -0,0 +1,443 @@
---
name: error-handling-patterns
description: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, Polly resiliency, và health checks.
compatibility: ".NET 8+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks"
metadata:
author: Velik Ho
version: "1.0"
---
# Error Handling Patterns / Mẫu Xử Lý Lỗi
Error handling và resiliency patterns cho GoodGo microservices.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Implementing global exception handling / Triển khai xử lý exception toàn cục
- Creating domain exceptions / Tạo domain exceptions
- Setting up retry policies with Polly / Cài đặt retry policies với Polly
- Implementing Circuit Breaker pattern / Triển khai Circuit Breaker
- Configuring health checks / Cấu hình health checks
- Handling validation errors / Xử lý validation errors
## Core Concepts / Khái Niệm Cốt Lõi
### Exception Hierarchy / Phân Cấp Exception
```
ApplicationException (Base)
├── DomainException # Business rule violations
│ ├── ValidationException # Input validation errors
│ ├── NotFoundException # Resource not found
│ └── ConflictException # Duplicate resource
├── InfrastructureException # External service failures
│ ├── DatabaseException # Database errors
│ └── ExternalServiceException # Third-party API errors
└── AuthorizationException # Permission errors
```
### HTTP Status Code Mapping / Ánh Xạ Status Code
| Exception Type | HTTP Status | Use Case |
|----------------|-------------|----------|
| `ValidationException` | 400 | Invalid input data |
| `UnauthorizedAccessException` | 401 | Missing/invalid token |
| `ForbiddenException` | 403 | No permission |
| `NotFoundException` | 404 | Resource doesn't exist |
| `ConflictException` | 409 | Duplicate resource |
| `DomainException` | 422 | Business rule violation |
| `Exception` | 500 | Unexpected errors |
### Resiliency Patterns / Mẫu Phục Hồi
| Pattern | Purpose | When to Use |
|---------|---------|-------------|
| **Retry** | Thử lại khi lỗi tạm thời | Network timeouts, transient DB errors |
| **Circuit Breaker** | Ngắt mạch khi service chết | External API calls |
| **Timeout** | Giới hạn thời gian chờ | Long-running operations |
| **Bulkhead** | Cô lập tài nguyên | Prevent cascade failures |
## Key Patterns / Mẫu Chính
### Global Exception Middleware
```csharp
/// <summary>
/// EN: Global exception handler middleware.
/// VI: Middleware xử lý exception toàn cục.
/// </summary>
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
// EN: Log full exception details
// VI: Log chi tiết exception đầy đủ
_logger.LogError(ex,
"EN: Unhandled exception / VI: Exception không xử lý: {Path}",
context.Request.Path);
var (statusCode, errorCode, message) = ex switch
{
ValidationException ve => (400, "VALIDATION_ERROR", ve.Message),
UnauthorizedAccessException => (401, "UNAUTHORIZED", "Authentication required"),
ForbiddenException => (403, "FORBIDDEN", "Access denied"),
NotFoundException nf => (404, "NOT_FOUND", nf.Message),
ConflictException ce => (409, "CONFLICT", ce.Message),
DomainException de => (422, "DOMAIN_ERROR", de.Message),
_ => (500, "INTERNAL_ERROR", "An unexpected error occurred")
};
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var response = new ApiResponse<object>
{
Success = false,
Error = message,
ErrorCode = errorCode
};
await context.Response.WriteAsJsonAsync(response);
}
}
// EN: Register in Program.cs / VI: Đăng ký trong Program.cs
app.UseMiddleware<ExceptionHandlerMiddleware>();
```
### Domain Exception Classes
```csharp
/// <summary>
/// EN: Base domain exception.
/// VI: Domain exception cơ sở.
/// </summary>
public class DomainException : Exception
{
public string ErrorCode { get; }
public DomainException(string message, string errorCode = "DOMAIN_ERROR")
: base(message)
{
ErrorCode = errorCode;
}
}
/// <summary>
/// EN: Resource not found exception.
/// VI: Exception không tìm thấy resource.
/// </summary>
public class NotFoundException : DomainException
{
public NotFoundException(string resourceType, object resourceId)
: base($"{resourceType} with ID {resourceId} was not found", "NOT_FOUND")
{ }
public NotFoundException(string message)
: base(message, "NOT_FOUND")
{ }
}
/// <summary>
/// EN: Conflict/duplicate resource exception.
/// VI: Exception trùng lặp resource.
/// </summary>
public class ConflictException : DomainException
{
public ConflictException(string message)
: base(message, "CONFLICT")
{ }
}
/// <summary>
/// EN: Validation exception with errors.
/// VI: Exception validation với danh sách lỗi.
/// </summary>
public class ValidationException : DomainException
{
public IReadOnlyDictionary<string, string[]> Errors { get; }
public ValidationException(string message)
: base(message, "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IReadOnlyDictionary<string, string[]> errors)
: base("One or more validation errors occurred", "VALIDATION_ERROR")
{
Errors = errors;
}
}
```
### Retry with Polly
```csharp
/// <summary>
/// EN: Configure Polly retry policies.
/// VI: Cấu hình Polly retry policies.
/// </summary>
// EN: Register resilient HttpClient / VI: Đăng ký HttpClient có khả năng phục hồi
builder.Services.AddHttpClient<IExternalService, ExternalService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:External:BaseUrl"]!);
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddResilienceHandler("external-api", builder =>
{
// EN: Retry with exponential backoff
// VI: Retry với thời gian chờ tăng dần
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => !r.IsSuccessStatusCode && (int)r.StatusCode >= 500)
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>(),
OnRetry = args =>
{
Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}");
return ValueTask.CompletedTask;
}
});
// EN: Circuit breaker
// VI: Circuit breaker
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30)
});
// EN: Timeout
// VI: Timeout
builder.AddTimeout(TimeSpan.FromSeconds(10));
});
```
### Health Checks
```csharp
/// <summary>
/// EN: Configure health checks.
/// VI: Cấu hình health checks.
/// </summary>
// EN: Register health checks / VI: Đăng ký health checks
builder.Services.AddHealthChecks()
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
tags: new[] { "db", "critical" })
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis",
tags: new[] { "cache" })
.AddCheck<ExternalServiceHealthCheck>("external-service", tags: new[] { "external" });
// EN: Map health endpoints / VI: Map health endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // EN: Just check app is running / VI: Chỉ kiểm tra app đang chạy
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("critical")
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
```
### Custom Health Check
```csharp
/// <summary>
/// EN: Custom health check for external service.
/// VI: Health check tùy chỉnh cho external service.
/// </summary>
public class ExternalServiceHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
public ExternalServiceHealthCheck(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient("ExternalService");
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync("/health", ct);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy("External service is healthy")
: HealthCheckResult.Degraded($"External service returned {response.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(
"External service is unavailable",
exception: ex);
}
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Catching Generic Exceptions
```csharp
// ❌ BAD: Swallowing all exceptions
try
{
await ProcessOrderAsync(order);
}
catch (Exception)
{
// Do nothing
}
// ✅ GOOD: Handle specific exceptions
try
{
await ProcessOrderAsync(order);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation failed");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error processing order");
throw;
}
```
### 2. Exposing Internal Errors
```csharp
// ❌ BAD: Exposing stack trace
return StatusCode(500, new { Error = ex.ToString() });
// ✅ GOOD: Generic message for internal errors
return StatusCode(500, new ApiResponse<object>
{
Success = false,
Error = "An unexpected error occurred",
ErrorCode = "INTERNAL_ERROR"
});
```
### 3. Missing Retry Configuration
```csharp
// ❌ BAD: No retry for transient failures
var response = await _httpClient.GetAsync("/api/data");
// ✅ GOOD: Use resilient HttpClient with Polly
// (configured at DI registration level)
var response = await _resilientHttpClient.GetAsync("/api/data");
```
### 4. Ignoring Cancellation Token
```csharp
// ❌ BAD: Ignoring cancellation
await Task.Delay(5000);
// ✅ GOOD: Respect cancellation
await Task.Delay(5000, cancellationToken);
```
## Quick Reference / Tham Chiếu Nhanh
### Error Response Format
```json
{
"success": false,
"error": "User-friendly error message",
"errorCode": "VALIDATION_ERROR",
"errors": {
"email": ["Email is required", "Invalid email format"],
"password": ["Password must be at least 8 characters"]
}
}
```
### Polly Strategies
| Strategy | Purpose | Typical Config |
|----------|---------|----------------|
| **Retry** | Retry transient failures | 3 attempts, exponential backoff |
| **Circuit Breaker** | Stop calling failing service | 50% failure, 30s break |
| **Timeout** | Limit wait time | 10-30 seconds |
| **Rate Limiter** | Throttle requests | X requests/minute |
### Health Check Tags
| Tag | Purpose |
|-----|---------|
| `critical` | Required for app to function |
| `db` | Database connectivity |
| `cache` | Cache connectivity |
| `external` | External service |
### Common Status Codes
```csharp
return BadRequest(...); // 400
return Unauthorized(...); // 401
return Forbid(...); // 403
return NotFound(...); // 404
return Conflict(...); // 409
return UnprocessableEntity(...); // 422
return StatusCode(500, ...); // 500
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [API Design](../api-design/SKILL.md) - API error responses
- [Security](../security/SKILL.md) - Authentication errors
- [Testing Patterns](../testing-patterns/SKILL.md) - Testing error handling
- [Project Rules](../project-rules/SKILL.md) - Coding standards

View File

@@ -0,0 +1,889 @@
# Error Handling Patterns - Detailed Reference
Detailed code examples for error handling and resiliency patterns in ASP.NET Core.
## Table of Contents
1. [Exception Middleware](#exception-middleware)
2. [Domain Exceptions](#domain-exceptions)
3. [Validation with FluentValidation](#validation-with-fluentvalidation)
4. [Result Pattern](#result-pattern)
5. [Polly Resiliency](#polly-resiliency)
6. [Health Checks](#health-checks)
7. [Problem Details](#problem-details)
---
## Exception Middleware
### Complete Exception Handler
```csharp
/// <summary>
/// EN: Global exception handler middleware with detailed error mapping.
/// VI: Middleware xử lý exception toàn cục với ánh xạ lỗi chi tiết.
/// </summary>
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
private readonly IHostEnvironment _environment;
public ExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlerMiddleware> logger,
IHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var traceId = Activity.Current?.Id ?? context.TraceIdentifier;
// EN: Log with appropriate level based on exception type
// VI: Log với level phù hợp dựa trên loại exception
LogException(ex, traceId);
var response = CreateErrorResponse(ex, traceId);
context.Response.StatusCode = response.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(response.Body);
}
private void LogException(Exception ex, string traceId)
{
var message = "EN: Error occurred / VI: Đã xảy ra lỗi TraceId: {TraceId}";
switch (ex)
{
case ValidationException:
case NotFoundException:
_logger.LogWarning(ex, message, traceId);
break;
case DomainException:
_logger.LogWarning(ex, message, traceId);
break;
default:
_logger.LogError(ex, message, traceId);
break;
}
}
private (int StatusCode, object Body) CreateErrorResponse(Exception ex, string traceId)
{
return ex switch
{
ValidationException ve => (400, new ErrorResponse
{
Success = false,
Error = ve.Message,
ErrorCode = "VALIDATION_ERROR",
Errors = ve.Errors,
TraceId = traceId
}),
UnauthorizedAccessException => (401, new ErrorResponse
{
Success = false,
Error = "Authentication required",
ErrorCode = "UNAUTHORIZED",
TraceId = traceId
}),
ForbiddenException fe => (403, new ErrorResponse
{
Success = false,
Error = fe.Message,
ErrorCode = "FORBIDDEN",
TraceId = traceId
}),
NotFoundException nf => (404, new ErrorResponse
{
Success = false,
Error = nf.Message,
ErrorCode = "NOT_FOUND",
TraceId = traceId
}),
ConflictException ce => (409, new ErrorResponse
{
Success = false,
Error = ce.Message,
ErrorCode = "CONFLICT",
TraceId = traceId
}),
DomainException de => (422, new ErrorResponse
{
Success = false,
Error = de.Message,
ErrorCode = de.ErrorCode,
TraceId = traceId
}),
_ => (500, new ErrorResponse
{
Success = false,
Error = _environment.IsDevelopment()
? ex.Message
: "An unexpected error occurred",
ErrorCode = "INTERNAL_ERROR",
TraceId = traceId,
StackTrace = _environment.IsDevelopment() ? ex.StackTrace : null
})
};
}
}
/// <summary>
/// EN: Standardized error response.
/// VI: Response lỗi chuẩn hóa.
/// </summary>
public class ErrorResponse
{
public bool Success { get; set; }
public string Error { get; set; } = string.Empty;
public string ErrorCode { get; set; } = string.Empty;
public string? TraceId { get; set; }
public IReadOnlyDictionary<string, string[]>? Errors { get; set; }
public string? StackTrace { get; set; }
}
```
---
## Domain Exceptions
### Complete Exception Hierarchy
```csharp
/// <summary>
/// EN: Base domain exception with error code.
/// VI: Domain exception cơ sở với error code.
/// </summary>
public abstract class DomainException : Exception
{
public string ErrorCode { get; }
protected DomainException(string message, string errorCode)
: base(message)
{
ErrorCode = errorCode;
}
protected DomainException(string message, string errorCode, Exception innerException)
: base(message, innerException)
{
ErrorCode = errorCode;
}
}
/// <summary>
/// EN: Validation exception with field-level errors.
/// VI: Exception validation với lỗi từng field.
/// </summary>
public class ValidationException : DomainException
{
public IReadOnlyDictionary<string, string[]> Errors { get; }
public ValidationException(string message)
: base(message, "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(string field, string error)
: base($"Validation failed for {field}", "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>
{
{ field, new[] { error } }
};
}
public ValidationException(IDictionary<string, string[]> errors)
: base("One or more validation errors occurred", "VALIDATION_ERROR")
{
Errors = new Dictionary<string, string[]>(errors);
}
public ValidationException(IEnumerable<FluentValidation.Results.ValidationFailure> failures)
: base("One or more validation errors occurred", "VALIDATION_ERROR")
{
Errors = failures
.GroupBy(f => f.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(f => f.ErrorMessage).ToArray());
}
}
/// <summary>
/// EN: Resource not found exception.
/// VI: Exception không tìm thấy resource.
/// </summary>
public class NotFoundException : DomainException
{
public string ResourceType { get; }
public object? ResourceId { get; }
public NotFoundException(string resourceType, object resourceId)
: base($"{resourceType} with ID '{resourceId}' was not found", "NOT_FOUND")
{
ResourceType = resourceType;
ResourceId = resourceId;
}
public NotFoundException(string message)
: base(message, "NOT_FOUND")
{
ResourceType = "Resource";
}
}
/// <summary>
/// EN: Conflict exception for duplicate resources.
/// VI: Exception xung đột cho resource trùng lặp.
/// </summary>
public class ConflictException : DomainException
{
public ConflictException(string message)
: base(message, "CONFLICT")
{ }
public ConflictException(string resourceType, string identifier)
: base($"{resourceType} with identifier '{identifier}' already exists", "CONFLICT")
{ }
}
/// <summary>
/// EN: Forbidden exception for permission errors.
/// VI: Exception forbidden cho lỗi phân quyền.
/// </summary>
public class ForbiddenException : DomainException
{
public ForbiddenException(string message = "You do not have permission to perform this action")
: base(message, "FORBIDDEN")
{ }
}
/// <summary>
/// EN: Business rule exception.
/// VI: Exception quy tắc nghiệp vụ.
/// </summary>
public class BusinessRuleException : DomainException
{
public BusinessRuleException(string message, string errorCode = "BUSINESS_RULE_VIOLATION")
: base(message, errorCode)
{ }
}
```
---
## Validation with FluentValidation
### Validation Behavior for MediatR
```csharp
/// <summary>
/// EN: MediatR pipeline behavior for FluentValidation.
/// VI: MediatR 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 cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
// EN: Registration in Program.cs / VI: Đăng ký trong Program.cs
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
```
### Sample Validators
```csharp
/// <summary>
/// EN: Validator for CreateOrderCommand.
/// VI: Validator cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty().WithMessage("User ID is required");
RuleFor(x => x.ShippingAddress)
.NotNull().WithMessage("Shipping address is required")
.SetValidator(new AddressValidator()!);
RuleFor(x => x.Items)
.NotEmpty().WithMessage("At least one item is required");
RuleForEach(x => x.Items)
.SetValidator(new OrderItemValidator());
}
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street)
.NotEmpty().WithMessage("Street is required")
.MaximumLength(200);
RuleFor(x => x.City)
.NotEmpty().WithMessage("City is required")
.MaximumLength(100);
RuleFor(x => x.PostalCode)
.NotEmpty().WithMessage("Postal code is required")
.Matches(@"^\d{5}(-\d{4})?$").WithMessage("Invalid postal code format");
}
}
public class OrderItemValidator : AbstractValidator<OrderItemDto>
{
public OrderItemValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty().WithMessage("Product ID is required");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be positive");
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("Price cannot be negative");
}
}
```
---
## Result Pattern
### Generic Result Type
```csharp
/// <summary>
/// EN: Result type for operations that can fail.
/// VI: Result type cho operations có thể thất bại.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public string? Error { get; }
public string? ErrorCode { get; }
protected Result(bool isSuccess, string? error, string? errorCode)
{
IsSuccess = isSuccess;
Error = error;
ErrorCode = errorCode;
}
public static Result Success() => new(true, null, null);
public static Result Failure(string error, string errorCode = "ERROR")
=> new(false, error, errorCode);
public static Result<T> Success<T>(T value) => new(value, true, null, null);
public static Result<T> Failure<T>(string error, string errorCode = "ERROR")
=> new(default!, false, error, errorCode);
}
/// <summary>
/// EN: Result type with value.
/// VI: Result type có giá trị.
/// </summary>
public class Result<T> : Result
{
public T Value { get; }
internal Result(T value, bool isSuccess, string? error, string? errorCode)
: base(isSuccess, error, errorCode)
{
Value = value;
}
public static implicit operator Result<T>(T value) => Success(value);
}
/// <summary>
/// EN: Result extensions for common patterns.
/// VI: Extensions cho các mẫu phổ biến.
/// </summary>
public static class ResultExtensions
{
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> mapper)
{
return result.IsSuccess
? Result.Success(mapper(result.Value))
: Result.Failure<TOut>(result.Error!, result.ErrorCode!);
}
public static async Task<Result<TOut>> MapAsync<TIn, TOut>(
this Task<Result<TIn>> resultTask,
Func<TIn, TOut> mapper)
{
var result = await resultTask;
return result.Map(mapper);
}
public static Result<T> Ensure<T>(
this Result<T> result,
Func<T, bool> predicate,
string error,
string errorCode = "ERROR")
{
if (result.IsFailure)
return result;
return predicate(result.Value)
? result
: Result.Failure<T>(error, errorCode);
}
}
```
### Using Result Pattern
```csharp
/// <summary>
/// EN: Command handler returning Result instead of throwing.
/// VI: Command handler trả về Result thay vì throw.
/// </summary>
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, Result<OrderResult>>
{
private readonly IOrderRepository _orderRepository;
private readonly IUserService _userService;
public async Task<Result<OrderResult>> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Validate user exists
// VI: Xác thực user tồn tại
var userExists = await _userService.ExistsAsync(request.UserId, ct);
if (!userExists)
return Result.Failure<OrderResult>("User not found", "USER_NOT_FOUND");
// EN: Validate items
// VI: Xác thực items
if (!request.Items.Any())
return Result.Failure<OrderResult>("At least one item required", "EMPTY_ORDER");
// EN: Create order
// VI: Tạo order
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);
return Result.Success(new OrderResult(order.Id));
}
}
// EN: Controller using Result pattern / VI: Controller dùng Result pattern
[HttpPost]
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken ct)
{
var command = request.ToCommand(GetUserId());
var result = await _mediator.Send(command, ct);
if (result.IsFailure)
{
return result.ErrorCode switch
{
"USER_NOT_FOUND" => NotFound(ApiResponse.Fail(result.Error!, result.ErrorCode)),
"EMPTY_ORDER" => BadRequest(ApiResponse.Fail(result.Error!, result.ErrorCode)),
_ => StatusCode(500, ApiResponse.Fail(result.Error!, result.ErrorCode))
};
}
return CreatedAtAction(
nameof(GetOrder),
new { orderId = result.Value.OrderId },
ApiResponse.Ok(result.Value));
}
```
---
## Polly Resiliency
### Comprehensive Resilience Configuration
```csharp
/// <summary>
/// EN: Configure resilient HTTP client with Polly.
/// VI: Cấu hình HTTP client có khả năng phục hồi với Polly.
/// </summary>
// EN: Using Microsoft.Extensions.Http.Resilience (Polly v8 integration)
// VI: Sử dụng Microsoft.Extensions.Http.Resilience (tích hợp Polly v8)
builder.Services.AddHttpClient<IExternalPaymentService, ExternalPaymentService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Payment:BaseUrl"]!);
})
.AddResilienceHandler("payment-service", builder =>
{
// EN: Retry policy with exponential backoff
// VI: Retry policy với thời gian chờ tăng dần
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(500),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // EN: Add randomness / VI: Thêm ngẫu nhiên
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => (int)r.StatusCode >= 500)
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>(),
OnRetry = args =>
{
Console.WriteLine(
$"EN: Retry {args.AttemptNumber} after {args.RetryDelay} / " +
$"VI: Thử lại lần {args.AttemptNumber} sau {args.RetryDelay}");
return ValueTask.CompletedTask;
}
});
// EN: Circuit breaker
// VI: Circuit breaker
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // EN: 50% failures / VI: 50% thất bại
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
Console.WriteLine("EN: Circuit opened / VI: Circuit đã mở");
return ValueTask.CompletedTask;
},
OnClosed = args =>
{
Console.WriteLine("EN: Circuit closed / VI: Circuit đã đóng");
return ValueTask.CompletedTask;
}
});
// EN: Timeout per request
// VI: Timeout cho mỗi request
builder.AddTimeout(TimeSpan.FromSeconds(10));
});
```
### Resilient Database Operations
```csharp
/// <summary>
/// EN: Configure EF Core with retry logic.
/// VI: Cấu hình EF Core với logic retry.
/// </summary>
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(
builder.Configuration.GetConnectionString("DefaultConnection"),
npgsqlOptions =>
{
// EN: Enable retry on transient failures
// VI: Bật retry cho lỗi tạm thời
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
npgsqlOptions.CommandTimeout(30);
});
});
```
---
## Health Checks
### Comprehensive Health Check Setup
```csharp
/// <summary>
/// EN: Configure comprehensive health checks.
/// VI: Cấu hình health checks toàn diện.
/// </summary>
// EN: Add health checks / VI: Thêm health checks
builder.Services.AddHealthChecks()
// EN: Database / VI: Database
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
name: "postgresql",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "critical" })
// EN: Redis cache / VI: Redis cache
.AddRedis(
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "cache" })
// EN: External services / VI: Services bên ngoài
.AddUrlGroup(
new Uri(builder.Configuration["Services:Payment:HealthUrl"]!),
name: "payment-service",
failureStatus: HealthStatus.Degraded,
tags: new[] { "external" })
// EN: Custom health check / VI: Health check tùy chỉnh
.AddCheck<StorageHealthCheck>("storage", tags: new[] { "storage", "critical" });
// EN: Configure endpoints / VI: Cấu hình endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
// EN: Liveness - just check if app is running
// VI: Liveness - chỉ kiểm tra app có đang chạy không
Predicate = _ => false,
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"status\":\"Healthy\"}");
}
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
// EN: Readiness - check critical dependencies
// VI: Readiness - kiểm tra các dependency quan trọng
Predicate = check => check.Tags.Contains("critical"),
ResponseWriter = WriteHealthCheckResponse
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteHealthCheckResponse
});
static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
exception = e.Value.Exception?.Message
})
};
await context.Response.WriteAsJsonAsync(result);
}
```
### Custom Health Check Implementation
```csharp
/// <summary>
/// EN: Custom health check for storage service.
/// VI: Health check tùy chỉnh cho storage service.
/// </summary>
public class StorageHealthCheck : IHealthCheck
{
private readonly IMinioClient _minioClient;
private readonly ILogger<StorageHealthCheck> _logger;
public StorageHealthCheck(
IMinioClient minioClient,
ILogger<StorageHealthCheck> logger)
{
_minioClient = minioClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// EN: Check if bucket exists and is accessible
// VI: Kiểm tra bucket tồn tại và có thể truy cập
var bucketExists = await _minioClient.BucketExistsAsync(
new BucketExistsArgs().WithBucket("uploads"),
cancellationToken);
if (!bucketExists)
{
return HealthCheckResult.Degraded(
"Storage bucket not found",
data: new Dictionary<string, object>
{
{ "bucket", "uploads" }
});
}
return HealthCheckResult.Healthy(
"Storage service is healthy",
data: new Dictionary<string, object>
{
{ "bucket", "uploads" },
{ "status", "available" }
});
}
catch (Exception ex)
{
_logger.LogError(ex, "EN: Storage health check failed / VI: Health check storage thất bại");
return HealthCheckResult.Unhealthy(
"Storage service is unavailable",
exception: ex);
}
}
}
```
---
## Problem Details
### RFC 7807 Problem Details
```csharp
/// <summary>
/// EN: Configure Problem Details for standardized errors.
/// VI: Cấu hình Problem Details cho lỗi chuẩn hóa.
/// </summary>
// EN: Add Problem Details / VI: Thêm Problem Details
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
// EN: Use exception handler with Problem Details
// VI: Sử dụng exception handler với Problem Details
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var exceptionHandler = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandler?.Error;
if (exception != null)
{
var problemDetails = exception switch
{
ValidationException ve => new ProblemDetails
{
Status = 400,
Title = "Validation Error",
Detail = ve.Message,
Extensions = { ["errors"] = ve.Errors }
},
NotFoundException nf => new ProblemDetails
{
Status = 404,
Title = "Not Found",
Detail = nf.Message
},
_ => new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred"
}
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = problemDetails.Status ?? 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
}
});
});
```
---
## Resources / Tài Nguyên
- [Microsoft: Handle Errors in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/web-api/handle-errors)
- [Polly Documentation](https://github.com/App-vNext/Polly)
- [FluentValidation](https://docs.fluentvalidation.net/)
- [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807)
- [Health Checks in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks)

View File

@@ -0,0 +1,369 @@
---
name: repository-pattern
description: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, aggregate roots, Unit of Work, và CQRS queries.
compatibility: ".NET 8+, Entity Framework Core 8+, Dapper"
metadata:
author: Velik Ho
version: "1.0"
---
# Repository Pattern / Mẫu Repository
Repository pattern cho GoodGo microservices theo chuẩn Domain-Driven Design (DDD).
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Creating new repositories for aggregates / Tạo repositories mới cho aggregates
- Implementing data access with EF Core / Triển khai data access với EF Core
- Setting up Unit of Work pattern / Cài đặt Unit of Work pattern
- Separating read/write operations (CQRS) / Phân tách read/write (CQRS)
- Creating database migrations / Tạo database migrations
- Optimizing query performance / Tối ưu hiệu năng query
## Core Concepts / Khái Niệm Cốt Lõi
### 1. One Repository Per Aggregate Root / Một Repository Cho Mỗi Aggregate Root
```
❌ WRONG: Repository cho từng table
OrderRepository
OrderItemRepository
OrderPaymentRepository
✅ CORRECT: Repository chỉ cho Aggregate Root
OrderRepository (quản lý Order + OrderItems + OrderPayments)
```
**Nguyên tắc:**
- Aggregate Root chịu trách nhiệm duy trì tính nhất quán
- Mọi thao tác với entities con phải thông qua Root
- Repository chỉ expose Aggregate Root, không expose entities con
### 2. Separated Interface / Tách Giao Diện
```
Domain Layer (ServiceName.Domain/)
├── AggregatesModel/
│ └── OrderAggregate/
│ ├── Order.cs # Aggregate Root
│ ├── OrderItem.cs # Entity con
│ └── IOrderRepository.cs # Interface
Infrastructure Layer (ServiceName.Infrastructure/)
├── Repositories/
│ └── OrderRepository.cs # Implementation
└── Data/
└── ApplicationDbContext.cs
```
**Lợi ích:**
- Domain không phụ thuộc vào công nghệ (EF Core, Dapper)
- Dễ thay thế implementation
- Tuân thủ Dependency Inversion Principle
### 3. Unit of Work / Đơn Vị Công Việc
```csharp
// EN: DbContext IS the Unit of Work in EF Core
// VI: DbContext CHÍNH LÀ Unit of Work trong EF Core
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
// EN: All repositories share the same DbContext
// VI: Tất cả repositories dùng chung một DbContext
```
### 4. CQRS Simplified / CQRS Đơn Giản
| Operation | Pattern | Tool |
|-----------|---------|------|
| **Commands** (Create, Update, Delete) | Repository + EF Core | Full domain model |
| **Queries** (Read) | Dapper / Raw SQL | Lightweight DTOs |
## Key Patterns / Mẫu Chính
### Repository Interface / Giao Diện Repository
```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);
}
/// <summary>
/// EN: Order repository interface.
/// VI: Interface repository cho Order.
/// </summary>
public interface IOrderRepository : IRepository<Order>
{
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByUserIdAsync(string userId, CancellationToken ct = default);
}
```
### EF Core Implementation / Triển Khai EF Core
```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);
}
}
```
### Command Handler with Repository / Handler với Repository
```csharp
/// <summary>
/// EN: Command handler using repository pattern.
/// VI: Command handler sử dụng repository pattern.
/// </summary>
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IOrderRepository _orderRepository;
public CreateOrderCommandHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<OrderResult> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// EN: Create aggregate through domain model
// VI: Tạo aggregate thông 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);
}
// EN: Persist through repository
// VI: Lưu qua repository
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
return new OrderResult(order.Id);
}
}
```
### Query with Dapper (CQRS) / Query với Dapper
```csharp
/// <summary>
/// EN: Query handler using Dapper for read operations.
/// VI: Query handler dùng Dapper cho operations đọc.
/// </summary>
public class GetOrdersQueryHandler : IRequestHandler<GetOrdersQuery, IReadOnlyList<OrderDto>>
{
private readonly IDbConnection _connection;
public GetOrdersQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<IReadOnlyList<OrderDto>> Handle(
GetOrdersQuery request,
CancellationToken ct)
{
// EN: Use Dapper for optimized read queries
// VI: Dùng Dapper cho query đọc tối ưu
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<OrderDto>(sql, new
{
request.UserId,
request.Skip,
request.Take
});
return orders.ToList();
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Repository Per Table / Repository Cho Từng Table
```csharp
// ❌ BAD: Multiple repositories for one aggregate
public class OrderItemRepository { }
public class OrderPaymentRepository { }
// ✅ GOOD: Only repository for aggregate root
public class OrderRepository
{
// EN: Access child entities through aggregate
// VI: Truy cập entities con thông qua aggregate
public async Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct)
{
return await _context.Orders
.Include(o => o.OrderItems)
.Include(o => o.Payment)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
}
```
### 2. Bypassing Aggregate Root / Bỏ Qua Aggregate Root
```csharp
// ❌ BAD: Modifying child entity directly
var orderItem = await _context.OrderItems.FindAsync(itemId);
orderItem.Quantity = 5;
// ✅ GOOD: Modify through aggregate root
var order = await _orderRepository.GetWithItemsAsync(orderId, ct);
order.UpdateItemQuantity(itemId, 5);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
```
### 3. Exposing IQueryable / Lộ IQueryable
```csharp
// ❌ BAD: Leaking implementation details
public interface IOrderRepository
{
IQueryable<Order> GetAll();
}
// ✅ GOOD: Encapsulated queries
public interface IOrderRepository
{
Task<IReadOnlyList<Order>> GetByUserIdAsync(string userId, CancellationToken ct);
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct);
}
```
### 4. Missing Unit of Work / Thiếu Unit of Work
```csharp
// ❌ BAD: SaveChanges in repository
public async Task<Order> AddAsync(Order order, CancellationToken ct)
{
await _context.Orders.AddAsync(order, ct);
await _context.SaveChangesAsync(ct); // WRONG!
return order;
}
// ✅ GOOD: SaveChanges in handler (Unit of Work)
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
{
await _orderRepository.AddAsync(order, ct);
await _orderRepository.UnitOfWork.SaveChangesAsync(ct); // CORRECT!
}
```
## Quick Reference / Tham Chiếu Nhanh
### Project Structure
| Component | Location |
|-----------|----------|
| **Repository Interface** | `Domain/AggregatesModel/{Aggregate}/I{Aggregate}Repository.cs` |
| **Repository Implementation** | `Infrastructure/Repositories/{Aggregate}Repository.cs` |
| **DbContext** | `Infrastructure/Data/ApplicationDbContext.cs` |
| **Entity Configurations** | `Infrastructure/Data/Configurations/` |
### DI Registration
```csharp
// EN: Register repositories in Program.cs
// VI: Đăng ký repositories trong Program.cs
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
// 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));
```
### Migration Commands
```bash
# EN: Add migration / VI: Thêm migration
dotnet ef migrations add MigrationName --project src/Service.Infrastructure
# EN: Update database / VI: Cập nhật database
dotnet ef database update --project src/Service.Infrastructure
# EN: Generate SQL script / VI: Tạo SQL script
dotnet ef migrations script --project src/Service.Infrastructure
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [API Design](../api-design/SKILL.md) - API patterns
- [Error Handling](../error-handling-patterns/SKILL.md) - Error handling
- [Testing Patterns](../testing-patterns/SKILL.md) - Repository testing
- [Project Rules](../project-rules/SKILL.md) - Coding standards

View File

@@ -0,0 +1,770 @@
# Repository Pattern - Detailed Reference
Detailed code examples for Repository pattern in ASP.NET Core with Entity Framework Core.
## Table of Contents
1. [Aggregate Root Pattern](#aggregate-root-pattern)
2. [Repository Interface Design](#repository-interface-design)
3. [EF Core Implementation](#ef-core-implementation)
4. [Unit of Work Pattern](#unit-of-work-pattern)
5. [Entity Configuration](#entity-configuration)
6. [CQRS with Dapper](#cqrs-with-dapper)
7. [Specification Pattern](#specification-pattern)
8. [DI Registration](#di-registration)
---
## Aggregate Root Pattern
### Domain Entities
```csharp
/// <summary>
/// EN: Marker interface for aggregate roots.
/// VI: Interface đánh dấu aggregate roots.
/// </summary>
public interface IAggregateRoot { }
/// <summary>
/// EN: Base entity with common properties.
/// VI: Entity cơ sở với các properties chung.
/// </summary>
public abstract class Entity
{
public Guid Id { get; protected set; }
public DateTime CreatedAt { get; protected set; }
public DateTime? UpdatedAt { get; protected set; }
protected Entity()
{
Id = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
}
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;
return Id == other.Id;
}
public override int GetHashCode() => Id.GetHashCode();
}
```
### Order Aggregate Example
```csharp
/// <summary>
/// EN: Order aggregate root.
/// VI: Aggregate root cho Order.
/// </summary>
public class Order : Entity, IAggregateRoot
{
public string UserId { get; private set; }
public OrderStatus Status { get; private set; }
public Address ShippingAddress { get; private set; }
public decimal TotalAmount { get; private set; }
private readonly List<OrderItem> _orderItems = new();
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
// EN: Private constructor for EF Core
// VI: Constructor private cho EF Core
private Order() { }
public Order(string userId, Address shippingAddress)
{
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
Status = OrderStatus.Draft;
}
/// <summary>
/// EN: Add item through aggregate root to maintain invariants.
/// VI: Thêm item thông qua aggregate root để duy trì invariants.
/// </summary>
public void AddItem(Guid productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot add items to non-draft order");
var existingItem = _orderItems.FirstOrDefault(x => x.ProductId == productId);
if (existingItem != null)
{
existingItem.AddQuantity(quantity);
}
else
{
_orderItems.Add(new OrderItem(Id, productId, quantity, unitPrice));
}
RecalculateTotal();
}
/// <summary>
/// EN: Update item quantity through aggregate root.
/// VI: Cập nhật số lượng item thông qua aggregate root.
/// </summary>
public void UpdateItemQuantity(Guid itemId, int newQuantity)
{
var item = _orderItems.FirstOrDefault(x => x.Id == itemId)
?? throw new DomainException($"Item {itemId} not found");
if (newQuantity <= 0)
{
_orderItems.Remove(item);
}
else
{
item.SetQuantity(newQuantity);
}
RecalculateTotal();
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted");
if (!_orderItems.Any())
throw new DomainException("Cannot submit empty order");
Status = OrderStatus.Submitted;
UpdatedAt = DateTime.UtcNow;
}
private void RecalculateTotal()
{
TotalAmount = _orderItems.Sum(x => x.GetSubtotal());
UpdatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// EN: Order item - child entity of Order aggregate.
/// VI: Order item - entity con của Order aggregate.
/// </summary>
public class OrderItem : Entity
{
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
private OrderItem() { }
internal OrderItem(Guid orderId, Guid productId, int quantity, decimal unitPrice)
{
OrderId = orderId;
ProductId = productId;
Quantity = quantity > 0 ? quantity : throw new ArgumentException("Quantity must be positive");
UnitPrice = unitPrice >= 0 ? unitPrice : throw new ArgumentException("Price cannot be negative");
}
internal void AddQuantity(int quantity)
{
Quantity += quantity;
}
internal void SetQuantity(int quantity)
{
Quantity = quantity > 0 ? quantity : throw new ArgumentException("Quantity must be positive");
}
public decimal GetSubtotal() => Quantity * UnitPrice;
}
/// <summary>
/// EN: Value object for address.
/// VI: Value object cho địa chỉ.
/// </summary>
public record Address(
string Street,
string City,
string State,
string PostalCode,
string Country);
public enum OrderStatus
{
Draft,
Submitted,
Processing,
Shipped,
Delivered,
Cancelled
}
```
---
## Repository Interface Design
### Base Repository Interface
```csharp
/// <summary>
/// EN: Generic repository interface for aggregate roots.
/// VI: Interface repository generic cho aggregate roots.
/// </summary>
public interface IRepository<T> where T : class, IAggregateRoot
{
/// <summary>
/// EN: Unit of Work for transaction management.
/// VI: Unit of Work cho quản lý transaction.
/// </summary>
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);
}
/// <summary>
/// EN: Unit of Work interface.
/// VI: Interface Unit of Work.
/// </summary>
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task<bool> SaveEntitiesAsync(CancellationToken ct = default);
}
```
### Specific Repository Interface
```csharp
/// <summary>
/// EN: Order repository interface with domain-specific methods.
/// VI: Interface repository Order với các phương thức domain-specific.
/// </summary>
public interface IOrderRepository : IRepository<Order>
{
/// <summary>
/// EN: Get order with all items loaded.
/// VI: Lấy order với tất cả items được load.
/// </summary>
Task<Order?> GetWithItemsAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// EN: Get orders by user with pagination.
/// VI: Lấy orders theo user với phân trang.
/// </summary>
Task<IReadOnlyList<Order>> GetByUserIdAsync(
string userId,
int skip = 0,
int take = 20,
CancellationToken ct = default);
/// <summary>
/// EN: Get orders by status.
/// VI: Lấy orders theo status.
/// </summary>
Task<IReadOnlyList<Order>> GetByStatusAsync(
OrderStatus status,
CancellationToken ct = default);
}
```
---
## EF Core Implementation
### DbContext as Unit of Work
```csharp
/// <summary>
/// EN: Application DbContext implementing Unit of Work.
/// VI: DbContext ứng dụng triển khai Unit of Work.
/// </summary>
public class ApplicationDbContext : DbContext, IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
/// <summary>
/// EN: Save entities with domain events support.
/// VI: Lưu entities với hỗ trợ domain events.
/// </summary>
public async Task<bool> SaveEntitiesAsync(CancellationToken ct = default)
{
// EN: Dispatch domain events before saving
// VI: Dispatch domain events trước khi lưu
// await _mediator.DispatchDomainEventsAsync(this);
await SaveChangesAsync(ct);
return true;
}
}
```
### Repository Implementation
```csharp
/// <summary>
/// EN: Order repository implementation with EF Core.
/// VI: Triển khai repository Order 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<IReadOnlyList<Order>> GetByUserIdAsync(
string userId,
int skip = 0,
int take = 20,
CancellationToken ct = default)
{
return await _context.Orders
.Where(o => o.UserId == userId)
.OrderByDescending(o => o.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Order>> GetByStatusAsync(
OrderStatus status,
CancellationToken ct = default)
{
return await _context.Orders
.Where(o => o.Status == status)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(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);
}
}
```
---
## Entity Configuration
### Fluent API Configuration
```csharp
/// <summary>
/// EN: EF Core configuration for Order entity.
/// VI: Cấu hình EF Core cho entity Order.
/// </summary>
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.UserId)
.IsRequired()
.HasMaxLength(36);
builder.Property(o => o.Status)
.IsRequired()
.HasConversion<string>();
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2);
// EN: Configure owned type for Address
// VI: Cấu hình owned type cho Address
builder.OwnsOne(o => o.ShippingAddress, a =>
{
a.Property(x => x.Street).HasMaxLength(200);
a.Property(x => x.City).HasMaxLength(100);
a.Property(x => x.State).HasMaxLength(100);
a.Property(x => x.PostalCode).HasMaxLength(20);
a.Property(x => x.Country).HasMaxLength(100);
});
// EN: Configure navigation to OrderItems
// VI: Cấu hình navigation đến OrderItems
var navigation = builder.Metadata.FindNavigation(nameof(Order.OrderItems));
navigation?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(o => o.OrderItems)
.WithOne()
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}
/// <summary>
/// EN: EF Core configuration for OrderItem entity.
/// VI: Cấu hình EF Core cho entity OrderItem.
/// </summary>
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> builder)
{
builder.ToTable("OrderItems");
builder.HasKey(oi => oi.Id);
builder.Property(oi => oi.UnitPrice)
.HasPrecision(18, 2);
builder.HasIndex(oi => oi.OrderId);
}
}
```
---
## CQRS with Dapper
### Query DTOs
```csharp
/// <summary>
/// EN: Lightweight DTO for order queries.
/// VI: DTO nhẹ cho order queries.
/// </summary>
public record OrderSummaryDto(
Guid Id,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
int ItemCount);
public record OrderDetailDto(
Guid Id,
string UserId,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
AddressDto ShippingAddress,
List<OrderItemDto> Items);
public record OrderItemDto(
Guid Id,
Guid ProductId,
string ProductName,
int Quantity,
decimal UnitPrice,
decimal Subtotal);
public record AddressDto(
string Street,
string City,
string State,
string PostalCode,
string Country);
```
### Dapper Query Handler
```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)
{
// EN: Count total records
// VI: Đếm tổng số records
const string countSql = @"
SELECT COUNT(*) FROM Orders WHERE UserId = @UserId";
var total = await _connection.ExecuteScalarAsync<int>(countSql, new { request.UserId });
// EN: Get paginated results
// VI: Lấy kết quả phân trang
const string dataSql = @"
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>(dataSql, new
{
request.UserId,
request.Skip,
request.Take
});
return new PagedResult<OrderSummaryDto>(
orders.ToList(),
total,
request.Skip / request.Take + 1,
request.Take);
}
}
/// <summary>
/// EN: Get order detail with items using Dapper multi-mapping.
/// VI: Lấy chi tiết order với items dùng Dapper multi-mapping.
/// </summary>
public class GetOrderDetailQueryHandler
: IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
{
private readonly IDbConnection _connection;
public async Task<OrderDetailDto?> Handle(
GetOrderDetailQuery request,
CancellationToken ct)
{
const string sql = @"
SELECT
o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt,
o.ShippingAddress_Street as Street,
o.ShippingAddress_City as City,
o.ShippingAddress_State as State,
o.ShippingAddress_PostalCode as PostalCode,
o.ShippingAddress_Country as Country
FROM Orders o
WHERE o.Id = @OrderId AND o.UserId = @UserId;
SELECT
oi.Id, oi.ProductId, p.Name as ProductName,
oi.Quantity, oi.UnitPrice,
(oi.Quantity * oi.UnitPrice) as Subtotal
FROM OrderItems oi
JOIN Products p ON oi.ProductId = p.Id
WHERE oi.OrderId = @OrderId";
using var multi = await _connection.QueryMultipleAsync(sql, new
{
request.OrderId,
request.UserId
});
var order = await multi.ReadFirstOrDefaultAsync<dynamic>();
if (order == null) return null;
var items = (await multi.ReadAsync<OrderItemDto>()).ToList();
return new OrderDetailDto(
order.Id,
order.UserId,
order.Status,
order.TotalAmount,
order.CreatedAt,
new AddressDto(order.Street, order.City, order.State, order.PostalCode, order.Country),
items);
}
}
```
---
## Specification Pattern
### Generic Specification
```csharp
/// <summary>
/// EN: Specification pattern for complex queries.
/// VI: Specification pattern cho queries phức tạp.
/// </summary>
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
return ToExpression().Compile()(entity);
}
public Specification<T> And(Specification<T> other)
=> new AndSpecification<T>(this, other);
public Specification<T> Or(Specification<T> other)
=> new OrSpecification<T>(this, other);
public Specification<T> Not()
=> new NotSpecification<T>(this);
}
public class AndSpecification<T> : Specification<T>
{
private readonly Specification<T> _left;
private readonly Specification<T> _right;
public AndSpecification(Specification<T> left, Specification<T> right)
{
_left = left;
_right = right;
}
public override Expression<Func<T, bool>> ToExpression()
{
var leftExpr = _left.ToExpression();
var rightExpr = _right.ToExpression();
var param = Expression.Parameter(typeof(T));
var body = Expression.AndAlso(
Expression.Invoke(leftExpr, param),
Expression.Invoke(rightExpr, param));
return Expression.Lambda<Func<T, bool>>(body, param);
}
}
```
### Order Specifications
```csharp
/// <summary>
/// EN: Specification for orders by user.
/// VI: Specification cho orders theo user.
/// </summary>
public class OrderByUserSpecification : Specification<Order>
{
private readonly string _userId;
public OrderByUserSpecification(string userId) => _userId = userId;
public override Expression<Func<Order, bool>> ToExpression()
=> order => order.UserId == _userId;
}
/// <summary>
/// EN: Specification for orders by status.
/// VI: Specification cho orders theo status.
/// </summary>
public class OrderByStatusSpecification : Specification<Order>
{
private readonly OrderStatus _status;
public OrderByStatusSpecification(OrderStatus status) => _status = status;
public override Expression<Func<Order, bool>> ToExpression()
=> order => order.Status == _status;
}
// EN: Usage / VI: Cách dùng
var spec = new OrderByUserSpecification(userId)
.And(new OrderByStatusSpecification(OrderStatus.Submitted));
var orders = await _context.Orders
.Where(spec.ToExpression())
.ToListAsync();
```
---
## DI Registration
### Program.cs Configuration
```csharp
/// <summary>
/// EN: Register repositories and DbContext.
/// VI: Đăng ký repositories và DbContext.
/// </summary>
// EN: DbContext registration
// VI: Đăng ký DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(
builder.Configuration.GetConnectionString("DefaultConnection"),
npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
});
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// EN: Repository registrations
// VI: Đăng ký repositories
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
// EN: Dapper connection for queries
// VI: Dapper connection cho queries
builder.Services.AddScoped<IDbConnection>(_ =>
new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));
// EN: Register MediatR
// VI: Đăng ký MediatR
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
```
---
## Resources / Tài Nguyên
- [Microsoft: Implementing Repository Pattern](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-implementation-entity-framework-core)
- [DDD Aggregate Pattern](https://martinfowler.com/bliki/DDD_Aggregate.html)
- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
- [Dapper Documentation](https://github.com/DapperLib/Dapper)

View File

@@ -0,0 +1,445 @@
---
name: testing-patterns
description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers.
compatibility: ".NET 8+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
metadata:
author: Velik Ho
version: "1.0"
---
# Testing Patterns / Mẫu Kiểm Thử
Testing patterns cho GoodGo microservices với xUnit, NSubstitute, và Testcontainers.
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Writing unit tests for handlers / Viết unit tests cho handlers
- Testing controllers with mocked dependencies / Test controllers với dependencies giả lập
- Creating integration tests with database / Tạo integration tests với database
- Setting up functional tests with TestServer / Cài đặt functional tests với TestServer
- Mocking services với NSubstitute / Giả lập services với NSubstitute
## Core Concepts / Khái Niệm Cốt Lõi
### Testing Pyramid / Kim Tự Tháp Testing
```
/\
/ \ E2E Tests (ít nhất)
/----\
/ \ Integration Tests
/--------\
/ \ Unit Tests (nhiều nhất)
--------------
```
| Level | Scope | Speed | Dependencies |
|-------|-------|-------|--------------|
| **Unit** | Single class/method | Milliseconds | Mocked |
| **Integration** | Multiple components + DB | Seconds | Real/Containerized |
| **Functional/E2E** | Full API workflow | Seconds-Minutes | Real services |
### Test Project Structure / Cấu Trúc Project Test
```
tests/
├── ServiceName.UnitTests/
│ ├── Handlers/
│ │ ├── CreateOrderCommandHandlerTests.cs
│ │ └── GetOrderQueryHandlerTests.cs
│ ├── Domain/
│ │ └── OrderTests.cs
│ └── ServiceName.UnitTests.csproj
├── ServiceName.IntegrationTests/
│ ├── Fixtures/
│ │ └── DatabaseFixture.cs
│ ├── Repositories/
│ │ └── OrderRepositoryTests.cs
│ └── ServiceName.IntegrationTests.csproj
└── ServiceName.FunctionalTests/
├── ApiTests/
│ └── OrdersApiTests.cs
└── ServiceName.FunctionalTests.csproj
```
## Key Patterns / Mẫu Chính
### Unit Test với xUnit + NSubstitute
```csharp
/// <summary>
/// EN: Unit test for command handler.
/// VI: Unit test cho command handler.
/// </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>>();
// EN: Create handler with mocked dependencies
// VI: Tạo handler với dependencies giả lập
_handler = new CreateOrderCommandHandler(_orderRepository, _logger);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrder()
{
// Arrange
var command = new CreateOrderCommand(
UserId: "user-123",
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"),
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();
await _orderRepository.Received(1).AddAsync(
Arg.Is<Order>(o => o.UserId == "user-123"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_EmptyItems_ThrowsDomainException()
{
// Arrange
var command = new CreateOrderCommand(
UserId: "user-123",
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "Country"),
Items: new List<OrderItemDto>());
// Act & Assert
await Assert.ThrowsAsync<DomainException>(() =>
_handler.Handle(command, CancellationToken.None));
await _orderRepository.DidNotReceive().AddAsync(
Arg.Any<Order>(),
Arg.Any<CancellationToken>());
}
}
```
### Domain Entity Tests / Test Domain Entity
```csharp
/// <summary>
/// EN: Tests for Order aggregate root.
/// VI: Tests cho Order aggregate root.
/// </summary>
public class OrderTests
{
[Fact]
public void AddItem_ValidItem_IncreasesTotalAmount()
{
// Arrange
var order = new Order("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);
}
[Fact]
public void AddItem_SameProduct_IncreasesQuantity()
{
// Arrange
var order = new Order("user-123", CreateAddress());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
order.AddItem(productId, quantity: 3, unitPrice: 10.00m);
// Assert
order.OrderItems.Should().HaveCount(1);
order.OrderItems.First().Quantity.Should().Be(5);
order.TotalAmount.Should().Be(50.00m);
}
[Fact]
public void Submit_EmptyOrder_ThrowsDomainException()
{
// Arrange
var order = new Order("user-123", CreateAddress());
// Act & Assert
var act = () => order.Submit();
act.Should().Throw<DomainException>()
.WithMessage("Cannot submit empty order");
}
private static Address CreateAddress() =>
new("123 Main St", "City", "State", "12345", "Country");
}
```
### Integration Tests với Testcontainers
```csharp
/// <summary>
/// EN: Database fixture using Testcontainers.
/// VI: Database fixture dùng Testcontainers.
/// </summary>
public class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.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();
}
}
[Collection("Database")]
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
private readonly OrderRepository _repository;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
_repository = new OrderRepository(_fixture.DbContext);
}
[Fact]
public async Task AddAsync_ValidOrder_PersistsToDatabase()
{
// Arrange
var order = new Order("user-123", new Address("St", "City", "State", "12345", "US"));
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
// Assert
var savedOrder = await _repository.GetWithItemsAsync(order.Id);
savedOrder.Should().NotBeNull();
savedOrder!.OrderItems.Should().HaveCount(1);
}
}
```
### Functional Tests với TestServer
```csharp
/// <summary>
/// EN: Web application factory for functional tests.
/// VI: Web application factory cho functional 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"));
});
}
}
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ValidRequest_Returns201()
{
// Arrange
var request = new
{
UserId = "user-123",
ShippingAddress = new { Street = "123 St", City = "City", State = "ST", PostalCode = "12345", Country = "US" },
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);
}
[Fact]
public async Task GetOrder_NotFound_Returns404()
{
// Act
var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Testing Implementation Details
```csharp
// ❌ BAD: Testing internal state
order.GetType().GetField("_status", BindingFlags.NonPublic)
.GetValue(order).Should().Be(OrderStatus.Draft);
// ✅ GOOD: Testing behavior
order.Status.Should().Be(OrderStatus.Draft);
order.Submit();
order.Status.Should().Be(OrderStatus.Submitted);
```
### 2. Not Using Async Assertions
```csharp
// ❌ BAD: Blocking call
var result = _handler.Handle(command, ct).Result;
// ✅ GOOD: Async assertion
var result = await _handler.Handle(command, ct);
```
### 3. Sharing State Between Tests
```csharp
// ❌ BAD: Static shared state
private static Order _order = new Order(...);
// ✅ GOOD: Fresh instance per test
private Order CreateOrder() => new Order("user-123", CreateAddress());
```
### 4. Ignoring CancellationToken
```csharp
// ❌ BAD: Ignoring cancellation
await _handler.Handle(command, CancellationToken.None);
// ✅ GOOD: Testing cancellation
var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
_handler.Handle(command, cts.Token));
```
## Quick Reference / Tham Chiếu Nhanh
### xUnit Attributes
| Attribute | Purpose |
|-----------|---------|
| `[Fact]` | Single test case |
| `[Theory]` | Parameterized test |
| `[InlineData]` | Inline parameters |
| `[MemberData]` | Complex parameters |
| `[Collection]` | Shared fixture |
| `[ClassData]` | External data source |
### NSubstitute Patterns
```csharp
// EN: Create substitute / VI: Tạo substitute
var service = Substitute.For<IOrderService>();
// EN: Setup return value / VI: Thiết lập giá trị trả về
service.GetByIdAsync(Arg.Any<Guid>()).Returns(order);
// EN: Verify call / VI: Xác minh gọi
await service.Received(1).GetByIdAsync(orderId);
// EN: Capture arguments / VI: Capture arguments
Order? capturedOrder = null;
await repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o));
```
### FluentAssertions
```csharp
result.Should().NotBeNull();
result.Should().BeEquivalentTo(expected);
orders.Should().HaveCount(5);
exception.Should().Throw<DomainException>().WithMessage("*empty*");
```
### Test Commands
```bash
# EN: Run all tests / VI: Chạy tất cả tests
dotnet test
# EN: Run specific project / VI: Chạy project cụ thể
dotnet test tests/Service.UnitTests
# EN: Run with coverage / VI: Chạy với coverage
dotnet test --collect:"XPlat Code Coverage"
# EN: Run specific test / VI: Chạy test cụ thể
dotnet test --filter "FullyQualifiedName~CreateOrder"
```
## Resources / Tài Nguyên
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
- [Repository Pattern](../repository-pattern/SKILL.md) - Repository testing
- [Error Handling](../error-handling-patterns/SKILL.md) - Error testing
- [API Design](../api-design/SKILL.md) - Controller testing
- [Project Rules](../project-rules/SKILL.md) - Coding standards

View File

@@ -0,0 +1,891 @@
# Testing Patterns - Detailed Reference
Detailed code examples for testing patterns in ASP.NET Core with xUnit and NSubstitute.
## Table of Contents
1. [Project Setup](#project-setup)
2. [Unit Testing MediatR Handlers](#unit-testing-mediatr-handlers)
3. [Domain Entity Testing](#domain-entity-testing)
4. [Controller Testing](#controller-testing)
5. [Integration Testing with Testcontainers](#integration-testing-with-testcontainers)
6. [Functional Testing with TestServer](#functional-testing-with-testserver)
7. [Testing Validation](#testing-validation)
8. [Test Data Builders](#test-data-builders)
---
## Project Setup
### Test Project Configuration
```xml
<!-- ServiceName.UnitTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
</ItemGroup>
</Project>
```
### Integration Test Project
```xml
<!-- ServiceName.IntegrationTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
<ProjectReference Include="..\..\src\ServiceName.Infrastructure\ServiceName.Infrastructure.csproj" />
</ItemGroup>
</Project>
```
---
## Unit Testing MediatR Handlers
### Command Handler Tests
```csharp
/// <summary>
/// EN: Tests for CreateOrderCommandHandler.
/// VI: Tests cho CreateOrderCommandHandler.
/// </summary>
public class CreateOrderCommandHandlerTests
{
private readonly IOrderRepository _orderRepository;
private readonly IUserService _userService;
private readonly ILogger<CreateOrderCommandHandler> _logger;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
_orderRepository = Substitute.For<IOrderRepository>();
_userService = Substitute.For<IUserService>();
_logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();
// EN: Setup default returns
// VI: Thiết lập giá trị trả về mặc định
_orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns(1);
_handler = new CreateOrderCommandHandler(
_orderRepository,
_userService,
_logger);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrderSuccessfully()
{
// Arrange
var command = CreateValidCommand();
Order? capturedOrder = null;
_orderRepository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>())
.Returns(callInfo => callInfo.Arg<Order>());
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.OrderId.Should().NotBeEmpty();
capturedOrder.Should().NotBeNull();
capturedOrder!.UserId.Should().Be(command.UserId);
capturedOrder.OrderItems.Should().HaveCount(command.Items.Count);
await _orderRepository.Received(1).AddAsync(
Arg.Any<Order>(),
Arg.Any<CancellationToken>());
await _orderRepository.UnitOfWork.Received(1)
.SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_EmptyItems_ThrowsDomainException()
{
// Arrange
var command = CreateValidCommand() with { Items = new List<OrderItemDto>() };
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<DomainException>()
.WithMessage("*empty*");
await _orderRepository.DidNotReceive().AddAsync(
Arg.Any<Order>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_RepositoryThrows_LogsAndRethrows()
{
// Arrange
var command = CreateValidCommand();
var expectedException = new InvalidOperationException("Database error");
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.ThrowsAsync(expectedException);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Database error");
}
private static CreateOrderCommand CreateValidCommand() =>
new(
UserId: "user-123",
ShippingAddress: new Address("123 Main St", "City", "State", "12345", "US"),
Items: new List<OrderItemDto>
{
new(Guid.NewGuid(), 2, 10.00m),
new(Guid.NewGuid(), 1, 25.00m)
});
}
```
### Query Handler Tests
```csharp
/// <summary>
/// EN: Tests for GetOrderQueryHandler.
/// VI: Tests cho GetOrderQueryHandler.
/// </summary>
public class GetOrderQueryHandlerTests
{
private readonly IOrderRepository _orderRepository;
private readonly GetOrderQueryHandler _handler;
public GetOrderQueryHandlerTests()
{
_orderRepository = Substitute.For<IOrderRepository>();
_handler = new GetOrderQueryHandler(_orderRepository);
}
[Fact]
public async Task Handle_OrderExists_ReturnsOrderDto()
{
// Arrange
var orderId = Guid.NewGuid();
var order = CreateOrder(orderId, "user-123");
_orderRepository.GetWithItemsAsync(orderId, Arg.Any<CancellationToken>())
.Returns(order);
var query = new GetOrderQuery(orderId, "user-123");
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(orderId);
result.UserId.Should().Be("user-123");
}
[Fact]
public async Task Handle_OrderNotFound_ReturnsNull()
{
// Arrange
var query = new GetOrderQuery(Guid.NewGuid(), "user-123");
_orderRepository.GetWithItemsAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((Order?)null);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task Handle_DifferentUser_ReturnsNull()
{
// Arrange
var orderId = Guid.NewGuid();
var order = CreateOrder(orderId, "user-123");
_orderRepository.GetWithItemsAsync(orderId, Arg.Any<CancellationToken>())
.Returns(order);
// EN: Query with different user
// VI: Query với user khác
var query = new GetOrderQuery(orderId, "different-user");
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeNull();
}
private static Order CreateOrder(Guid id, string userId)
{
var order = new Order(userId, new Address("St", "City", "ST", "12345", "US"));
// EN: Use reflection to set Id for testing
// VI: Dùng reflection để set Id cho testing
typeof(Order).GetProperty("Id")!.SetValue(order, id);
return order;
}
}
```
---
## Domain Entity Testing
### Aggregate Root Tests
```csharp
/// <summary>
/// EN: Tests for Order aggregate root behavior.
/// VI: Tests cho behavior của Order aggregate root.
/// </summary>
public class OrderTests
{
[Fact]
public void Constructor_ValidParameters_CreatesOrder()
{
// Act
var order = new Order("user-123", CreateAddress());
// Assert
order.Id.Should().NotBeEmpty();
order.UserId.Should().Be("user-123");
order.Status.Should().Be(OrderStatus.Draft);
order.TotalAmount.Should().Be(0);
order.OrderItems.Should().BeEmpty();
}
[Fact]
public void AddItem_NewProduct_AddsToItems()
{
// Arrange
var order = new Order("user-123", CreateAddress());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
// Assert
order.OrderItems.Should().HaveCount(1);
var item = order.OrderItems.First();
item.ProductId.Should().Be(productId);
item.Quantity.Should().Be(2);
item.UnitPrice.Should().Be(10.00m);
order.TotalAmount.Should().Be(20.00m);
}
[Fact]
public void AddItem_ExistingProduct_IncreasesQuantity()
{
// Arrange
var order = new Order("user-123", CreateAddress());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
order.AddItem(productId, quantity: 3, unitPrice: 10.00m);
// Assert
order.OrderItems.Should().HaveCount(1);
order.OrderItems.First().Quantity.Should().Be(5);
order.TotalAmount.Should().Be(50.00m);
}
[Fact]
public void AddItem_SubmittedOrder_ThrowsDomainException()
{
// Arrange
var order = new Order("user-123", CreateAddress());
order.AddItem(Guid.NewGuid(), 1, 10.00m);
order.Submit();
// Act
var act = () => order.AddItem(Guid.NewGuid(), 1, 10.00m);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("Cannot add items to non-draft order");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void AddItem_InvalidQuantity_ThrowsArgumentException(int quantity)
{
// Arrange
var order = new Order("user-123", CreateAddress());
// Act
var act = () => order.AddItem(Guid.NewGuid(), quantity, 10.00m);
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*positive*");
}
[Fact]
public void Submit_ValidOrder_ChangesStatus()
{
// Arrange
var order = new Order("user-123", CreateAddress());
order.AddItem(Guid.NewGuid(), 1, 10.00m);
// Act
order.Submit();
// Assert
order.Status.Should().Be(OrderStatus.Submitted);
}
[Fact]
public void Submit_EmptyOrder_ThrowsDomainException()
{
// Arrange
var order = new Order("user-123", CreateAddress());
// Act
var act = () => order.Submit();
// Assert
act.Should().Throw<DomainException>()
.WithMessage("Cannot submit empty order");
}
private static Address CreateAddress() =>
new("123 Main St", "City", "State", "12345", "Country");
}
```
---
## Controller Testing
### Controller Unit Tests
```csharp
/// <summary>
/// EN: Tests for OrdersController.
/// VI: Tests cho OrdersController.
/// </summary>
public class OrdersControllerTests
{
private readonly IMediator _mediator;
private readonly OrdersController _controller;
public OrdersControllerTests()
{
_mediator = Substitute.For<IMediator>();
_controller = new OrdersController(_mediator);
// EN: Setup mock user claims
// VI: Thiết lập user claims giả lập
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-123")
}, "TestAuth"));
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
}
[Fact]
public async Task CreateOrder_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new CreateOrderRequest(
new AddressDto("St", "City", "ST", "12345", "US"),
new[] { new OrderItemDto(Guid.NewGuid(), 2, 10.00m) });
var expectedResult = new OrderResult(Guid.NewGuid());
_mediator.Send(Arg.Any<CreateOrderCommand>(), Arg.Any<CancellationToken>())
.Returns(expectedResult);
// Act
var result = await _controller.CreateOrder(request, CancellationToken.None);
// Assert
var createdResult = result.Result.Should().BeOfType<CreatedAtActionResult>().Subject;
var response = createdResult.Value.Should().BeOfType<ApiResponse<OrderResult>>().Subject;
response.Success.Should().BeTrue();
response.Data.Should().Be(expectedResult);
}
[Fact]
public async Task GetOrder_NotFound_ReturnsNotFound()
{
// Arrange
var orderId = Guid.NewGuid();
_mediator.Send(Arg.Any<GetOrderQuery>(), Arg.Any<CancellationToken>())
.Returns((OrderDto?)null);
// Act
var result = await _controller.GetOrder(orderId, CancellationToken.None);
// Assert
result.Result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task GetOrder_Exists_ReturnsOk()
{
// Arrange
var orderId = Guid.NewGuid();
var orderDto = new OrderDto(orderId, "user-123", "Draft", 100m, DateTime.UtcNow);
_mediator.Send(Arg.Any<GetOrderQuery>(), Arg.Any<CancellationToken>())
.Returns(orderDto);
// Act
var result = await _controller.GetOrder(orderId, CancellationToken.None);
// Assert
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
var response = okResult.Value.Should().BeOfType<ApiResponse<OrderDto>>().Subject;
response.Success.Should().BeTrue();
response.Data!.Id.Should().Be(orderId);
}
}
```
---
## Integration Testing with Testcontainers
### Database Fixture
```csharp
/// <summary>
/// EN: Shared database fixture for integration tests.
/// VI: Fixture database dùng chung cho integration tests.
/// </summary>
public class DatabaseFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.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: Create fresh DbContext for each test.
/// VI: Tạo DbContext mới cho mỗi test.
/// </summary>
public ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(ConnectionString)
.Options;
return new ApplicationDbContext(options);
}
}
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
```
### Repository Integration Tests
```csharp
/// <summary>
/// EN: Integration tests for OrderRepository.
/// VI: Integration tests cho OrderRepository.
/// </summary>
[Collection("Database")]
public class OrderRepositoryIntegrationTests : IAsyncLifetime
{
private readonly DatabaseFixture _fixture;
private ApplicationDbContext _context = null!;
private OrderRepository _repository = null!;
public OrderRepositoryIntegrationTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
public Task InitializeAsync()
{
_context = _fixture.CreateContext();
_repository = new OrderRepository(_context);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
// EN: Clean up test data
// VI: Dọn dẹp dữ liệu test
_context.Orders.RemoveRange(_context.Orders);
await _context.SaveChangesAsync();
await _context.DisposeAsync();
}
[Fact]
public async Task AddAsync_ValidOrder_PersistsToDatabase()
{
// Arrange
var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
var addedOrder = await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
// Assert - verify with fresh context
using var verifyContext = _fixture.CreateContext();
var savedOrder = await verifyContext.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == addedOrder.Id);
savedOrder.Should().NotBeNull();
savedOrder!.UserId.Should().Be("user-123");
savedOrder.OrderItems.Should().HaveCount(1);
savedOrder.TotalAmount.Should().Be(20.00m);
}
[Fact]
public async Task GetWithItemsAsync_OrderExists_IncludesItems()
{
// Arrange
var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
order.AddItem(Guid.NewGuid(), 2, 10.00m);
order.AddItem(Guid.NewGuid(), 1, 25.00m);
await _repository.AddAsync(order);
await _repository.UnitOfWork.SaveChangesAsync();
// Act
using var queryContext = _fixture.CreateContext();
var queryRepo = new OrderRepository(queryContext);
var result = await queryRepo.GetWithItemsAsync(order.Id);
// Assert
result.Should().NotBeNull();
result!.OrderItems.Should().HaveCount(2);
result.TotalAmount.Should().Be(45.00m);
}
[Fact]
public async Task GetByUserIdAsync_HasOrders_ReturnsUserOrders()
{
// Arrange
var order1 = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
var order2 = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
var order3 = new Order("other-user", new Address("St", "City", "ST", "12345", "US"));
foreach (var o in new[] { order1, order2, order3 })
{
o.AddItem(Guid.NewGuid(), 1, 10.00m);
await _repository.AddAsync(o);
}
await _repository.UnitOfWork.SaveChangesAsync();
// Act
using var queryContext = _fixture.CreateContext();
var queryRepo = new OrderRepository(queryContext);
var result = await queryRepo.GetByUserIdAsync("user-123");
// Assert
result.Should().HaveCount(2);
result.Should().AllSatisfy(o => o.UserId.Should().Be("user-123"));
}
}
```
---
## Functional Testing with TestServer
### Custom Web Application Factory
```csharp
/// <summary>
/// EN: Custom factory for functional tests.
/// VI: Factory tùy chỉnh cho functional tests.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly PostgreSqlContainer _container;
public CustomWebApplicationFactory()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.Build();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
_container.StartAsync().GetAwaiter().GetResult();
builder.ConfigureServices(services =>
{
// EN: Remove existing DbContext registration
// VI: Xóa đăng ký DbContext hiện có
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// EN: Add test database
// VI: Thêm test database
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(_container.GetConnectionString()));
// EN: Ensure database is created and migrated
// VI: Đảm bảo database được tạo và migrate
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate();
});
}
protected override void Dispose(bool disposing)
{
_container.DisposeAsync().GetAwaiter().GetResult();
base.Dispose(disposing);
}
}
```
### API Functional Tests
```csharp
/// <summary>
/// EN: Functional tests for Orders API.
/// VI: Functional tests cho Orders API.
/// </summary>
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly CustomWebApplicationFactory _factory;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
// EN: Add test authentication
// VI: Thêm authentication test
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Test", "user-123");
}
[Fact]
public async Task CreateOrder_ValidRequest_Returns201WithOrderId()
{
// Arrange
var request = new
{
ShippingAddress = new
{
Street = "123 Test St",
City = "Test City",
State = "TS",
PostalCode = "12345",
Country = "US"
},
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);
var content = await response.Content.ReadFromJsonAsync<ApiResponse<OrderResult>>();
content.Should().NotBeNull();
content!.Success.Should().BeTrue();
content.Data!.OrderId.Should().NotBeEmpty();
}
[Fact]
public async Task GetOrder_NotExists_Returns404()
{
// Act
var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task CreateAndGetOrder_Workflow_ReturnsCreatedOrder()
{
// Arrange - Create order
var createRequest = new
{
ShippingAddress = new { Street = "St", City = "City", State = "ST", PostalCode = "12345", Country = "US" },
Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 50.00m } }
};
// Act - Create
var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", createRequest);
var createResult = await createResponse.Content.ReadFromJsonAsync<ApiResponse<OrderResult>>();
var orderId = createResult!.Data!.OrderId;
// Act - Get
var getResponse = await _client.GetAsync($"/api/v1/orders/{orderId}");
var getResult = await getResponse.Content.ReadFromJsonAsync<ApiResponse<OrderDto>>();
// Assert
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
getResult!.Data!.Id.Should().Be(orderId);
getResult.Data.TotalAmount.Should().Be(50.00m);
}
}
```
---
## Test Data Builders
### Builder Pattern for Test Data
```csharp
/// <summary>
/// EN: Builder for creating test orders.
/// VI: Builder để tạo test orders.
/// </summary>
public class OrderBuilder
{
private string _userId = "test-user";
private Address _address = new("123 Test St", "Test City", "TS", "12345", "US");
private readonly List<(Guid ProductId, int Quantity, decimal UnitPrice)> _items = new();
private OrderStatus? _status;
public OrderBuilder WithUserId(string userId)
{
_userId = userId;
return this;
}
public OrderBuilder WithAddress(Address address)
{
_address = address;
return this;
}
public OrderBuilder WithItem(Guid? productId = null, int quantity = 1, decimal unitPrice = 10.00m)
{
_items.Add((productId ?? Guid.NewGuid(), quantity, unitPrice));
return this;
}
public OrderBuilder AsSubmitted()
{
_status = OrderStatus.Submitted;
return this;
}
public Order Build()
{
var order = new Order(_userId, _address);
foreach (var (productId, quantity, unitPrice) in _items)
{
order.AddItem(productId, quantity, unitPrice);
}
if (_status == OrderStatus.Submitted && _items.Any())
{
order.Submit();
}
return order;
}
}
// EN: Usage / VI: Cách dùng
var order = new OrderBuilder()
.WithUserId("user-123")
.WithItem(quantity: 2, unitPrice: 15.00m)
.WithItem(quantity: 1, unitPrice: 25.00m)
.AsSubmitted()
.Build();
```
---
## Resources / Tài Nguyên
- [xUnit Documentation](https://xunit.net/docs/getting-started/netcore/cmdline)
- [NSubstitute Documentation](https://nsubstitute.github.io/)
- [FluentAssertions](https://fluentassertions.com/)
- [Testcontainers for .NET](https://testcontainers.com/guides/getting-started-with-testcontainers-for-dotnet/)
- [Microsoft: Integration Testing](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests)