Testing PearDrop Applications
Testing strategies for PearDrop's command/query architecture and domain-driven design patterns.
Testing Strategy
Test at three levels:
┌─────────────────────────────────────┐
│ Integration Tests │ Command/Query handlers,
│ (Real DB, real services) │ database interactions
└─────────────────────────────────────┘
▲
┌─────────────────────────────────────┐
│ Unit Tests │ Validators, domain methods
│ (Mocked dependencies) │ specifications
└─────────────────────────────────────┘
▲
┌─────────────────────────────────────┐
│ Domain Logic Tests │ Aggregates, value objects
│ (Pure functions, no dependencies) │ business rules
└─────────────────────────────────────┘
Unit Tests: Domain Aggregates
Test aggregate methods and business rules in isolation:
[Fact]
public void OrderAggregate_Ship_WithValidStatus_UpdatesStatusAndTracking()
{
// Arrange
var order = new OrderAggregate(
Guid.NewGuid(),
"ORD-001",
OrderStatus.Paid,
items: new[] { new OrderItem("SKU-123", 2) });
// Act
var result = order.Ship("UPS-12345678");
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(OrderStatus.Shipped, order.Status);
Assert.Equal("UPS-12345678", order.TrackingNumber);
}
[Fact]
public void OrderAggregate_Ship_WithUnpaidStatus_ReturnsFail()
{
// Arrange
var order = new OrderAggregate(
Guid.NewGuid(),
"ORD-001",
OrderStatus.Pending, // ← Not paid
items: Array.Empty<OrderItem>());
// Act
var result = order.Ship("UPS-12345678");
// Assert
Assert.True(result.IsFailure);
Assert.Equal("Cannot ship unpaid order", result.Error?.Message);
}
Best Practices:
✅ DO:
- Test business logic, not persistence
- Test both success and failure paths
- Use meaningful test names:
[Feature]_[Scenario]_[ExpectedResult] - Test boundary conditions and edge cases
- Keep tests focused and fast
❌ DON'T:
- Test framework code (EF Core, MediatR)
- Create brittle tests that break on unrelated changes
- Mock dependencies in domain tests (too many external calls = poor design)
- Test implementation details
✅ GOOD test name
public void Inventory_ReserveStock_WithInsufficientQuantity_ReturnsFailed()
❌ BAD test name
public void TestReserve()
public void ReserveSucceeds()
Unit Tests: Validators
Test FluentValidation rules independently:
[Fact]
public void CreateOrderCommandValidator_WithValidCommand_ReturnsNoErrors()
{
// Arrange
var validator = new CreateOrderCommandValidator();
var command = new CreateOrderCommand(
customerId: Guid.NewGuid(),
items: new[] { new OrderItemInput(Guid.NewGuid(), 5) });
// Act
var result = validator.Validate(command);
// Assert
Assert.True(result.IsValid);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void CreateOrderCommandValidator_WithEmptyCustomerId_ReturnsError(string customerId)
{
// Arrange
var validator = new CreateOrderCommandValidator();
var command = new CreateOrderCommand(
customerId: string.IsNullOrEmpty(customerId) ? null : Guid.Parse(customerId),
items: new[] { new OrderItemInput(Guid.NewGuid(), 5) });
// Act
var result = validator.Validate(command);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateOrderCommand.CustomerId));
}
[Fact]
public void CreateOrderCommandValidator_WithEmptyItems_ReturnsError()
{
// Arrange
var validator = new CreateOrderCommandValidator();
var command = new CreateOrderCommand(Guid.NewGuid(), items: Array.Empty<OrderItemInput>());
// Act
var result = validator.Validate(command);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName.Contains("Items"));
}
Integration Tests: Command Handlers
Test command handlers with real database:
public class CreateOrderCommandHandlerTests : IAsyncLifetime
{
private OrderDbContext dbContext;
private IRepositoryFactory<OrderAggregate> repositoryFactory;
private CreateOrderCommandHandler handler;
public async Task InitializeAsync()
{
// Set up real in-memory database
var options = new DbContextOptionsBuilder<OrderDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
this.dbContext = new OrderDbContext(options);
await this.dbContext.Database.EnsureCreatedAsync();
this.repositoryFactory = new OrderRepositoryFactory(
() => this.dbContext);
this.handler = new CreateOrderCommandHandler(
validators: new[] { new CreateOrderCommandValidator() },
logger: new NullLogger<CreateOrderCommandHandler>(),
commandStore: new CommandStore(new NullLogger<CommandStore>()),
repositoryFactory: this.repositoryFactory);
}
public async Task DisposeAsync()
{
await this.dbContext.Database.EnsureDeletedAsync();
this.dbContext.Dispose();
}
[Fact]
public async Task Handle_WithValidCommand_CreatesOrderAndReturnsId()
{
// Arrange
var customerId = Guid.NewGuid();
var items = new[] { new OrderItemInput(Guid.NewGuid(), 5) };
var command = new CreateOrderCommand(customerId, items);
// Act
var result = await this.handler.Handle(command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.NotEqual(Guid.Empty, result.Value.OrderId);
// Verify persisted in database
var savedOrder = await this.dbContext.Orders
.FirstOrDefaultAsync(o => o.Id == result.Value.OrderId);
Assert.NotNull(savedOrder);
Assert.Equal(customerId, savedOrder.CustomerId);
Assert.Single(savedOrder.Items);
}
[Fact]
public async Task Handle_WithInvalidCommand_ReturnsFailed()
{
// Arrange - Missing required items
var command = new CreateOrderCommand(Guid.NewGuid(), items: Array.Empty<OrderItemInput>());
// Act
var result = await this.handler.Handle(command, CancellationToken.None);
// Assert
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
}
Best Practices:
✅ DO:
- Use real database (in-memory for tests, actual SQL Server in CI/CD)
- Test against real DbContext
- Clean up database after each test (
InitializeAsync/DisposeAsync) - Test full round-trip: command → handler → database
- Test error cases and validation failures
❌ DON'T:
- Mock the repository (defeats purpose of integration test)
- Test handler logic you didn't write (framework code)
- Forget to verify database state
- Use shared test databases (isolation issues)
Integration Tests: Query Handlers
Test with read models:
public class GetOrdersQueryHandlerTests : IAsyncLifetime
{
private OrderReadDbContext readDbContext;
private GetOrdersQueryHandler handler;
public async Task InitializeAsync()
{
var options = new DbContextOptionsBuilder<OrderReadDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
this.readDbContext = new OrderReadDbContext(options);
await this.readDbContext.Database.EnsureCreatedAsync();
// Seed test data
await this.readDbContext.Orders.AddRangeAsync(
new OrderProjection { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid(), Status = "Pending" },
new OrderProjection { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid(), Status = "Shipped" },
new OrderProjection { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid(), Status = "Delivered" });
await this.readDbContext.SaveChangesAsync();
this.handler = new GetOrdersQueryHandler(
readModels: new OrderReadModels(this.readDbContext),
logger: new NullLogger<GetOrdersQueryHandler>());
}
public async Task DisposeAsync()
{
await this.readDbContext.Database.EnsureDeletedAsync();
this.readDbContext.Dispose();
}
[Fact]
public async Task Handle_ReturnsAllPendingOrders()
{
// Arrange
var query = new GetOrdersByStatusQuery("Pending");
// Act
var result = await this.handler.Handle(query, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Single(result.Value.Orders); // Only 1 pending order
}
[Fact]
public async Task Handle_WithPagination_ReturnsPagedResults()
{
// Arrange
var query = new GetOrdersQuery(pageNumber: 1, pageSize: 2);
// Act
var result = await this.handler.Handle(query, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Orders.Count); // Page size = 2
Assert.Equal(3, result.Value.Total); // 3 total orders
}
}
Testing Error Paths
Always test failure scenarios:
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateNoteCommand_WithBlankTitle_ReturnsFailed(string title)
{
// Arrange
var command = new CreateNoteCommand(title: title, content: "Valid content");
var handler = new CreateNoteCommandHandler(/*...*/);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
Assert.True(result.IsFailure);
Assert.Contains("Title", result.Error?.Message);
}
[Fact]
public async Task UpdateNoteCommand_WithNonexistentNote_ReturnsFailed()
{
// Arrange
var command = new UpdateNoteCommand(
noteId: Guid.NewGuid(),
title: "Updated",
content: "Updated content");
var handler = new UpdateNoteCommandHandler(/*...*/);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
Assert.True(result.IsFailure);
Assert.Equal(ErrorCodes.NotFound, result.Error?.Code);
}
Avoid Testing Framework Code
Don't test infrastructure—test your business logic:
// ❌ DON'T test EF Core
[Fact]
public async Task DbContext_SaveChanges_PersistsData()
{
var options = new DbContextOptionsBuilder<MyContext>()
.UseInMemoryDatabase("test")
.Options;
var context = new MyContext(options);
context.Notes.Add(new Note { Title = "Test" });
await context.SaveChangesAsync();
Assert.Single(context.Notes); // This tests EF Core, not your code
}
// ✅ DO test your domain logic
[Fact]
public void Note_Create_WithValidTitle_ReturnsNewNote()
{
var note = NoteAggregate.Create("My Note", "Content", Guid.NewGuid());
Assert.NotEqual(Guid.Empty, note.Id);
Assert.Equal("My Note", note.Title);
}
See Also
- Command Validators & Authorizers - Validation testing
- Aggregates - Domain logic testing
- Complete CRUD Feature - Full example with tests