Learnixo

.NET & C# Development · Lesson 194 of 229

OrderFlow Part 6: Complete Test Suite — Unit, Integration & Data Builders

OrderFlow: Complete Test Suite — Unit, Integration, and Data Builders

This is part 6 of the OrderFlow series. The features are built. Now we test them properly: unit tests for business logic, integration tests with real infrastructure (PostgreSQL + Redis in Docker), and API-level tests through the full HTTP stack.

Starting point: OrderFlow Caching complete.


Test Project Structure

tests/
  OrderFlow.UnitTests/
    Orders/
      CreateOrderCommandHandlerTests.cs
      CancelOrderCommandHandlerTests.cs
    Auth/
      TokenServiceTests.cs
    Common/
      ValidationBehaviourTests.cs

  OrderFlow.IntegrationTests/
    Infrastructure/
      OrderFlowTestFactory.cs      ← WebApplicationFactory
      DatabaseFixture.cs           ← Testcontainers PostgreSQL + Redis
      TestDataBuilder.cs           ← fluent test data
    Orders/
      CreateOrderTests.cs          ← API-level tests
      GetOrdersTests.cs
    Auth/
      LoginTests.cs

Step 1: Unit Tests — Command Handlers

C#
// tests/OrderFlow.UnitTests/Orders/CreateOrderCommandHandlerTests.cs
public class CreateOrderCommandHandlerTests
{
    private readonly OrderFlowDbContext  _db;
    private readonly IProductRepository _products;
    private readonly CreateOrderCommandHandler _sut;

    public CreateOrderCommandHandlerTests()
    {
        // In-memory SQLite for unit tests — no Docker required
        var opts = new DbContextOptionsBuilder<OrderFlowDbContext>()
            .UseSqlite("DataSource=:memory:")
            .Options;

        _db = new OrderFlowDbContext(opts, Substitute.For<IPublisher>());
        _db.Database.OpenConnection();
        _db.Database.EnsureCreated();

        _products = Substitute.For<IProductRepository>();
        _sut      = new CreateOrderCommandHandler(
            _db, _products,
            Substitute.For<ILogger<CreateOrderCommandHandler>>());
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrder()
    {
        // Arrange
        _products.GetByIdsAsync([1, 2], Arg.Any<CancellationToken>())
            .Returns([
                new Product { Id = 1, Name = "Widget",  Price = 9.99m },
                new Product { Id = 2, Name = "Gadget",  Price = 24.99m },
            ]);

        var cmd = new CreateOrderCommand(
            CustomerId: 42,
            Lines: [new(1, 2), new(2, 1)]);

        // Act
        var result = await _sut.Handle(cmd, CancellationToken.None);

        // Assert
        result.OrderId.Should().BeGreaterThan(0);
        result.Total.Should().Be(9.99m * 2 + 24.99m);
        result.Status.Should().Be("Pending");

        var saved = await _db.Orders.FindAsync(result.OrderId);
        saved.Should().NotBeNull();
        saved!.Lines.Should().HaveCount(2);
    }

    [Fact]
    public async Task Handle_ProductNotFound_Throws()
    {
        _products.GetByIdsAsync([99], Arg.Any<CancellationToken>())
            .Returns([]);   // product doesn't exist

        var cmd = new CreateOrderCommand(42, [new(99, 1)]);

        await _sut.Invoking(s => s.Handle(cmd, CancellationToken.None))
            .Should().ThrowAsync<InvalidOperationException>();
    }
}
C#
// Validation behaviour unit test
public class ValidationBehaviourTests
{
    [Fact]
    public async Task Handle_InvalidCommand_ThrowsValidationException()
    {
        var validators = new List<IValidator<CreateOrderCommand>>
        {
            new CreateOrderCommandValidator()
        };

        var sut = new ValidationBehaviour<CreateOrderCommand, CreateOrderResult>(validators);

        var invalid = new CreateOrderCommand(
            CustomerId: 0,       // must be > 0
            Lines: []);          // must have at least one line

        await sut.Invoking(s => s.Handle(invalid, () => Task.FromResult<CreateOrderResult>(null!), default))
            .Should().ThrowAsync<ValidationException>()
            .Where(ex => ex.Errors.Any(e => e.PropertyName == "CustomerId")
                      && ex.Errors.Any(e => e.PropertyName == "Lines"));
    }
}

Step 2: Test Infrastructure — Testcontainers Fixture

C#
// tests/OrderFlow.IntegrationTests/Infrastructure/DatabaseFixture.cs
public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("orderflow_test")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    private readonly RedisContainer _redis = new RedisBuilder()
        .WithImage("redis:7-alpine")
        .Build();

