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

26 KiB

Testing Patterns - Detailed Reference

Detailed code examples for testing patterns in ASP.NET Core with xUnit and NSubstitute.

Table of Contents

  1. Project Setup
  2. Unit Testing MediatR Handlers
  3. Domain Entity Testing
  4. Controller Testing
  5. Integration Testing with Testcontainers
  6. Functional Testing with TestServer
  7. Testing Validation
  8. Test Data Builders

Project Setup

Test Project Configuration

<!-- ServiceName.UnitTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
    <PackageReference Include="NSubstitute" Version="5.1.0" />
    <PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16" />
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="coverlet.collector" Version="6.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
  </ItemGroup>
</Project>

Integration Test Project

<!-- ServiceName.IntegrationTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Testcontainers.PostgreSql" Version="3.6.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ServiceName.API\ServiceName.API.csproj" />
    <ProjectReference Include="..\..\src\ServiceName.Infrastructure\ServiceName.Infrastructure.csproj" />
  </ItemGroup>
</Project>

Unit Testing MediatR Handlers

Command Handler Tests

/// <summary>
/// EN: Tests for CreateOrderCommandHandler.
/// VI: Tests cho CreateOrderCommandHandler.
/// </summary>
public class CreateOrderCommandHandlerTests
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUserService _userService;
    private readonly ILogger<CreateOrderCommandHandler> _logger;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _orderRepository = Substitute.For<IOrderRepository>();
        _userService = Substitute.For<IUserService>();
        _logger = Substitute.For<ILogger<CreateOrderCommandHandler>>();

        // EN: Setup default returns
        // VI: Thiết lập giá trị trả về mặc định
        _orderRepository.UnitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
            .Returns(1);

        _handler = new CreateOrderCommandHandler(
            _orderRepository,
            _userService,
            _logger);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderSuccessfully()
    {
        // Arrange
        var command = CreateValidCommand();
        Order? capturedOrder = null;

        _orderRepository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), 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();

        capturedOrder.Should().NotBeNull();
        capturedOrder!.UserId.Should().Be(command.UserId);
        capturedOrder.OrderItems.Should().HaveCount(command.Items.Count);

        await _orderRepository.Received(1).AddAsync(
            Arg.Any<Order>(),
            Arg.Any<CancellationToken>());
        await _orderRepository.UnitOfWork.Received(1)
            .SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_EmptyItems_ThrowsDomainException()
    {
        // Arrange
        var command = CreateValidCommand() with { Items = new List<OrderItemDto>() };

        // Act
        var act = () => _handler.Handle(command, CancellationToken.None);

        // Assert
        await act.Should().ThrowAsync<DomainException>()
            .WithMessage("*empty*");

        await _orderRepository.DidNotReceive().AddAsync(
            Arg.Any<Order>(),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_RepositoryThrows_LogsAndRethrows()
    {
        // Arrange
        var command = CreateValidCommand();
        var expectedException = new InvalidOperationException("Database error");

        _orderRepository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(expectedException);

        // Act
        var act = () => _handler.Handle(command, CancellationToken.None);

        // Assert
        await act.Should().ThrowAsync<InvalidOperationException>()
            .WithMessage("Database error");
    }

    private static CreateOrderCommand CreateValidCommand() =>
        new(
            UserId: "user-123",
            ShippingAddress: new Address("123 Main St", "City", "State", "12345", "US"),
            Items: new List<OrderItemDto>
            {
                new(Guid.NewGuid(), 2, 10.00m),
                new(Guid.NewGuid(), 1, 25.00m)
            });
}

Query Handler Tests

/// <summary>
/// EN: Tests for GetOrderQueryHandler.
/// VI: Tests cho GetOrderQueryHandler.
/// </summary>
public class GetOrderQueryHandlerTests
{
    private readonly IOrderRepository _orderRepository;
    private readonly GetOrderQueryHandler _handler;

    public GetOrderQueryHandlerTests()
    {
        _orderRepository = Substitute.For<IOrderRepository>();
        _handler = new GetOrderQueryHandler(_orderRepository);
    }

    [Fact]
    public async Task Handle_OrderExists_ReturnsOrderDto()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var order = CreateOrder(orderId, "user-123");

        _orderRepository.GetWithItemsAsync(orderId, Arg.Any<CancellationToken>())
            .Returns(order);

        var query = new GetOrderQuery(orderId, "user-123");

        // Act
        var result = await _handler.Handle(query, CancellationToken.None);

        // Assert
        result.Should().NotBeNull();
        result!.Id.Should().Be(orderId);
        result.UserId.Should().Be("user-123");
    }

    [Fact]
    public async Task Handle_OrderNotFound_ReturnsNull()
    {
        // Arrange
        var query = new GetOrderQuery(Guid.NewGuid(), "user-123");

        _orderRepository.GetWithItemsAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
            .Returns((Order?)null);

        // Act
        var result = await _handler.Handle(query, CancellationToken.None);

        // Assert
        result.Should().BeNull();
    }

    [Fact]
    public async Task Handle_DifferentUser_ReturnsNull()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var order = CreateOrder(orderId, "user-123");

        _orderRepository.GetWithItemsAsync(orderId, Arg.Any<CancellationToken>())
            .Returns(order);

        // EN: Query with different user
        // VI: Query với user khác
        var query = new GetOrderQuery(orderId, "different-user");

        // Act
        var result = await _handler.Handle(query, CancellationToken.None);

        // Assert
        result.Should().BeNull();
    }

    private static Order CreateOrder(Guid id, string userId)
    {
        var order = new Order(userId, new Address("St", "City", "ST", "12345", "US"));
        // EN: Use reflection to set Id for testing
        // VI: Dùng reflection để set Id cho testing
        typeof(Order).GetProperty("Id")!.SetValue(order, id);
        return order;
    }
}

