-
Notifications
You must be signed in to change notification settings - Fork 12
Testing LiteBus
Testing is a first-class concern in applications built with LiteBus. The library's design, which emphasizes separation of concerns and dependency injection, makes it highly testable at different levels. This guide covers strategies for unit testing handlers and integration testing the full mediation pipeline.
Handlers are simple classes with dependencies, making them easy to unit test in isolation. You do not need the LiteBus infrastructure to test a handler's logic.
Given a command handler that creates a product:
public sealed class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, Guid>
{
private readonly IProductRepository _repository;
public CreateProductCommandHandler(IProductRepository repository)
{
_repository = repository;
}
public async Task<Guid> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(command.Name))
{
throw new ValidationException("Product name is required.");
}
var product = new Product(command.Name);
await _repository.AddAsync(product, cancellationToken);
return product.Id;
}
}
You can test its logic using a mocking framework like Moq:
[Fact]
public async Task CreateProductCommandHandler_WithValidName_ShouldAddProductToRepository()
{
// Arrange
var mockRepository = new Mock<IProductRepository>();
var handler = new CreateProductCommandHandler(mockRepository.Object);
var command = new CreateProductCommand { Name = "Laptop" };
// Act
var productId = await handler.HandleAsync(command, CancellationToken.None);
// Assert
Assert.NotEqual(Guid.Empty, productId);
mockRepository.Verify(r => r.AddAsync(It.Is<Product>(p => p.Name == "Laptop"), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateProductCommandHandler_WithEmptyName_ShouldThrowValidationException()
{
// Arrange
var mockRepository = new Mock<IProductRepository>();
var handler = new CreateProductCommandHandler(mockRepository.Object);
var command = new CreateProductCommand { Name = "" };
// Act & Assert
await Assert.ThrowsAsync<ValidationException>(() => handler.HandleAsync(command, CancellationToken.None));
}
This same approach applies to pre-handlers, post-handlers, and error handlers.
To test the entire mediation pipeline, including pre-handlers, the main handler, and post-handlers, you can use an in-memory test setup.
The LiteBus.Testing
package provides a LiteBusTestBase
class that automatically clears the static MessageRegistry
between tests to ensure test isolation.
Consider a pipeline with a validator (pre-handler) and a notifier (post-handler).
public class CommandIntegrationTests : LiteBusTestBase // Inherit for test isolation
{
private readonly IServiceProvider _serviceProvider;
public CommandIntegrationTests()
{
var services = new ServiceCollection();
// Register mock dependencies
services.AddSingleton<IProductRepository, InMemoryProductRepository>();
services.AddSingleton(new Mock<IEventPublisher>().Object); // Mock for the post-handler
// Configure LiteBus with all relevant handlers
services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module =>
{
module.Register<CreateProductCommandValidator>(); // Pre-handler
module.Register<CreateProductCommandHandler>(); // Main handler
module.Register<ProductCreationNotifier>(); // Post-handler
});
});
_serviceProvider = services.BuildServiceProvider();
}
[Fact]
public async Task SendAsync_WithValidCommand_ShouldExecuteFullPipeline()
{
// Arrange
var commandMediator = _serviceProvider.GetRequiredService<ICommandMediator>();
var mockEventPublisher = _serviceProvider.GetRequiredService<Mock<IEventPublisher>>();
var command = new CreateProductCommand { Name = "Test Product" };
// Act
var productId = await commandMediator.SendAsync(command);
// Assert
// 1. Check the result from the main handler
Assert.NotEqual(Guid.Empty, productId);
// 2. Verify the repository was called (by the main handler)
var repository = _serviceProvider.GetRequiredService<IProductRepository>();
var product = await repository.GetByIdAsync(productId);
Assert.NotNull(product);
// 3. Verify the notifier was called (by the post-handler)
mockEventPublisher.Verify(p => p.PublishAsync(It.IsAny<ProductCreatedEvent>(), null, It.IsAny<CancellationToken>()), Times.Once);
}
}
When testing a class that depends on a mediator (e.g., a controller), you can mock the mediator interface to isolate the class from the LiteBus pipeline.
[Fact]
public async Task ProductsController_Create_ShouldReturnCreatedResult()
{
// Arrange
var mockMediator = new Mock<ICommandMediator>();
var command = new CreateProductCommand { Name = "New Gadget" };
var expectedProductId = Guid.NewGuid();
mockMediator
.Setup(m => m.SendAsync(command, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedProductId);
var controller = new ProductsController(mockMediator.Object);
// Act
var result = await controller.Create(command);
// Assert
var createdResult = Assert.IsType<CreatedAtActionResult>(result.Result);
Assert.Equal(expectedProductId, createdResult.RouteValues["id"]);
}
- Unit Test Handlers in Isolation: The majority of your business logic lives in handlers. Focus your testing efforts here with simple unit tests.
- Use Mocks for Dependencies: When unit testing a handler, mock its dependencies (repositories, services, etc.) to ensure you are only testing the handler's logic.
- Integration Test Key Pipelines: Write a few integration tests for your most critical command/query pipelines to ensure that pre-handlers, main handlers, and post-handlers work together correctly.
- Use an In-Memory Database: For integration tests involving repositories, use an in-memory database provider (like EF Core's InMemory provider or a real database in a test container) to ensure tests are fast and isolated.
-
Inherit from
LiteBusTestBase
: When writing tests that use the full LiteBus pipeline, inherit fromLiteBusTestBase
to prevent state from leaking between tests.