Test Publisher
The Hermodr.TestPublisher package provides an in-memory publish channel (TestEventPublishChannel) that can be used in unit and integration tests to assert on published events without requiring a real messaging transport.
Installation
dotnet add package Hermodr.TestPublisher
Registration
Register a test channel using one of the AddTestChannel overloads on EventPublisherBuilder.
With a Func<CloudEvent, Task> callback
using CloudNative.CloudEvents;
using Hermodr;
using Microsoft.Extensions.DependencyInjection;
var publishedEvents = new List<CloudEvent>();
var services = new ServiceCollection();
services.AddEventPublisher()
.AddTestChannel(async @event =>
{
publishedEvents.Add(@event);
await Task.CompletedTask;
});
var provider = services.BuildServiceProvider();
With an Action<CloudEvent> callback (synchronous)
var publishedEvents = new List<CloudEvent>();
services.AddEventPublisher()
.AddTestChannel(@event => publishedEvents.Add(@event));
With a custom IEventPublishCallback
public class MyTestCallback : IEventPublishCallback
{
public List<CloudEvent> Events { get; } = new();
public Task OnEventPublishedAsync(CloudEvent @event)
{
Events.Add(@event);
return Task.CompletedTask;
}
}
var callback = new MyTestCallback();
services.AddEventPublisher()
.AddTestChannel(callback);
Usage in xUnit
using CloudNative.CloudEvents;
using Hermodr;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
public class OrderServiceTests
{
private readonly EventPublisher _publisher;
private readonly List<CloudEvent> _published = new();
public OrderServiceTests()
{
var services = new ServiceCollection();
services.AddEventPublisher()
.AddTestChannel(@event => _published.Add(@event));
services.AddTransient<OrderService>();
var provider = services.BuildServiceProvider();
_publisher = provider.GetRequiredService<EventPublisher>();
}
[Fact]
public async Task PlaceOrder_PublishesOrderPlacedEvent()
{
var service = new OrderService(_publisher);
await service.PlaceOrderAsync(Guid.NewGuid(), 99.95m, "USD");
Assert.Single(_published);
var @event = _published[0];
Assert.Equal("order.placed", @event.Type);
Assert.Equal("USD", ((OrderPlacedData)@event.Data!).Currency);
}
}
Named test channels
When the code under test publishes to a named channel, register the test channel with the same name by passing the optional channelName parameter:
var ordersEvents = new List<CloudEvent>();
var notificationEvents = new List<CloudEvent>();
services.AddEventPublisher()
.AddTestChannel(@ev => ordersEvents.Add(@ev), channelName: "rabbit-orders")
.AddTestChannel(@ev => notificationEvents.Add(@ev), channelName: "rabbit-notifications");
Each named test channel fires only for events whose per-call options carry the matching ChannelName. A test channel registered without a name (or with channelName: null) is treated as anonymous and receives every event, just like any unnamed production channel.
Typed test channels
Use AddTestChannel<TEvent>(...) when you want the callback to run only for one annotated event type:
var orders = new List<CloudEvent>();
services.AddEventPublisher()
.AddTestChannel<OrderPlacedData>(@event => orders.Add(@event));
Typed test channels behave like any other typed channel in the framework: when typed channels are registered for an event type, the publisher routes that event to the typed channels for that type.
IEventPublishCallback
public interface IEventPublishCallback
{
Task OnEventPublishedAsync(CloudEvent @event);
}
Implement this interface when you need richer callback logic (e.g. recording timestamps, simulating failures, asserting within the callback).
Combining with IEventSystemTime
Replace the system time to control event timestamps in tests:
services.AddEventPublisher()
.UseSystemTime<FrozenSystemTime>()
.AddTestChannel(@event => _published.Add(@event));
public class FrozenSystemTime : IEventSystemTime
{
public DateTimeOffset UtcNow { get; } = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
}