Domain Entity Testing

Aggregate Root Tests

/// <summary>
/// EN: Tests for Order aggregate root behavior.
/// VI: Tests cho behavior của Order aggregate root.
/// </summary>
public class OrderTests
{
    [Fact]
    public void Constructor_ValidParameters_CreatesOrder()
    {
        // Act
        var order = new Order("user-123", CreateAddress());

        // Assert
        order.Id.Should().NotBeEmpty();
        order.UserId.Should().Be("user-123");
        order.Status.Should().Be(OrderStatus.Draft);
        order.TotalAmount.Should().Be(0);
        order.OrderItems.Should().BeEmpty();
    }

    [Fact]
    public void AddItem_NewProduct_AddsToItems()
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());
        var productId = Guid.NewGuid();

        // Act
        order.AddItem(productId, quantity: 2, unitPrice: 10.00m);

        // Assert
        order.OrderItems.Should().HaveCount(1);
        var item = order.OrderItems.First();
        item.ProductId.Should().Be(productId);
        item.Quantity.Should().Be(2);
        item.UnitPrice.Should().Be(10.00m);
        order.TotalAmount.Should().Be(20.00m);
    }

    [Fact]
    public void AddItem_ExistingProduct_IncreasesQuantity()
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());
        var productId = Guid.NewGuid();

        // Act
        order.AddItem(productId, quantity: 2, unitPrice: 10.00m);
        order.AddItem(productId, quantity: 3, unitPrice: 10.00m);

        // Assert
        order.OrderItems.Should().HaveCount(1);
        order.OrderItems.First().Quantity.Should().Be(5);
        order.TotalAmount.Should().Be(50.00m);
    }

    [Fact]
    public void AddItem_SubmittedOrder_ThrowsDomainException()
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());
        order.AddItem(Guid.NewGuid(), 1, 10.00m);
        order.Submit();

        // Act
        var act = () => order.AddItem(Guid.NewGuid(), 1, 10.00m);

        // Assert
        act.Should().Throw<DomainException>()
            .WithMessage("Cannot add items to non-draft order");
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100)]
    public void AddItem_InvalidQuantity_ThrowsArgumentException(int quantity)
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());

        // Act
        var act = () => order.AddItem(Guid.NewGuid(), quantity, 10.00m);

        // Assert
        act.Should().Throw<ArgumentException>()
            .WithMessage("*positive*");
    }

    [Fact]
    public void Submit_ValidOrder_ChangesStatus()
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());
        order.AddItem(Guid.NewGuid(), 1, 10.00m);

        // Act
        order.Submit();

        // Assert
        order.Status.Should().Be(OrderStatus.Submitted);
    }

    [Fact]
    public void Submit_EmptyOrder_ThrowsDomainException()
    {
        // Arrange
        var order = new Order("user-123", CreateAddress());

        // Act
        var act = () => order.Submit();

        // Assert
        act.Should().Throw<DomainException>()
            .WithMessage("Cannot submit empty order");
    }

    private static Address CreateAddress() =>
        new("123 Main St", "City", "State", "12345", "Country");
}

