Files
pos-system/microservices/.agent/skills/dotnet-senior-tester/references/REFERENCE.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

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