37 KiB
37 KiB
Full Code Reference / Tham Chiếu Code Đầy Đủ
Tài liệu này chứa các ví dụ code hoàn chỉnh, sẵn sàng sử dụng cho testing .NET Microservices.
1. Project Setup / Cài Đặt Project
1.1 Test Project Structure
tests/
├── ServiceName.UnitTests/
│ ├── ServiceName.UnitTests.csproj
│ ├── GlobalUsings.cs
│ ├── Handlers/
│ │ ├── Commands/
│ │ │ └── CreateOrderCommandHandlerTests.cs
│ │ └── Queries/
│ │ └── GetOrderQueryHandlerTests.cs
│ ├── Domain/
│ │ ├── Entities/
│ │ │ └── OrderTests.cs
│ │ └── ValueObjects/
│ │ └── MoneyTests.cs
│ └── Fixtures/
│ └── TestDataFixture.cs
├── ServiceName.IntegrationTests/
│ ├── ServiceName.IntegrationTests.csproj
│ ├── GlobalUsings.cs
│ ├── Fixtures/
│ │ ├── PostgresFixture.cs
│ │ ├── DatabaseCollection.cs
│ │ └── IntegrationTestFactory.cs
│ ├── Repositories/
│ │ └── OrderRepositoryTests.cs
│ └── Api/
│ └── OrdersApiTests.cs
└── ServiceName.FunctionalTests/
├── ServiceName.FunctionalTests.csproj
└── Scenarios/
└── OrderWorkflowTests.cs
1.2 Unit Test Project File
<!-- ServiceName.UnitTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- xUnit -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Mocking -->
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Assertions -->
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<!-- Coverage -->
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ServiceName.Application\ServiceName.Application.csproj" />
<ProjectReference Include="..\..\src\ServiceName.Domain\ServiceName.Domain.csproj" />
</ItemGroup>
</Project>
1.3 Integration Test Project File
<!-- ServiceName.IntegrationTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- xUnit -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- Assertions -->
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<!-- Integration Testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageReference Include="Testcontainers.MsSql" Version="4.1.0" />
<!-- WireMock for HTTP mocking -->
<PackageReference Include="WireMock.Net" Version="1.6.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ServiceName.Api\ServiceName.Api.csproj" />
<ProjectReference Include="..\..\src\ServiceName.Infrastructure\ServiceName.Infrastructure.csproj" />
</ItemGroup>
</Project>
1.4 Global Usings
// GlobalUsings.cs - Unit Tests
global using Xunit;
global using FluentAssertions;
global using NSubstitute;
global using NSubstitute.ExceptionExtensions;
// GlobalUsings.cs - Integration Tests
global using Xunit;
global using FluentAssertions;
global using NSubstitute;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.EntityFrameworkCore;
global using Testcontainers.PostgreSql;
global using System.Net;
global using System.Net.Http.Json;
2. Complete Test Examples / Ví Dụ Test Hoàn Chỉnh
2.1 Domain Entity Tests
/// <summary>
/// EN: Comprehensive tests for Order aggregate root.
/// VI: Kiểm thử toàn diện cho Order aggregate root.
/// </summary>
namespace ServiceName.UnitTests.Domain.Entities;
public class OrderTests
{
#region Creation Tests
[Fact]
public void Create_ValidParameters_ReturnsOrderWithDraftStatus()
{
// Arrange
var userId = "user-123";
var address = CreateTestAddress();
// Act
var order = Order.Create(userId, address);
// Assert
order.Should().NotBeNull();
order.Id.Should().NotBeEmpty();
order.UserId.Should().Be(userId);
order.Status.Should().Be(OrderStatus.Draft);
order.ShippingAddress.Should().Be(address);
order.OrderItems.Should().BeEmpty();
order.TotalAmount.Should().Be(0m);
order.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Create_InvalidUserId_ThrowsDomainException(string? userId)
{
// Arrange
var address = CreateTestAddress();
// Act
var act = () => Order.Create(userId!, address);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*user*required*");
}
[Fact]
public void Create_NullAddress_ThrowsDomainException()
{
// Act
var act = () => Order.Create("user-123", null!);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*address*required*");
}
#endregion
#region AddItem Tests
[Fact]
public void AddItem_ValidItem_AddsToOrderItems()
{
// Arrange
var order = CreateTestOrder();
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
// Assert
order.OrderItems.Should().HaveCount(1);
order.OrderItems.First().ProductId.Should().Be(productId);
order.OrderItems.First().Quantity.Should().Be(2);
order.OrderItems.First().UnitPrice.Should().Be(10.00m);
}
[Fact]
public void AddItem_ValidItem_UpdatesTotalAmount()
{
// Arrange
var order = CreateTestOrder();
// Act
order.AddItem(Guid.NewGuid(), quantity: 2, unitPrice: 10.00m);
order.AddItem(Guid.NewGuid(), quantity: 1, unitPrice: 25.00m);
// Assert
order.TotalAmount.Should().Be(45.00m);
}
[Fact]
public void AddItem_SameProduct_IncreasesQuantityInsteadOfDuplicate()
{
// Arrange
var order = CreateTestOrder();
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_RaisesDomainEvent()
{
// Arrange
var order = CreateTestOrder();
// Act
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Assert
order.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<OrderItemAddedEvent>();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void AddItem_InvalidQuantity_ThrowsDomainException(int quantity)
{
// Arrange
var order = CreateTestOrder();
// Act
var act = () => order.AddItem(Guid.NewGuid(), quantity, 10.00m);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*quantity*positive*");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-0.01)]
public void AddItem_InvalidUnitPrice_ThrowsDomainException(decimal unitPrice)
{
// Arrange
var order = CreateTestOrder();
// Act
var act = () => order.AddItem(Guid.NewGuid(), 1, unitPrice);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*price*positive*");
}
[Fact]
public void AddItem_SubmittedOrder_ThrowsDomainException()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 1, 10.00m);
order.Submit();
// Act
var act = () => order.AddItem(Guid.NewGuid(), 1, 20.00m);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*cannot modify*submitted*");
}
#endregion
#region RemoveItem Tests
[Fact]
public void RemoveItem_ExistingItem_RemovesFromOrder()
{
// Arrange
var order = CreateTestOrder();
var productId = Guid.NewGuid();
order.AddItem(productId, 2, 10.00m);
// Act
order.RemoveItem(productId);
// Assert
order.OrderItems.Should().BeEmpty();
order.TotalAmount.Should().Be(0m);
}
[Fact]
public void RemoveItem_NonExistingItem_ThrowsDomainException()
{
// Arrange
var order = CreateTestOrder();
// Act
var act = () => order.RemoveItem(Guid.NewGuid());
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*item*not found*");
}
#endregion
#region Submit Tests
[Fact]
public void Submit_ValidDraftOrder_ChangesStatusToSubmitted()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
order.Submit();
// Assert
order.Status.Should().Be(OrderStatus.Submitted);
order.SubmittedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Submit_RaisesOrderSubmittedEvent()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
order.ClearDomainEvents(); // Clear AddItem event
// Act
order.Submit();
// Assert
order.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<OrderSubmittedEvent>();
}
[Fact]
public void Submit_EmptyOrder_ThrowsDomainException()
{
// Arrange
var order = CreateTestOrder();
// Act
var act = () => order.Submit();
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*cannot submit*empty*");
}
[Fact]
public void Submit_AlreadySubmitted_ThrowsDomainException()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
order.Submit();
// Act
var act = () => order.Submit();
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*already submitted*");
}
#endregion
#region Cancel Tests
[Fact]
public void Cancel_DraftOrder_ChangesStatusToCancelled()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
// Act
order.Cancel("Changed my mind");
// Assert
order.Status.Should().Be(OrderStatus.Cancelled);
order.CancellationReason.Should().Be("Changed my mind");
}
[Fact]
public void Cancel_CompletedOrder_ThrowsDomainException()
{
// Arrange
var order = CreateTestOrder();
order.AddItem(Guid.NewGuid(), 2, 10.00m);
order.Submit();
order.Complete();
// Act
var act = () => order.Cancel("Too late");
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*cannot cancel*completed*");
}
#endregion
#region Helper Methods
private static Order CreateTestOrder() =>
Order.Create("user-123", CreateTestAddress());
private static Address CreateTestAddress() =>
Address.Create("123 Main St", "Ho Chi Minh City", "HCM", "70000", "VN");
#endregion
}
2.2 Value Object Tests
/// <summary>
/// EN: Comprehensive tests for Money value object.
/// VI: Kiểm thử toàn diện cho Money value object.
/// </summary>
namespace ServiceName.UnitTests.Domain.ValueObjects;
public class MoneyTests
{
#region Creation Tests
[Fact]
public void Create_ValidParameters_ReturnsMoney()
{
// Act
var money = Money.Create(100.50m, "VND");
// Assert
money.Amount.Should().Be(100.50m);
money.Currency.Should().Be("VND");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100.50)]
public void Create_NonPositiveAmount_ThrowsException(decimal amount)
{
// Act
var act = () => Money.Create(amount, "VND");
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*amount*positive*");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Create_InvalidCurrency_ThrowsException(string? currency)
{
// Act
var act = () => Money.Create(100, currency!);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*currency*required*");
}
#endregion
#region Arithmetic Tests
[Theory]
[InlineData(100, 50, 150)]
[InlineData(100.50, 25.25, 125.75)]
[InlineData(0.01, 0.01, 0.02)]
public void Add_SameCurrency_ReturnsSum(decimal a, decimal b, decimal expected)
{
// Arrange
var money1 = Money.Create(a, "VND");
var money2 = Money.Create(b, "VND");
// Act
var result = money1.Add(money2);
// Assert
result.Amount.Should().Be(expected);
result.Currency.Should().Be("VND");
}
[Fact]
public void Add_DifferentCurrency_ThrowsException()
{
// Arrange
var vnd = Money.Create(100, "VND");
var usd = Money.Create(10, "USD");
// Act
var act = () => vnd.Add(usd);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*currency mismatch*");
}
[Fact]
public void Subtract_SameCurrency_ReturnsDifference()
{
// Arrange
var money1 = Money.Create(100, "VND");
var money2 = Money.Create(30, "VND");
// Act
var result = money1.Subtract(money2);
// Assert
result.Amount.Should().Be(70);
}
[Fact]
public void Subtract_ResultNegative_ThrowsException()
{
// Arrange
var money1 = Money.Create(30, "VND");
var money2 = Money.Create(100, "VND");
// Act
var act = () => money1.Subtract(money2);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("*negative*");
}
[Theory]
[InlineData(100, 2, 200)]
[InlineData(50.25, 3, 150.75)]
public void Multiply_ValidMultiplier_ReturnsProduct(decimal amount, int multiplier, decimal expected)
{
// Arrange
var money = Money.Create(amount, "VND");
// Act
var result = money.Multiply(multiplier);
// Assert
result.Amount.Should().Be(expected);
}
#endregion
#region Equality Tests
[Fact]
public void Equals_SameAmountAndCurrency_ReturnsTrue()
{
// Arrange
var money1 = Money.Create(100, "VND");
var money2 = Money.Create(100, "VND");
// Assert
money1.Should().Be(money2);
(money1 == money2).Should().BeTrue();
money1.GetHashCode().Should().Be(money2.GetHashCode());
}
[Fact]
public void Equals_DifferentAmount_ReturnsFalse()
{
// Arrange
var money1 = Money.Create(100, "VND");
var money2 = Money.Create(200, "VND");
// Assert
money1.Should().NotBe(money2);
(money1 != money2).Should().BeTrue();
}
[Fact]
public void Equals_DifferentCurrency_ReturnsFalse()
{
// Arrange
var money1 = Money.Create(100, "VND");
var money2 = Money.Create(100, "USD");
// Assert
money1.Should().NotBe(money2);
}
#endregion
#region Immutability Tests
[Fact]
public void Add_ReturnsNewInstance_DoesNotModifyOriginal()
{
// Arrange
var original = Money.Create(100, "VND");
var toAdd = Money.Create(50, "VND");
// Act
var result = original.Add(toAdd);
// Assert
result.Should().NotBeSameAs(original);
original.Amount.Should().Be(100); // Unchanged
result.Amount.Should().Be(150);
}
#endregion
}
2.3 Command Handler Tests
/// <summary>
/// EN: Comprehensive tests for CreateOrderCommandHandler.
/// VI: Kiểm thử toàn diện cho CreateOrderCommandHandler.
/// </summary>
namespace ServiceName.UnitTests.Handlers.Commands;
public class CreateOrderCommandHandlerTests
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<CreateOrderCommandHandler> _logger;
private readonly IPublisher _publisher;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
_orderRepository = Substitute.For<IOrderRepository>();
_logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();
_publisher = Substitute.For<IPublisher>();
// Setup UnitOfWork mock
var unitOfWork = Substitute.For<IUnitOfWork>();
unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_orderRepository.UnitOfWork.Returns(unitOfWork);
_handler = new CreateOrderCommandHandler(
_orderRepository,
_publisher,
_logger);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrderAndReturnsResult()
{
// Arrange
var command = CreateValidCommand();
_orderRepository.AddAsync(Arg.Any<Order>(), 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();
result.Status.Should().Be("Draft");
}
[Fact]
public async Task Handle_ValidCommand_PersistsOrderToRepository()
{
// Arrange
var command = CreateValidCommand();
Order? capturedOrder = null;
_orderRepository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>())
.Returns(callInfo => callInfo.Arg<Order>());
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _orderRepository.Received(1).AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
await _orderRepository.UnitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
capturedOrder.Should().NotBeNull();
capturedOrder!.UserId.Should().Be(command.UserId);
capturedOrder.OrderItems.Should().HaveCount(command.Items.Count);
}
[Fact]
public async Task Handle_ValidCommand_PublishesDomainEvents()
{
// Arrange
var command = CreateValidCommand();
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(callInfo => callInfo.Arg<Order>());
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _publisher.Received().Publish(
Arg.Is<OrderItemAddedEvent>(e => e.ProductId == command.Items[0].ProductId),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_EmptyItems_ThrowsValidationException()
{
// Arrange
var command = new CreateOrderCommand(
UserId: "user-123",
ShippingAddress: CreateAddressDto(),
Items: new List<OrderItemDto>());
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>()
.WithMessage("*items*required*");
await _orderRepository.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_NullUserId_ThrowsValidationException()
{
// Arrange
var command = new CreateOrderCommand(
UserId: null!,
ShippingAddress: CreateAddressDto(),
Items: new List<OrderItemDto>
{
new(Guid.NewGuid(), 1, 10.00m)
});
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>()
.WithMessage("*user*required*");
}
[Fact]
public async Task Handle_RepositoryThrows_PropagatesException()
{
// Arrange
var command = CreateValidCommand();
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new DbUpdateException("Database error"));
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task Handle_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
var command = CreateValidCommand();
var cts = new CancellationTokenSource();
cts.Cancel();
_orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
var ct = callInfo.Arg<CancellationToken>();
ct.ThrowIfCancellationRequested();
return callInfo.Arg<Order>();
});
// Act & Assert
var act = async () => await _handler.Handle(command, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
#region Helper Methods
private static CreateOrderCommand CreateValidCommand() => new(
UserId: "user-123",
ShippingAddress: CreateAddressDto(),
Items: new List<OrderItemDto>
{
new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 10.00m),
new(ProductId: Guid.NewGuid(), Quantity: 1, UnitPrice: 25.00m)
});
private static AddressDto CreateAddressDto() => new(
Street: "123 Main St",
City: "Ho Chi Minh City",
State: "HCM",
PostalCode: "70000",
Country: "VN");
#endregion
}
3. Integration Test Examples
3.1 Complete Integration Test Suite
/// <summary>
/// EN: Complete integration test suite for Orders API.
/// VI: Bộ integration test hoàn chỉnh cho Orders API.
/// </summary>
namespace ServiceName.IntegrationTests.Api;
[Collection("Database")]
public class OrdersApiIntegrationTests : IClassFixture<IntegrationTestFactory>
{
private readonly HttpClient _client;
private readonly IntegrationTestFactory _factory;
public OrdersApiIntegrationTests(IntegrationTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
#region Create Order Tests
[Fact]
public async Task CreateOrder_ValidRequest_Returns201Created()
{
// Arrange
var request = CreateValidRequest();
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var content = await response.Content.ReadFromJsonAsync<OrderCreatedResponse>();
content!.OrderId.Should().NotBeEmpty();
}
[Fact]
public async Task CreateOrder_ValidRequest_PersistsToDatabase()
{
// Arrange
var request = CreateValidRequest();
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
var content = await response.Content.ReadFromJsonAsync<OrderCreatedResponse>();
// Assert - Verify database persistence
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var order = await db.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == content!.OrderId);
order.Should().NotBeNull();
order!.OrderItems.Should().HaveCount(request.Items.Length);
}
[Fact]
public async Task CreateOrder_EmptyItems_Returns400BadRequest()
{
// Arrange
var request = new
{
ShippingAddress = CreateAddressDto(),
Items = Array.Empty<object>()
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Items");
}
#endregion
#region Get Order Tests
[Fact]
public async Task GetOrder_ExistingOrder_Returns200OK()
{
// Arrange - Create order first
var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", CreateValidRequest());
var created = await createResponse.Content.ReadFromJsonAsync<OrderCreatedResponse>();
// Act
var response = await _client.GetAsync($"/api/v1/orders/{created!.OrderId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
order!.Id.Should().Be(created.OrderId);
order.Items.Should().NotBeEmpty();
}
[Fact]
public async Task GetOrder_NonExistingOrder_Returns404NotFound()
{
// Act
var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region Full Workflow Tests
[Fact]
public async Task OrderWorkflow_CreateToComplete_Success()
{
// Step 1: Create order
var createRequest = CreateValidRequest();
var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", createRequest);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<OrderCreatedResponse>();
// Step 2: Verify initial status
var getResponse = await _client.GetAsync($"/api/v1/orders/{created!.OrderId}");
var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
order!.Status.Should().Be("Draft");
// Step 3: Submit order
var submitResponse = await _client.PostAsync(
$"/api/v1/orders/{created.OrderId}/submit", null);
submitResponse.StatusCode.Should().Be(HttpStatusCode.OK);
// Step 4: Verify submitted status
getResponse = await _client.GetAsync($"/api/v1/orders/{created.OrderId}");
order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
order!.Status.Should().Be("Submitted");
// Step 5: Complete order
var completeResponse = await _client.PostAsync(
$"/api/v1/orders/{created.OrderId}/complete", null);
completeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
// Step 6: Verify completed status
getResponse = await _client.GetAsync($"/api/v1/orders/{created.OrderId}");
order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
order!.Status.Should().Be("Completed");
}
[Fact]
public async Task OrderWorkflow_CancelSubmittedOrder_Success()
{
// Arrange - Create and submit order
var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", CreateValidRequest());
var created = await createResponse.Content.ReadFromJsonAsync<OrderCreatedResponse>();
await _client.PostAsync($"/api/v1/orders/{created!.OrderId}/submit", null);
// Act
var cancelRequest = new { Reason = "Customer requested cancellation" };
var cancelResponse = await _client.PostAsJsonAsync(
$"/api/v1/orders/{created.OrderId}/cancel", cancelRequest);
// Assert
cancelResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var order = await (await _client.GetAsync($"/api/v1/orders/{created.OrderId}"))
.Content.ReadFromJsonAsync<OrderDto>();
order!.Status.Should().Be("Cancelled");
order.CancellationReason.Should().Be("Customer requested cancellation");
}
#endregion
#region Helper Methods
private static object CreateValidRequest() => new
{
ShippingAddress = CreateAddressDto(),
Items = new[]
{
new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m },
new { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 25.00m }
}
};
private static object CreateAddressDto() => new
{
Street = "123 Main St",
City = "Ho Chi Minh City",
State = "HCM",
PostalCode = "70000",
Country = "VN"
};
#endregion
}
4. Test Data Builders
4.1 Builder Pattern Implementation
/// <summary>
/// EN: Fluent builder for creating test Order instances.
/// VI: Fluent builder để tạo Order instances cho test.
/// </summary>
namespace ServiceName.UnitTests.Fixtures;
public class OrderBuilder
{
private string _userId = "default-user";
private Address _address = Address.Create("123 St", "City", "State", "12345", "VN");
private OrderStatus _status = OrderStatus.Draft;
private readonly List<(Guid ProductId, int Quantity, decimal UnitPrice)> _items = new();
public OrderBuilder WithUserId(string userId)
{
_userId = userId;
return this;
}
public OrderBuilder WithAddress(Address address)
{
_address = address;
return this;
}
public OrderBuilder WithAddress(string street, string city, string state, string postalCode, string country)
{
_address = Address.Create(street, city, state, postalCode, country);
return this;
}
public OrderBuilder WithItem(Guid productId, int quantity, decimal unitPrice)
{
_items.Add((productId, quantity, unitPrice));
return this;
}
public OrderBuilder WithRandomItem(int quantity = 1, decimal unitPrice = 10.00m)
{
_items.Add((Guid.NewGuid(), quantity, unitPrice));
return this;
}
public OrderBuilder AsSubmitted()
{
_status = OrderStatus.Submitted;
return this;
}
public OrderBuilder AsCancelled()
{
_status = OrderStatus.Cancelled;
return this;
}
public OrderBuilder AsCompleted()
{
_status = OrderStatus.Completed;
return this;
}
public Order Build()
{
var order = Order.Create(_userId, _address);
foreach (var item in _items)
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
// Apply status transitions
if (_status >= OrderStatus.Submitted && _items.Any())
order.Submit();
if (_status == OrderStatus.Completed)
order.Complete();
if (_status == OrderStatus.Cancelled)
order.Cancel("Test cancellation");
order.ClearDomainEvents(); // Clean up for tests
return order;
}
}
// Usage examples:
// Simple order
var order = new OrderBuilder().Build();
// Order with items
var orderWithItems = new OrderBuilder()
.WithUserId("user-123")
.WithRandomItem(quantity: 2, unitPrice: 10.00m)
.WithRandomItem(quantity: 1, unitPrice: 25.00m)
.Build();
// Submitted order
var submittedOrder = new OrderBuilder()
.WithRandomItem()
.AsSubmitted()
.Build();
5. Test Utilities
5.1 Custom Assertions
/// <summary>
/// EN: Custom FluentAssertions extensions.
/// VI: Extensions FluentAssertions tùy chỉnh.
/// </summary>
namespace ServiceName.UnitTests.Extensions;
public static class FluentAssertionsExtensions
{
public static AndConstraint<ObjectAssertions> ContainDomainEvent<TEvent>(
this ObjectAssertions assertions) where TEvent : IDomainEvent
{
var entity = assertions.Subject as Entity;
entity.Should().NotBeNull("entity should implement Entity base class");
entity!.DomainEvents.Should().Contain(e => e is TEvent,
$"entity should contain a {typeof(TEvent).Name} domain event");
return new AndConstraint<ObjectAssertions>(assertions);
}
public static AndConstraint<ObjectAssertions> HaveStatus(
this ObjectAssertions assertions, OrderStatus expectedStatus)
{
var order = assertions.Subject as Order;
order.Should().NotBeNull();
order!.Status.Should().Be(expectedStatus);
return new AndConstraint<ObjectAssertions>(assertions);
}
}
// Usage:
order.Should().ContainDomainEvent<OrderSubmittedEvent>();
order.Should().HaveStatus(OrderStatus.Submitted);
5.2 Test Clock
/// <summary>
/// EN: Fake clock for testing time-dependent logic.
/// VI: Fake clock để test logic phụ thuộc thời gian.
/// </summary>
namespace ServiceName.UnitTests.Fixtures;
public class FakeClock : IClock
{
public DateTime UtcNow { get; set; } = DateTime.UtcNow;
public FakeClock At(DateTime dateTime)
{
UtcNow = dateTime;
return this;
}
public FakeClock Advance(TimeSpan duration)
{
UtcNow = UtcNow.Add(duration);
return this;
}
}
// Usage in tests:
var clock = new FakeClock().At(new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc));
var handler = new ExpireOrdersCommandHandler(_repository, clock);
// Advance time
clock.Advance(TimeSpan.FromHours(25));
await handler.Handle(new ExpireOrdersCommand(), CancellationToken.None);
6. CI/CD Test Configuration
6.1 xunit.runner.json
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"methodDisplay": "classAndMethod",
"parallelizeTestCollections": true,
"maxParallelThreads": -1,
"shadowCopy": false
}
6.2 Test Settings for CI
<!-- Directory.Build.props at tests/ level -->
<Project>
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<PropertyGroup Condition="'$(CI)' == 'true'">
<!-- Fail on warnings in CI -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Collect code coverage -->
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
</PropertyGroup>
</Project>
6.3 GitHub Actions Workflow
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Run Unit Tests
run: dotnet test tests/ServiceName.UnitTests --no-build --verbosity normal
- name: Run Integration Tests
run: dotnet test tests/ServiceName.IntegrationTests --no-build --verbosity normal
env:
CI: true
- name: Upload Coverage
uses: codecov/codecov-action@v4
with:
files: '**/coverage.cobertura.xml'
fail_ci_if_error: true