-
Notifications
You must be signed in to change notification settings - Fork 16
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.
Consider a pipeline with a validator (pre-handler) and a notifier (post-handler).
public class CommandIntegrationTests
{
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"]);
}Open generic handlers can be verified in integration tests the same way as any other pipeline handler. The key is to register the open generic alongside concrete handlers and assert that it participates in the pipeline.
public class OpenGenericHandlerIntegrationTests
{
[Fact]
public async Task OpenGenericPreHandler_ShouldExecuteForAllCommands()
{
// Arrange
var executionLog = new List<string>();
var services = new ServiceCollection();
services.AddSingleton(executionLog);
services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module =>
{
// Register the open generic
module.Register(typeof(LoggingPreHandler<>));
module.Register<CreateProductCommandHandler>();
module.Register<UpdateStockLevelCommandHandler>();
});
});
var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<ICommandMediator>();
// Act
await mediator.SendAsync(new CreateProductCommand { Name = "Test" });
await mediator.SendAsync(new UpdateStockLevelCommand { ProductId = Guid.NewGuid(), NewQuantity = 5 });
// Assert: open generic ran for both commands
executionLog.Should().Contain("PreHandle:CreateProductCommand");
executionLog.Should().Contain("PreHandle:UpdateStockLevelCommand");
}
}
// The open generic handler under test
public sealed class LoggingPreHandler<T> : ICommandPreHandler<T> where T : ICommand
{
private readonly List<string> _log;
public LoggingPreHandler(List<string> log) => _log = log;
public Task PreHandleAsync(T message, CancellationToken cancellationToken = default)
{
_log.Add($"PreHandle:{typeof(T).Name}");
return Task.CompletedTask;
}
}When using ASP.NET Core's WebApplicationFactory<TProgram> for end-to-end integration tests, LiteBus's internal MessageRegistry can cause intermittent failures (typically appearing on CI but not locally) with an error like:
System.InvalidOperationException: The required service of type 'MyApp.Application.SignUpUserCommandHandler' is not registered.
LiteBus stores handler registrations in a process-level singleton (MessageRegistry). When WebApplicationFactory calls ConfigureTestServices to override services, the registry is rebuilt for the new DI container. However, if a previous test run (in the same process) left stale entries in the registry, subsequent test factories can resolve the wrong handler types, leading to InvalidOperationException at dispatch time.
This race condition is more likely on CI agents where tests run in parallel or share a single process.
Call MessageRegistryAccessor.Instance.Clear() before (and optionally after) each test that uses a WebApplicationFactory. This leaves the registry in a clean state before LiteBus re-registers all handlers.
public class AppWebApplicationFactory : WebApplicationFactory<Program>, IDisposable
{
private SqliteConnection _connection = null!;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Clear any stale LiteBus handler registrations from previous tests
// running in the same process. Required for reliable CI behavior.
MessageRegistryAccessor.Instance.Clear();
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContextPool<AppDbContext>(options => options.UseSqlite(_connection));
services.AddLiteBus(liteBus =>
{
liteBus.AddCommandModule(module => module.Register<SignUpUserCommandHandler>());
});
EnsureDatabaseCreated(services);
});
}
public new void Dispose()
{
MessageRegistryAccessor.Instance.Clear(); // Clean up after the test
base.Dispose();
_connection.Dispose();
}
}Alternatively, inherit from LiteBusTestBase in your test class. LiteBusTestBase automatically clears the registry in its constructor and Dispose(), which covers the lifecycle of each test:
public class UserEndpointTests : LiteBusTestBase, IClassFixture<AppWebApplicationFactory>
{
private readonly AppWebApplicationFactory _factory;
public UserEndpointTests(AppWebApplicationFactory factory)
{
_factory = factory;
// LiteBusTestBase constructor already called MessageRegistryAccessor.Instance.Clear()
}
[Fact]
public async Task Post_SignUp_ShouldReturn201()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/users/sign-up", new { Email = "test@example.com" });
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}MessageRegistryAccessor is in the LiteBus.Messaging.Registry namespace:
using LiteBus.Messaging.Registry;LiteBus.PostgreSql.IntegrationTests uses Testcontainers to start postgres:16-alpine. Docker must be running before you execute the full solution test suite.
dotnet test LiteBus.slnxIf Docker is not available, those four tests are reported as skipped with this message:
PostgreSQL integration tests require Docker. Start Docker Desktop (or the Docker daemon) and run the tests again.
Start Docker Desktop on Windows or macOS, or confirm the Docker daemon is running on Linux, then rerun the tests. CI runs docker info before testing so agents fail fast when Docker is misconfigured.
To run unit tests only:
dotnet test LiteBus.slnx --filter "FullyQualifiedName!~PostgreSql"- 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.) so you test only the handler's logic.
- Integration Test Key Pipelines: Write a few integration tests for your most critical command/query pipelines to confirm 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) so tests stay fast and isolated.
-
Clear the Registry in
WebApplicationFactoryTests: If your integration tests useWebApplicationFactory, callMessageRegistryAccessor.Instance.Clear()in the factory setup and teardown to prevent stale handler registrations from causing failures on CI.