# 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 net8.0 enable enable false true ``` ### Integration Test Project ```xml net8.0 enable enable false true ``` --- ## Unit Testing MediatR Handlers ### Command Handler Tests ```csharp /// /// EN: Tests for CreateOrderCommandHandler. /// VI: Tests cho CreateOrderCommandHandler. /// public class CreateOrderCommandHandlerTests { private readonly IOrderRepository _orderRepository; private readonly IUserService _userService; private readonly ILogger _logger; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _orderRepository = Substitute.For(); _userService = Substitute.For(); _logger = Substitute.For>(); // EN: Setup default returns // VI: Thiết lập giá trị trả về mặc định _orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any()) .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(o => capturedOrder = o), Arg.Any()) .Returns(callInfo => callInfo.Arg()); // 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(), Arg.Any()); await _orderRepository.UnitOfWork.Received(1) .SaveChangesAsync(Arg.Any()); } [Fact] public async Task Handle_EmptyItems_ThrowsDomainException() { // Arrange var command = CreateValidCommand() with { Items = new List() }; // Act var act = () => _handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync() .WithMessage("*empty*"); await _orderRepository.DidNotReceive().AddAsync( Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_RepositoryThrows_LogsAndRethrows() { // Arrange var command = CreateValidCommand(); var expectedException = new InvalidOperationException("Database error"); _orderRepository.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(expectedException); // Act var act = () => _handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync() .WithMessage("Database error"); } private static CreateOrderCommand CreateValidCommand() => new( UserId: "user-123", ShippingAddress: new Address("123 Main St", "City", "State", "12345", "US"), Items: new List { new(Guid.NewGuid(), 2, 10.00m), new(Guid.NewGuid(), 1, 25.00m) }); } ``` ### Query Handler Tests ```csharp /// /// EN: Tests for GetOrderQueryHandler. /// VI: Tests cho GetOrderQueryHandler. /// public class GetOrderQueryHandlerTests { private readonly IOrderRepository _orderRepository; private readonly GetOrderQueryHandler _handler; public GetOrderQueryHandlerTests() { _orderRepository = Substitute.For(); _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()) .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(), Arg.Any()) .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()) .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 /// /// EN: Tests for Order aggregate root behavior. /// VI: Tests cho behavior của Order aggregate root. /// 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() .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() .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() .WithMessage("Cannot submit empty order"); } private static Address CreateAddress() => new("123 Main St", "City", "State", "12345", "Country"); } ``` --- ## Controller Testing ### Controller Unit Tests ```csharp /// /// EN: Tests for OrdersController. /// VI: Tests cho OrdersController. /// public class OrdersControllerTests { private readonly IMediator _mediator; private readonly OrdersController _controller; public OrdersControllerTests() { _mediator = Substitute.For(); _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(), Arg.Any()) .Returns(expectedResult); // Act var result = await _controller.CreateOrder(request, CancellationToken.None); // Assert var createdResult = result.Result.Should().BeOfType().Subject; var response = createdResult.Value.Should().BeOfType>().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(), Arg.Any()) .Returns((OrderDto?)null); // Act var result = await _controller.GetOrder(orderId, CancellationToken.None); // Assert result.Result.Should().BeOfType(); } [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(), Arg.Any()) .Returns(orderDto); // Act var result = await _controller.GetOrder(orderId, CancellationToken.None); // Assert var okResult = result.Result.Should().BeOfType().Subject; var response = okResult.Value.Should().BeOfType>().Subject; response.Success.Should().BeTrue(); response.Data!.Id.Should().Be(orderId); } } ``` --- ## Integration Testing with Testcontainers ### Database Fixture ```csharp /// /// EN: Shared database fixture for integration tests. /// VI: Fixture database dùng chung cho integration tests. /// 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() .UseNpgsql(ConnectionString) .Options; DbContext = new ApplicationDbContext(options); await DbContext.Database.MigrateAsync(); } public async Task DisposeAsync() { await DbContext.DisposeAsync(); await _container.DisposeAsync(); } /// /// EN: Create fresh DbContext for each test. /// VI: Tạo DbContext mới cho mỗi test. /// public ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseNpgsql(ConnectionString) .Options; return new ApplicationDbContext(options); } } [CollectionDefinition("Database")] public class DatabaseCollection : ICollectionFixture { } ``` ### Repository Integration Tests ```csharp /// /// EN: Integration tests for OrderRepository. /// VI: Integration tests cho OrderRepository. /// [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 /// /// EN: Custom factory for functional tests. /// VI: Factory tùy chỉnh cho functional tests. /// public class CustomWebApplicationFactory : WebApplicationFactory { 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)); if (descriptor != null) services.Remove(descriptor); // EN: Add test database // VI: Thêm test database services.AddDbContext(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(); db.Database.Migrate(); }); } protected override void Dispose(bool disposing) { _container.DisposeAsync().GetAwaiter().GetResult(); base.Dispose(disposing); } } ``` ### API Functional Tests ```csharp /// /// EN: Functional tests for Orders API. /// VI: Functional tests cho Orders API. /// public class OrdersApiTests : IClassFixture { 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>(); 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>(); var orderId = createResult!.Data!.OrderId; // Act - Get var getResponse = await _client.GetAsync($"/api/v1/orders/{orderId}"); var getResult = await getResponse.Content.ReadFromJsonAsync>(); // 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 /// /// EN: Builder for creating test orders. /// VI: Builder để tạo test orders. /// 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)