Controller Testing

Controller Unit Tests

/// <summary>
/// EN: Tests for OrdersController.
/// VI: Tests cho OrdersController.
/// </summary>
public class OrdersControllerTests
{
    private readonly IMediator _mediator;
    private readonly OrdersController _controller;

    public OrdersControllerTests()
    {
        _mediator = Substitute.For<IMediator>();
        _controller = new OrdersController(_mediator);

        // EN: Setup mock user claims
        // VI: Thiết lập user claims giả lập
        var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "user-123")
        }, "TestAuth"));

        _controller.ControllerContext = new ControllerContext
        {
            HttpContext = new DefaultHttpContext { User = user }
        };
    }

    [Fact]
    public async Task CreateOrder_ValidRequest_ReturnsCreated()
    {
        // Arrange
        var request = new CreateOrderRequest(
            new AddressDto("St", "City", "ST", "12345", "US"),
            new[] { new OrderItemDto(Guid.NewGuid(), 2, 10.00m) });

        var expectedResult = new OrderResult(Guid.NewGuid());

        _mediator.Send(Arg.Any<CreateOrderCommand>(), Arg.Any<CancellationToken>())
            .Returns(expectedResult);

        // Act
        var result = await _controller.CreateOrder(request, CancellationToken.None);

        // Assert
        var createdResult = result.Result.Should().BeOfType<CreatedAtActionResult>().Subject;
        var response = createdResult.Value.Should().BeOfType<ApiResponse<OrderResult>>().Subject;
        response.Success.Should().BeTrue();
        response.Data.Should().Be(expectedResult);
    }

    [Fact]
    public async Task GetOrder_NotFound_ReturnsNotFound()
    {
        // Arrange
        var orderId = Guid.NewGuid();

        _mediator.Send(Arg.Any<GetOrderQuery>(), Arg.Any<CancellationToken>())
            .Returns((OrderDto?)null);

        // Act
        var result = await _controller.GetOrder(orderId, CancellationToken.None);

        // Assert
        result.Result.Should().BeOfType<NotFoundObjectResult>();
    }

    [Fact]
    public async Task GetOrder_Exists_ReturnsOk()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var orderDto = new OrderDto(orderId, "user-123", "Draft", 100m, DateTime.UtcNow);

        _mediator.Send(Arg.Any<GetOrderQuery>(), Arg.Any<CancellationToken>())
            .Returns(orderDto);

        // Act
        var result = await _controller.GetOrder(orderId, CancellationToken.None);

        // Assert
        var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
        var response = okResult.Value.Should().BeOfType<ApiResponse<OrderDto>>().Subject;
        response.Success.Should().BeTrue();
        response.Data!.Id.Should().Be(orderId);
    }
}

Integration Testing with Testcontainers

Database Fixture

/// <summary>
/// EN: Shared database fixture for integration tests.
/// VI: Fixture database dùng chung cho integration tests.
/// </summary>
public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    public string ConnectionString => _container.GetConnectionString();
    public ApplicationDbContext DbContext { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        await _container.StartAsync();

        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseNpgsql(ConnectionString)
            .Options;

        DbContext = new ApplicationDbContext(options);
        await DbContext.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await DbContext.DisposeAsync();
        await _container.DisposeAsync();
    }

    /// <summary>
    /// EN: Create fresh DbContext for each test.
    /// VI: Tạo DbContext mới cho mỗi test.
    /// </summary>
    public ApplicationDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseNpgsql(ConnectionString)
            .Options;
        return new ApplicationDbContext(options);
    }
}