    public string PostgresConnectionString => _postgres.GetConnectionString();
    public string RedisConnectionString    => _redis.GetConnectionString();

    public async Task InitializeAsync()
    {
        await Task.WhenAll(_postgres.StartAsync(), _redis.StartAsync());

        // Run migrations against the test database
        var opts = new DbContextOptionsBuilder<OrderFlowDbContext>()
            .UseNpgsql(PostgresConnectionString)
            .Options;
        await using var db = new OrderFlowDbContext(opts, Substitute.For<IPublisher>());
        await db.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
        => await Task.WhenAll(_postgres.DisposeAsync().AsTask(), _redis.DisposeAsync().AsTask());
}

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

Step 3: WebApplicationFactory — Full API Tests

C#
// tests/OrderFlow.IntegrationTests/Infrastructure/OrderFlowTestFactory.cs
public class OrderFlowTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly DatabaseFixture _db = new();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Replace real DbContext with test DB
            services.RemoveAll<DbContextOptions<OrderFlowDbContext>>();
            services.AddDbContext<OrderFlowDbContext>(opts =>
                opts.UseNpgsql(_db.PostgresConnectionString));

            // Replace real Redis with test Redis
            services.RemoveAll<IConnectionMultiplexer>();
            services.AddStackExchangeRedisCache(opts =>
                opts.Configuration = _db.RedisConnectionString);

            // Replace email service with a fake (no real emails during tests)
            services.RemoveAll<IEmailService>();
            services.AddSingleton<IEmailService, FakeEmailService>();
        });
    }

    // Convenience: get an authenticated HttpClient
    public async Task<HttpClient> CreateAuthenticatedClientAsync(
        string role = "Customer")
    {
        await using var scope = Services.CreateAsyncScope();
        var db     = scope.ServiceProvider.GetRequiredService<OrderFlowDbContext>();
        var tokens = scope.ServiceProvider.GetRequiredService<ITokenService>();

        var user = await new UserBuilder().WithRole(role).SaveAsync(db);
        var token = tokens.GenerateAccessToken(user);

        var client = CreateClient();
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);

        return client;
    }

    public async Task InitializeAsync() => await _db.InitializeAsync();
    public new async Task DisposeAsync() => await _db.DisposeAsync();
}

Step 4: Test Data Builders

C#
// tests/OrderFlow.IntegrationTests/Infrastructure/TestDataBuilder.cs

// Fluent builder — readable test data setup
public class OrderBuilder
{
    private int     _customerId = 1;
    private string  _status     = "Pending";
    private decimal _total      = 99.99m;
    private List<OrderLine> _lines = [new() { ProductName = "Widget", Quantity = 1, UnitPrice = 99.99m }];

    public OrderBuilder ForCustomer(int customerId) { _customerId = customerId; return this; }
    public OrderBuilder WithStatus(string status)   { _status = status; return this; }
    public OrderBuilder WithTotal(decimal total)    { _total = total; return this; }
    public OrderBuilder WithLines(List<OrderLine> lines) { _lines = lines; return this; }

    public Order Build() => new()
    {
        CustomerId = _customerId,
        Status     = _status,
        Total      = _total,
        Lines      = _lines,
        CreatedAt  = DateTime.UtcNow,
    };

    public async Task<Order> SaveAsync(OrderFlowDbContext db)
    {
        var order = Build();
        db.Orders.Add(order);
        await db.SaveChangesAsync();
        return order;
    }
}

public class UserBuilder
{
    private string _email    = $"test-{Guid.NewGuid():N}@example.com";
    private string _role     = "Customer";
    private string _password = "TestP@ssw0rd!";

    public UserBuilder WithRole(string role)     { _role = role; return this; }
    public UserBuilder WithEmail(string email)   { _email = email; return this; }

    public User Build() => new()
    {
        Email        = _email,
        PasswordHash = BCrypt.Net.BCrypt.HashPassword(_password),
        Role         = _role,
        CreatedAt    = DateTime.UtcNow,
        IsActive     = true,
    };

    public async Task<User> SaveAsync(OrderFlowDbContext db)
    {
        var user = Build();
        db.Users.Add(user);
        await db.SaveChangesAsync();
        return user;
    }
}

Step 5: API Integration Tests

