Skip to main content

Testing

Relayer.Testing supplies RelayerHarness, TestMessageContext and the transport conformance suite. The harness is independent of the test framework. Relayer tests it with parallel TUnit tests.

Observe a host​

Register the harness after AddRelayer and all transport registrations. Configure real message identities, generated handlers and routes as usual:

using Microsoft.Extensions.DependencyInjection;
using Relayer;
using Relayer.InMemory;
using Relayer.Testing;

services.AddRelayer(builder =>
{
builder.AddMessageTypesFromThisAssembly();
builder.InMemory("tests", transport =>
{
transport.Send<OrderPlaced>("orders");
transport.Consume("orders", consumer => consumer.Handler<OrderHandler>());
});
});
services.AddRelayerHarness();

Start the host before sending. Resolve the scoped ISender from a scope and the singleton harness from the host:

var harness = host.Services.GetRequiredService<RelayerHarness>();
using var scope = host.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<ISender>()
.SendAsync(new OrderPlaced("A-1001"));

using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await harness.WaitForIdleAsync(deadline.Token);

var logicalSend = harness.Sent[0];
var received = harness.Consumed[0];
var serializer = host.Services.GetRequiredService<Relayer.Serialization.IMessageSerializer>();
var order = received.Read<OrderPlaced>(serializer);

The collections return snapshots safe to enumerate while other work runs:

CollectionRecords
SentSuccessful logical sends, including sends enlisted in an outbox
DispatchedSuccessful native send calls, including outbox dispatch through the registered ITransport services
ConsumedDeliveries entering the consume pipeline, including failed deliveries and native redeliveries
FailedExceptions at logical send, native dispatch and consume boundaries, identified by HarnessOperation
DeadLetteredSuccessful native dead-letter operations, including manual in-memory native settlement

Publish/subscribe observation uses the same ISender.SendAsync API. A send routed to an in-memory log appears once in Sent and once per consumer group in Consumed. The harness does not change routing, settlement, ordering or native batching. If a native batch fails after partial delivery, Dispatched does not claim that the batch succeeded; Failed records the failed batch operation for each input.

Consume failures remain observable when native settlement already logged the exception; logging configuration does not change Failed. A traced settlement failure is recorded once per consume boundary. Dispatch observation reads each route's configured envelope header names, including routes that share a destination with different bindings.

Receive and dispatch bodies are copied before native storage can be reused. HarnessMessage.Read<T>(serializer) deserializes those copies with your configured serializer. Logical sends retain the original message object in Message; do not mutate that object during assertions. Wire IDs and types can be null for malformed or raw native messages. Transport and destination names on receive observations are correlated with sends in the same provider; unsolicited external deliveries may not have those names.

Deterministic idle and reset​

WaitForIdleAsync tracks active sends, dispatches and consumption. With the registered in-memory transport, it also tracks queued, scheduled and redelivered messages until every consumer group releases them. It uses completion signals, not sleeps or polling. An endpoint with no consumer retains its queued messages and therefore does not become idle.

Start producers before awaiting idle. Future external broker arrivals and durable outbox rows not yet being dispatched cannot be inferred from an idle pipeline. Include an application-owned producer or dispatch cycle explicitly. In this example, CommitAndDispatchAsync is your application's method:

var cycle = harness.TrackWork();
try
{
await application.CommitAndDispatchAsync();
}
finally
{
cycle.Dispose();
}
await harness.WaitForIdleAsync(deadline.Token);

Keep the lease from before the producer or outbox commit until dispatch finishes or the transaction rolls back. The native dispatch must use the registered ITransport services for observation. Transactional outbox integrations and dispatch workers remain planned; the current enlistment and native dispatch seams are both observable. The harness does not provide an outbox implementation.

Cancelling an idle wait cancels only that caller's wait. It does not stop processing or other waiters. Use a test deadline so poison messages configured for indefinite redelivery cannot hang a test.

Call harness.Reset() only after idle. It clears all observations and correlation state. Calling it while work is pending throws an actionable exception. Reset does not purge native queues, broker state or outbox rows.

Each service provider owns its harness. Multiple named transports in one host share that host's observations and work counter. Separate hosts have independent harnesses and can run concurrently; no mutable process-wide state is used. AddRelayer currently has one registration per provider: configure all named transports there rather than calling it again for another bus.

Invoke a handler without a host​

Handlers are plain objects. Construct TestMessageContext with a stable wire identity and any required services, headers, attempt or cancellation token:

var context = new TestMessageContext(
new MessageType("orders.placed", 1),
services: provider,
headers: new Dictionary<string, string> { ["tenant"] = "north" });

context.Features.Set(new FakeNativeFeature());
await handler.HandleAsync(new OrderPlaced("A-1001"), context, context.CancellationToken);

Features accepts arbitrary typed features, including fake native features. The default context has a new UUIDv7 ID, attempt one, empty headers and an empty service provider. Header input is copied. The context does not create or dispose DI scopes; the test owns any provider or scope it supplies.

Harness snapshots intentionally allocate and retain history until reset. Register the harness only in tests.