[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

Repository Integration Tests

/// <summary>
/// EN: Integration tests for OrderRepository.
/// VI: Integration tests cho OrderRepository.
/// </summary>
[Collection("Database")]
public class OrderRepositoryIntegrationTests : IAsyncLifetime
{
    private readonly DatabaseFixture _fixture;
    private ApplicationDbContext _context = null!;
    private OrderRepository _repository = null!;

    public OrderRepositoryIntegrationTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    public Task InitializeAsync()
    {
        _context = _fixture.CreateContext();
        _repository = new OrderRepository(_context);
        return Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        // EN: Clean up test data
        // VI: Dọn dẹp dữ liệu test
        _context.Orders.RemoveRange(_context.Orders);
        await _context.SaveChangesAsync();
        await _context.DisposeAsync();
    }

    [Fact]
    public async Task AddAsync_ValidOrder_PersistsToDatabase()
    {
        // Arrange
        var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
        order.AddItem(Guid.NewGuid(), 2, 10.00m);

        // Act
        var addedOrder = await _repository.AddAsync(order);
        await _repository.UnitOfWork.SaveChangesAsync();

        // Assert - verify with fresh context
        using var verifyContext = _fixture.CreateContext();
        var savedOrder = await verifyContext.Orders
            .Include(o => o.OrderItems)
            .FirstOrDefaultAsync(o => o.Id == addedOrder.Id);

        savedOrder.Should().NotBeNull();
        savedOrder!.UserId.Should().Be("user-123");
        savedOrder.OrderItems.Should().HaveCount(1);
        savedOrder.TotalAmount.Should().Be(20.00m);
    }

    [Fact]
    public async Task GetWithItemsAsync_OrderExists_IncludesItems()
    {
        // Arrange
        var order = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
        order.AddItem(Guid.NewGuid(), 2, 10.00m);
        order.AddItem(Guid.NewGuid(), 1, 25.00m);

        await _repository.AddAsync(order);
        await _repository.UnitOfWork.SaveChangesAsync();

        // Act
        using var queryContext = _fixture.CreateContext();
        var queryRepo = new OrderRepository(queryContext);
        var result = await queryRepo.GetWithItemsAsync(order.Id);

        // Assert
        result.Should().NotBeNull();
        result!.OrderItems.Should().HaveCount(2);
        result.TotalAmount.Should().Be(45.00m);
    }

    [Fact]
    public async Task GetByUserIdAsync_HasOrders_ReturnsUserOrders()
    {
        // Arrange
        var order1 = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
        var order2 = new Order("user-123", new Address("St", "City", "ST", "12345", "US"));
        var order3 = new Order("other-user", new Address("St", "City", "ST", "12345", "US"));

        foreach (var o in new[] { order1, order2, order3 })
        {
            o.AddItem(Guid.NewGuid(), 1, 10.00m);
            await _repository.AddAsync(o);
        }
        await _repository.UnitOfWork.SaveChangesAsync();

        // Act
        using var queryContext = _fixture.CreateContext();
        var queryRepo = new OrderRepository(queryContext);
        var result = await queryRepo.GetByUserIdAsync("user-123");

        // Assert
        result.Should().HaveCount(2);
        result.Should().AllSatisfy(o => o.UserId.Should().Be("user-123"));
    }
}

Functional Testing with TestServer

Custom Web Application Factory

/// <summary>
/// EN: Custom factory for functional tests.
/// VI: Factory tùy chỉnh cho functional tests.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly PostgreSqlContainer _container;
    
    public CustomWebApplicationFactory()
    {
        _container = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .Build();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        _container.StartAsync().GetAwaiter().GetResult();

        builder.ConfigureServices(services =>
        {
            // EN: Remove existing DbContext registration
            // VI: Xóa đăng ký DbContext hiện có
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            // EN: Add test database
            // VI: Thêm test database
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseNpgsql(_container.GetConnectionString()));

            // EN: Ensure database is created and migrated
            // VI: Đảm bảo database được tạo và migrate
            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
            db.Database.Migrate();
        });
    }

    protected override void Dispose(bool disposing)
    {
        _container.DisposeAsync().GetAwaiter().GetResult();
        base.Dispose(disposing);
    }
}

