OrderFlow: Complete Test Suite — Unit, Integration, and Data Builders
Build a full test suite for OrderFlow: unit tests with NSubstitute, integration tests with Testcontainers (real PostgreSQL + Redis), WebApplicationFactory for API tests, and test 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.csStep 1: Unit Tests — Command Handlers
// 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>();
}
}// 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
// 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
// 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
// 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
// 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);
}
}// 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
// 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.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.