1349 lines
37 KiB
Markdown
1349 lines
37 KiB
Markdown
# 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
|
|
|
|
```xml
|
|
<!-- 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
|
|
|
|
```xml
|
|
<!-- 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
|
|
|
|
```csharp
|
|
// GlobalUsings.cs - Unit Tests
|
|
global using Xunit;
|
|
global using FluentAssertions;
|
|
global using NSubstitute;
|
|
global using NSubstitute.ExceptionExtensions;
|
|
```
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```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
|
|
|
|
```xml
|
|
<!-- 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
|
|
|
|
```yaml
|
|
# .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
|
|
```
|