Skip to main content

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