# 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)