You can test your CQRS command handlers without a running database by using in-memory implementations of your repositories and event stores. This allows you to isolate your handler logic and verify its behavior under various scenarios without the overhead and complexity of setting up a full database environment.
Let’s see this in action. Imagine you have a simple CreateOrderCommand handler.
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
private readonly IOrderRepository _orderRepository;
private readonly IEventStore _eventStore;
public CreateOrderCommandHandler(IOrderRepository orderRepository, IEventStore eventStore)
{
_orderRepository = orderRepository;
_eventStore = eventStore;
}
public async Task HandleAsync(CreateOrderCommand command)
{
var order = new Order(command.OrderId, command.CustomerId);
await _orderRepository.SaveAsync(order);
await _eventStore.AppendAsync(order.DomainEvents);
}
}
To test this without a database, we’ll create simple in-memory versions of IOrderRepository and IEventStore.
// In-memory repository
public class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<Guid, Order> _orders = new Dictionary<Guid, Order>();
public Task SaveAsync(Order order)
{
_orders[order.Id] = order;
return Task.CompletedTask;
}
public Task<Order> GetByIdAsync(Guid id)
{
if (_orders.TryGetValue(id, out var order))
{
return Task.FromResult(order);
}
return Task.FromResult<Order>(null);
}
}
// In-memory event store
public class InMemoryEventStore : IEventStore
{
private readonly Dictionary<Guid, List<IDomainEvent>> _events = new Dictionary<Guid, List<IDomainEvent>>();
public Task AppendAsync(IEnumerable<IDomainEvent> domainEvents)
{
foreach (var ev in domainEvents)
{
if (!_events.ContainsKey(ev.AggregateId))
{
_events[ev.AggregateId] = new List<IDomainEvent>();
}
_events[ev.AggregateId].Add(ev);
}
return Task.CompletedTask;
}
public Task<IEnumerable<IDomainEvent>> GetEventsAsync(Guid aggregateId)
{
if (_events.TryGetValue(aggregateId, out var events))
{
return Task.FromResult(events.AsEnumerable());
}
return Task.FromResult(Enumerable.Empty<IDomainEvent>());
}
}
Now, in your unit test, you can instantiate these in-memory components and pass them to your command handler.
[Test]
public async Task CreateOrderCommandHandler_Should_SaveOrderAndAppendEvents()
{
// Arrange
var orderId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var command = new CreateOrderCommand { OrderId = orderId, CustomerId = customerId };
var orderRepository = new InMemoryOrderRepository();
var eventStore = new InMemoryEventStore();
var handler = new CreateOrderCommandHandler(orderRepository, eventStore);
// Act
await handler.HandleAsync(command);
// Assert
// Verify order was saved
var savedOrder = await orderRepository.GetByIdAsync(orderId);
Assert.IsNotNull(savedOrder);
Assert.AreEqual(orderId, savedOrder.Id);
Assert.AreEqual(customerId, savedOrder.CustomerId);
// Verify events were appended
var appendedEvents = await eventStore.GetEventsAsync(orderId);
Assert.AreEqual(1, appendedEvents.Count());
var orderCreatedEvent = appendedEvents.OfType<OrderCreatedEvent>().SingleOrDefault();
Assert.IsNotNull(orderCreatedEvent);
Assert.AreEqual(orderId, orderCreatedEvent.AggregateId);
Assert.AreEqual(customerId, orderCreatedEvent.CustomerId);
}
This approach allows you to test the core business logic of your command handlers in isolation. You’re not testing the database’s ability to store data or the event store’s persistence. You’re testing that your handler correctly orchestrates calls to these dependencies and produces the expected outcomes based on its internal logic and the commands it receives. The goal is to ensure that when given a specific command, the handler correctly interacts with its repositories and event stores to achieve the desired state change.
The power of this pattern lies in its ability to simulate various states and edge cases. For instance, you can easily test what happens if _orderRepository.SaveAsync throws an exception, or if the Order constructor itself fails, by mocking or stubbing those behaviors on your in-memory implementations. You can also pre-populate the InMemoryOrderRepository with existing orders to test scenarios like updating an order that already exists, or attempting to create an order with an ID that’s already in use, if your domain logic dictates such checks. This level of granular control over dependencies is practically impossible with a real database without extensive setup and teardown.
When dealing with more complex scenarios, like handlers that need to query existing data before acting, you can pre-populate your InMemoryOrderRepository with specific Order objects. This allows you to simulate scenarios where an order might already exist, or a customer might have certain properties, and verify how your handler reacts. For example, if your CreateOrderCommandHandler had logic to check if an order with the same ID already exists, you would add an order to the InMemoryOrderRepository before calling HandleAsync to test that specific branch of your code.
The next logical step is to explore testing your query handlers, which often involve different types of data retrieval and projection.