Unit Tests
Unit tests verify individual services, validators, and event handlers in isolation. They use in-memory SQLite for database-backed tests and fake implementations for cross-module dependencies.
Service Tests
Service tests create a real DbContext with an in-memory SQLite connection and test the service directly. Here is the pattern from ProductServiceTests:
public sealed class ProductServiceTests : IDisposable
{
private readonly ProductsDbContext _db;
private readonly ProductService _sut;
public ProductServiceTests()
{
var options = new DbContextOptionsBuilder<ProductsDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
var dbOptions = Options.Create(
new DatabaseOptions
{
ModuleConnections = new Dictionary<string, string>
{
["Products"] = "Data Source=:memory:",
},
}
);
_db = new ProductsDbContext(options, dbOptions);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();
_sut = new ProductService(_db, NullLogger<ProductService>.Instance);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task CreateProductAsync_CreatesAndReturnsProduct()
{
var request = new CreateProductRequest { Name = "Test Widget", Price = 19.99m };
var product = await _sut.CreateProductAsync(request);
product.Should().NotBeNull();
product.Name.Should().Be("Test Widget");
product.Price.Should().Be(19.99m);
product.Id.Value.Should().BeGreaterThan(0);
}
[Fact]
public async Task UpdateProductAsync_WithNonExistentId_ThrowsNotFoundException()
{
var request = new UpdateProductRequest { Name = "Test", Price = 10.00m };
var act = () => _sut.UpdateProductAsync(ProductId.From(99999), request);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*Product*99999*not found*");
}
}Key Pattern
Each test class creates its own in-memory SQLite connection and DbContext. The connection is opened in the constructor and the database schema is created with EnsureCreated(). The IDisposable pattern ensures cleanup.
Validator Tests
Validators are pure functions that return a validation result. They are straightforward to test:
public class CreateRequestValidatorTests
{
[Fact]
public void Validate_WithValidRequest_ReturnsSuccess()
{
var request = new CreateProductRequest { Name = "Widget", Price = 9.99m };
var result = CreateRequestValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_WithEmptyName_ReturnsError()
{
var request = new CreateProductRequest { Name = "", Price = 9.99m };
var result = CreateRequestValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainKey("Name");
}
}Fake Data Generators
The SimpleModule.Tests.Shared project provides pre-built Bogus fakers for all module DTOs and request types in FakeDataGenerators:
public static class FakeDataGenerators
{
public static Faker<Product> ProductFaker { get; } =
new Faker<Product>()
.RuleFor(p => p.Id, f => ProductId.From(f.IndexFaker + 1))
.RuleFor(p => p.Name, f => f.Commerce.ProductName())
.RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000));
public static Faker<CreateProductRequest> CreateProductRequestFaker { get; } =
new Faker<CreateProductRequest>()
.RuleFor(r => r.Name, f => f.Commerce.ProductName())
.RuleFor(r => r.Price, f => f.Finance.Amount(1, 1000));
public static Faker<Order> OrderFaker { get; } =
new Faker<Order>()
.RuleFor(o => o.Id, f => OrderId.From(f.IndexFaker + 1))
.RuleFor(o => o.UserId, f => UserId.From(
f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)))
.RuleFor(o => o.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3)))
.RuleFor(o => o.Total, f => f.Finance.Amount(10, 500))
.RuleFor(o => o.CreatedAt, f => f.Date.Recent());
// ... fakers for all module DTOs and request types
}Use them in tests to generate realistic test data:
var products = FakeDataGenerators.ProductFaker.Generate(5);
var request = FakeDataGenerators.CreateProductRequestFaker.Generate();Fake Contract Implementations
For testing code that depends on other modules, the shared project provides fake implementations of contract interfaces. For example, FakeProductContracts implements IProductContracts with an in-memory list:
public class FakeProductContracts : IProductContracts
{
public List<Product> Products { get; set; } =
FakeDataGenerators.ProductFaker.Generate(3);
public Task<IEnumerable<Product>> GetAllProductsAsync() =>
Task.FromResult<IEnumerable<Product>>(Products);
public Task<Product?> GetProductByIdAsync(ProductId id) =>
Task.FromResult(Products.FirstOrDefault(p => p.Id == id));
public Task<Product> CreateProductAsync(CreateProductRequest request)
{
var product = new Product
{
Id = ProductId.From(_nextId++),
Name = request.Name,
Price = request.Price,
};
Products.Add(product);
return Task.FromResult(product);
}
// ... other CRUD methods
}These fakes are useful when a module under test depends on another module's contracts. Rather than spinning up the full dependency, inject the fake:
var fakeProducts = new FakeProductContracts();
var service = new OrderService(fakeProducts, db, logger);Testing Event Handlers
Event handlers are tested by invoking HandleAsync directly and asserting on side effects:
[Fact]
public async Task HandleAsync_LogsEvent()
{
var handler = new AuditLogEventHandler(logger);
var @event = new OrderCreatedEvent { OrderId = OrderId.From(1) };
await handler.HandleAsync(@event, CancellationToken.None);
// Assert on side effects (e.g., logger calls, database writes)
}For testing the EventBus itself, including partial failure semantics, see the EventBusPartialFailureTests in the core test project.
Next Steps
- Integration Tests -- test HTTP endpoints through the full pipeline
- E2E Tests -- browser-based testing with Playwright
- Event Bus -- understand event handler patterns and partial failure