# 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 net10.0 enable enable false true runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all ``` ### 1.3 Integration Test Project File ```xml net10.0 enable enable false true all ``` ### 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 /// /// EN: Comprehensive tests for Order aggregate root. /// VI: Kiểm thử toàn diện cho Order aggregate root. /// 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() .WithMessage("*user*required*"); } [Fact] public void Create_NullAddress_ThrowsDomainException() { // Act var act = () => Order.Create("user-123", null!); // Assert act.Should().Throw() .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(); } [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() .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() .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() .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() .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(); } [Fact] public void Submit_EmptyOrder_ThrowsDomainException() { // Arrange var order = CreateTestOrder(); // Act var act = () => order.Submit(); // Assert act.Should().Throw() .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() .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() .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 /// /// EN: Comprehensive tests for Money value object. /// VI: Kiểm thử toàn diện cho Money value object. /// 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() .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() .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() .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() .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 /// /// EN: Comprehensive tests for CreateOrderCommandHandler. /// VI: Kiểm thử toàn diện cho CreateOrderCommandHandler. /// namespace ServiceName.UnitTests.Handlers.Commands; public class CreateOrderCommandHandlerTests { private readonly IOrderRepository _orderRepository; private readonly ILogger _logger; private readonly IPublisher _publisher; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _orderRepository = Substitute.For(); _logger = Substitute.For>(); _publisher = Substitute.For(); // Setup UnitOfWork mock var unitOfWork = Substitute.For(); unitOfWork.SaveChangesAsync(Arg.Any()).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(), Arg.Any()) .Returns(callInfo => callInfo.Arg()); // 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(o => capturedOrder = o), Arg.Any()) .Returns(callInfo => callInfo.Arg()); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _orderRepository.Received(1).AddAsync(Arg.Any(), Arg.Any()); await _orderRepository.UnitOfWork.Received(1).SaveChangesAsync(Arg.Any()); 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(), Arg.Any()) .Returns(callInfo => callInfo.Arg()); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _publisher.Received().Publish( Arg.Is(e => e.ProductId == command.Items[0].ProductId), Arg.Any()); } [Fact] public async Task Handle_EmptyItems_ThrowsValidationException() { // Arrange var command = new CreateOrderCommand( UserId: "user-123", ShippingAddress: CreateAddressDto(), Items: new List()); // Act & Assert var act = async () => await _handler.Handle(command, CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*items*required*"); await _orderRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_NullUserId_ThrowsValidationException() { // Arrange var command = new CreateOrderCommand( UserId: null!, ShippingAddress: CreateAddressDto(), Items: new List { new(Guid.NewGuid(), 1, 10.00m) }); // Act & Assert var act = async () => await _handler.Handle(command, CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*user*required*"); } [Fact] public async Task Handle_RepositoryThrows_PropagatesException() { // Arrange var command = CreateValidCommand(); _orderRepository.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new DbUpdateException("Database error")); // Act & Assert var act = async () => await _handler.Handle(command, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_CancellationRequested_ThrowsOperationCanceledException() { // Arrange var command = CreateValidCommand(); var cts = new CancellationTokenSource(); cts.Cancel(); _orderRepository.AddAsync(Arg.Any(), Arg.Any()) .Returns(async callInfo => { var ct = callInfo.Arg(); ct.ThrowIfCancellationRequested(); return callInfo.Arg(); }); // Act & Assert var act = async () => await _handler.Handle(command, cts.Token); await act.Should().ThrowAsync(); } #region Helper Methods private static CreateOrderCommand CreateValidCommand() => new( UserId: "user-123", ShippingAddress: CreateAddressDto(), Items: new List { 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 /// /// EN: Complete integration test suite for Orders API. /// VI: Bộ integration test hoàn chỉnh cho Orders API. /// namespace ServiceName.IntegrationTests.Api; [Collection("Database")] public class OrdersApiIntegrationTests : IClassFixture { 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(); 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(); // Assert - Verify database persistence using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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() }; // Act var response = await _client.PostAsJsonAsync("/api/v1/orders", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var problem = await response.Content.ReadFromJsonAsync(); 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(); // Act var response = await _client.GetAsync($"/api/v1/orders/{created!.OrderId}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var order = await response.Content.ReadFromJsonAsync(); 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(); // Step 2: Verify initial status var getResponse = await _client.GetAsync($"/api/v1/orders/{created!.OrderId}"); var order = await getResponse.Content.ReadFromJsonAsync(); 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(); 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(); 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(); 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(); 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 /// /// EN: Fluent builder for creating test Order instances. /// VI: Fluent builder để tạo Order instances cho test. /// 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 /// /// EN: Custom FluentAssertions extensions. /// VI: Extensions FluentAssertions tùy chỉnh. /// namespace ServiceName.UnitTests.Extensions; public static class FluentAssertionsExtensions { public static AndConstraint ContainDomainEvent( 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(assertions); } public static AndConstraint HaveStatus( this ObjectAssertions assertions, OrderStatus expectedStatus) { var order = assertions.Subject as Order; order.Should().NotBeNull(); order!.Status.Should().Be(expectedStatus); return new AndConstraint(assertions); } } // Usage: order.Should().ContainDomainEvent(); order.Should().HaveStatus(OrderStatus.Submitted); ``` ### 5.2 Test Clock ```csharp /// /// EN: Fake clock for testing time-dependent logic. /// VI: Fake clock để test logic phụ thuộc thời gian. /// 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 false true true true cobertura ``` ### 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 ```