C#
// tests/OrderFlow.IntegrationTests/Orders/CreateOrderTests.cs
[Collection("Database")]
public class CreateOrderTests : IClassFixture<OrderFlowTestFactory>
{
    private readonly OrderFlowTestFactory _factory;

    public CreateOrderTests(OrderFlowTestFactory factory)
        => _factory = factory;

    [Fact]
    public async Task POST_CreateOrder_Returns201_WithValidRequest()
    {
        // Arrange
        var client = await _factory.CreateAuthenticatedClientAsync("Customer");

        // Seed a product
        await using var scope = _factory.Services.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<OrderFlowDbContext>();
        var product = new Product { Name = "Widget", Price = 9.99m, Category = "Gadgets", IsActive = true };
        db.Products.Add(product);
        await db.SaveChangesAsync();

        var request = new { lines = new[] { new { productId = product.Id, quantity = 2 } } };

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

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();

        var body = await response.Content.ReadFromJsonAsync<CreateOrderResult>();
        body.Should().NotBeNull();
        body!.Total.Should().Be(9.99m * 2);
        body.Status.Should().Be("Pending");
    }

    [Fact]
    public async Task POST_CreateOrder_Returns401_WithoutToken()
    {
        var client   = _factory.CreateClient();
        var response = await client.PostAsJsonAsync("/api/orders",
            new { lines = Array.Empty<object>() });

        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task POST_CreateOrder_Returns422_WithEmptyLines()
    {
        var client   = await _factory.CreateAuthenticatedClientAsync();
        var response = await client.PostAsJsonAsync("/api/orders",
            new { customerId = 1, lines = Array.Empty<object>() });

        response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
    }
}
C#
// Auth flow integration test
public class LoginTests(OrderFlowTestFactory factory) : IClassFixture<OrderFlowTestFactory>
{
    [Fact]
    public async Task Login_ValidCredentials_ReturnsTokens()
    {
        // Arrange — register first
        var client = factory.CreateClient();
        var email  = $"user-{Guid.NewGuid():N}@example.com";

        await client.PostAsJsonAsync("/api/auth/register",
            new { email, password = "P@ssw0rd!" });

        // Act
        var response = await client.PostAsJsonAsync("/api/auth/login",
            new { email, password = "P@ssw0rd!" });

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var body = await response.Content.ReadFromJsonAsync<AuthResponse>();
        body!.AccessToken.Should().NotBeNullOrEmpty();
        body.RefreshToken.Should().NotBeNullOrEmpty();
        body.Role.Should().Be("Customer");
    }

    [Fact]
    public async Task Refresh_ValidToken_RotatesTokenPair()
    {
        var client = factory.CreateClient();
        var email  = $"user-{Guid.NewGuid():N}@example.com";

        await client.PostAsJsonAsync("/api/auth/register", new { email, password = "P@ssw0rd!" });
        var loginResp = await (await client.PostAsJsonAsync("/api/auth/login",
            new { email, password = "P@ssw0rd!" }))
            .Content.ReadFromJsonAsync<AuthResponse>();

        // Act
        var refreshResp = await client.PostAsJsonAsync("/api/auth/refresh",
            new { refreshToken = loginResp!.RefreshToken });

        // Assert
        refreshResp.StatusCode.Should().Be(HttpStatusCode.OK);

        var newTokens = await refreshResp.Content.ReadFromJsonAsync<AuthResponse>();
        newTokens!.RefreshToken.Should().NotBe(loginResp.RefreshToken);   // rotated
    }
}

Step 6: Isolating Tests With Transaction Rollback

C#
// For tests that need a clean DB state without dropping and recreating tables
public class TransactionalTestBase : IAsyncLifetime
{
    private readonly OrderFlowTestFactory _factory;
    private IDbContextTransaction? _transaction;
    protected OrderFlowDbContext Db = null!;

    public TransactionalTestBase(OrderFlowTestFactory factory)
        => _factory = factory;

    public async Task InitializeAsync()
    {
        var scope = _factory.Services.CreateAsyncScope();
        Db           = scope.ServiceProvider.GetRequiredService<OrderFlowDbContext>();
        _transaction = await Db.Database.BeginTransactionAsync();
    }

    public async Task DisposeAsync()
    {
        if (_transaction is not null)
            await _transaction.RollbackAsync();   // all test data gone — next test starts clean
    }
}

What's Next

Next: OrderFlow AI Features — add AI-powered capabilities: semantic product search with pgvector, AI-generated order summaries, and a customer support chatbot with full conversation history.