Migrate
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user