Skip to main content

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);
}