API Functional Tests

/// <summary>
/// EN: Functional tests for Orders API.
/// VI: Functional tests cho Orders API.
/// </summary>
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory _factory;

    public OrdersApiTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();

        // EN: Add test authentication
        // VI: Thêm authentication test
        _client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Test", "user-123");
    }

    [Fact]
    public async Task CreateOrder_ValidRequest_Returns201WithOrderId()
    {
        // Arrange
        var request = new
        {
            ShippingAddress = new 
            { 
                Street = "123 Test St", 
                City = "Test City", 
                State = "TS", 
                PostalCode = "12345", 
                Country = "US" 
            },
            Items = new[]
            {
                new { ProductId = Guid.NewGuid(), Quantity = 2, UnitPrice = 10.00m }
            }
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/v1/orders", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        
        var content = await response.Content.ReadFromJsonAsync<ApiResponse<OrderResult>>();
        content.Should().NotBeNull();
        content!.Success.Should().BeTrue();
        content.Data!.OrderId.Should().NotBeEmpty();
    }

    [Fact]
    public async Task GetOrder_NotExists_Returns404()
    {
        // Act
        var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task CreateAndGetOrder_Workflow_ReturnsCreatedOrder()
    {
        // Arrange - Create order
        var createRequest = new
        {
            ShippingAddress = new { Street = "St", City = "City", State = "ST", PostalCode = "12345", Country = "US" },
            Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 1, UnitPrice = 50.00m } }
        };

        // Act - Create
        var createResponse = await _client.PostAsJsonAsync("/api/v1/orders", createRequest);
        var createResult = await createResponse.Content.ReadFromJsonAsync<ApiResponse<OrderResult>>();
        var orderId = createResult!.Data!.OrderId;

        // Act - Get
        var getResponse = await _client.GetAsync($"/api/v1/orders/{orderId}");
        var getResult = await getResponse.Content.ReadFromJsonAsync<ApiResponse<OrderDto>>();

        // Assert
        getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
        getResult!.Data!.Id.Should().Be(orderId);
        getResult.Data.TotalAmount.Should().Be(50.00m);
    }
}

Test Data Builders

Builder Pattern for Test Data

/// <summary>
/// EN: Builder for creating test orders.
/// VI: Builder để tạo test orders.
/// </summary>
public class OrderBuilder
{
    private string _userId = "test-user";
    private Address _address = new("123 Test St", "Test City", "TS", "12345", "US");
    private readonly List<(Guid ProductId, int Quantity, decimal UnitPrice)> _items = new();
    private OrderStatus? _status;

    public OrderBuilder WithUserId(string userId)
    {
        _userId = userId;
        return this;
    }

    public OrderBuilder WithAddress(Address address)
    {
        _address = address;
        return this;
    }

    public OrderBuilder WithItem(Guid? productId = null, int quantity = 1, decimal unitPrice = 10.00m)
    {
        _items.Add((productId ?? Guid.NewGuid(), quantity, unitPrice));
        return this;
    }

    public OrderBuilder AsSubmitted()
    {
        _status = OrderStatus.Submitted;
        return this;
    }

    public Order Build()
    {
        var order = new Order(_userId, _address);

        foreach (var (productId, quantity, unitPrice) in _items)
        {
            order.AddItem(productId, quantity, unitPrice);
        }

        if (_status == OrderStatus.Submitted && _items.Any())
        {
            order.Submit();
        }

        return order;
    }
}

// EN: Usage / VI: Cách dùng
var order = new OrderBuilder()
    .WithUserId("user-123")
    .WithItem(quantity: 2, unitPrice: 15.00m)
    .WithItem(quantity: 1, unitPrice: 25.00m)
    .AsSubmitted()
    .Build();

Resources / Tài Nguyên