Messaging
ProtoTest.Messaging gives each test a broker client: publish a message to a destination, then await the one that matters with a predicate and a timeout. Failing to arrive is a TimeoutException and a test failure, not a sleep. Without an adapter the client runs against an in-memory broker, so the API works anywhere; ProtoTest.Messaging.RabbitMq replaces it with RabbitMQ, and ProtoTest.Messaging.RabbitMq.Testcontainers owns a broker for the whole run.
Install
dotnet add package ProtoTest.Messaging
dotnet add package ProtoTest.Messaging.RabbitMq
dotnet add package ProtoTest.Messaging.RabbitMq.Testcontainers
The packages target .NET 8, 9 and 10 (the project template defaults to net10.0; pass -f net8.0 or net9.0 for an older runtime). The first is the capability. The other two are optional: add the RabbitMQ adapter to talk to a real broker, and the container package when the run should start one. ProtoTest.Messaging.RabbitMq brings ProtoTest.Messaging with it.
Registering
builder.AddMessaging(); // in-memory broker
builder.AddMessaging(messaging => messaging.UseRabbitMq()); // RabbitMQ
IProtoHostBuilder AddMessaging(this IProtoHostBuilder builder, Action<ProtoMessagingBuilder>? configure = null);
ProtoMessagingBuilder UseBroker(this ProtoMessagingBuilder messaging,
Func<IServiceProvider, IProtoMessageBroker> factory);
ProtoMessagingBuilder CaptureAttachments(this ProtoMessagingBuilder messaging,
Action<MessagingAttachmentOptions>? configure = null);
ProtoMessagingBuilder UseRabbitMq(this ProtoMessagingBuilder messaging,
Action<RabbitMqOptions>? configure = null);
UseBroker is the adapter seam; UseRabbitMq is the built-in implementation of it. AddMessaging registers the options, the ProtoMessageClient initializer for every test, the run-scoped messaging:broker resource, and — only when an adapter is configured — the Messaging capability with kind broker.
A repeated AddMessaging is not a no-op: its configure callback always runs, so a later call can add an adapter to an adapter-less first call or refresh attachment options. Infrastructure stays idempotent — one options object, one broker holder, one initializer, one capability and one run resource — and the first adapter configured wins. A call whose configure throws leaves no guard behind, so a later successful call still composes.
A configured adapter is what makes the Broker capability true. The in-memory default registers none, so [RequiresCapability(ProtoCapabilityKinds.Broker)] skips where no real broker is configured instead of passing against the double (see skip conditions).
Options and keys
| Key | Option | Type | Default |
|---|---|---|---|
ProtoTest:Messaging:DefaultTimeout | MessagingOptions.DefaultTimeout | TimeSpan | 10 seconds |
ProtoTest:Messaging:Destinations | MessagingOptions.Destinations | IList<string> | empty |
ProtoTest:Messaging:Attachments:CapturePublishedPayloads | MessagingAttachmentOptions.CapturePublishedPayloads | bool | true |
ProtoTest:Messaging:Attachments:CaptureReceivedPayloads | MessagingAttachmentOptions.CaptureReceivedPayloads | bool | true |
ProtoTest:Messaging:Attachments:RedactSensitiveData | JsonDiagnosticOptions.RedactSensitiveData | bool | true |
ProtoTest:Messaging:Attachments:MaxDiagnosticBodyLength | JsonDiagnosticOptions.MaxDiagnosticBodyLength | int | 65536 (64 KiB) |
ProtoTest:Messaging:Attachments:SensitiveJsonProperties | JsonDiagnosticOptions.SensitiveJsonProperties | List<string> | password, token, access_token, refresh_token, secret, apiKey, api_key |
ProtoTest:Messaging:RabbitMq:ConnectionString | RabbitMqOptions.ConnectionString | string | amqp://guest:guest@localhost:5672/ |
ProtoTest:Messaging:RabbitMq:PollInterval | RabbitMqOptions.PollInterval | TimeSpan | 25 ms |
MessagingAttachmentOptions derives from JsonDiagnosticOptions and binds from ProtoTest:Messaging:Attachments; RabbitMqOptions binds from ProtoTest:Messaging:RabbitMq. Code configuration runs first and the configuration section binds over it. For the connection string the order is: an explicit configuration value wins, then the value a started container filled, then your code callback or the default.
ProtoTest:Messaging:Broker is not a library option. The demo reads it itself (ProtoTest:Messaging:Broker=container) to decide whether to register a container; the messaging packages never look at that key.
The test-side API
public static ProtoMessageClient Messaging(this ProtoExecutionContext context);
Task PublishAsync(string destination, string? payload = null,
IReadOnlyDictionary<string, string?>? headers = null, string? contentType = null,
CancellationToken cancellationToken = default);
Task<ProtoMessage> AwaitAsync(string destination, Func<ProtoMessage, bool> predicate,
TimeSpan? timeout = null, CancellationToken cancellationToken = default);
AwaitAsync returns the first message on the destination that matches the predicate; when no timeout is given it uses MessagingOptions.DefaultTimeout. Messaging() throws when the host was not composed with AddMessaging. An empty destination or a null predicate is an argument error. ProtoMessage is the broker-agnostic shape every adapter maps onto:
public sealed record ProtoMessage(string Destination, string? Payload = null,
IReadOnlyDictionary<string, string?>? Headers = null, string? ContentType = null);
message.ShouldMatchShape(Proto.Context, shape) matches the payload with the same shape matcher as REST, GraphQL and gRPC. It records an assert.json.shape operation with a messaging.contract.shape observation and throws JsonShapeMismatchException with every mismatch listed; a payload that is empty or not JSON fails with a message naming the destination.
The adapter contract
An adapter implements two interfaces. The capability owns the broker resource and the test-side API; an adapter owns the client technology, and no broker-specific type reaches the test or the trace:
public interface IProtoMessageBroker
{
string Name { get; }
ValueTask PublishAsync(ProtoMessage message, CancellationToken cancellationToken = default);
ValueTask<IProtoMessageConsumer> CreateConsumerAsync(CancellationToken cancellationToken = default);
}
public interface IProtoMessageConsumer : IAsyncDisposable
{
ValueTask PrepareAsync(IReadOnlyCollection<string> destinations, CancellationToken cancellationToken = default);
ValueTask<ProtoMessage> AwaitAsync(string destination, Func<ProtoMessage, bool> predicate,
TimeSpan timeout, CancellationToken cancellationToken = default);
}
One broker is shared by the whole run and may publish concurrently; CreateConsumerAsync returns a consumer that belongs to exactly one test. PrepareAsync declares the destinations that test will await before it acts; DisposeAsync removes whatever the adapter declared for it. That is the isolation contract: parallel tests on one destination cannot steal each other's messages. The in-memory broker keeps history instead and treats PrepareAsync as a no-op.
Quick start
builder.AddMessaging();
[ProtoTest]
public async Task PayingAnInvoicePublishesAnEvent()
{
var messages = Proto.Context.Messaging();
await messages.PublishAsync("invoice.paid", """{"id":42}""", contentType: "application/json");
var message = await messages.AwaitAsync(
"invoice.paid",
candidate => candidate.Payload!.Contains("\"id\":42"));
message.ShouldMatchShape(Proto.Context, new { id = 42 });
}
Going further
RabbitMQ topology
UseRabbitMq publishes to the exchange named like the destination, with the destination as the routing key, ContentType defaulting to application/json and messages marked non-persistent; null headers are dropped.
The broker owns one connection and one publish channel, created lazily on first use and kept for the run. Every test's consumer owns its own channel on that connection — channels are not thread-safe, so each side serializes its own. The consumer declares one exclusive, auto-delete tap queue per destination, named prototest-{guid}: during setup for a declared destination, just in time at the first await otherwise. Each queue is bound with the destination as routing key and with #, which covers every exchange type — direct exchanges match the routing key, topic exchanges match the # catch-all, and fanout and headers exchanges ignore the routing key, so their argument-less bindings match every message. Every queue is deleted when the test's consumer is disposed, and an exclusive queue never competes with the application's own consumers.
The exchange must already exist when a tap binds; the adapter does not declare application exchanges. The demo declares its event topology at application startup and registers AddMessaging last on purpose, so the messaging initializer binds after the in-process application's initializer has created the exchanges:
builder
.AddApplication(NorthstarTargets.Api, app => app.AddAspNetCoreServer<Program>())
.AddMessaging(messaging => messaging.UseRabbitMq());
Repeats and consumption
Each await consumes the message it matches. On RabbitMQ the tap polls with BasicGet(autoAck: true) at PollInterval and discards a message whose predicate does not match, so a later await never sees it again. The in-memory broker behaves the same way: messages live for the run and are ordered, each consumer snapshots the broker position when it is created — so only messages published after its test started can match — and a match advances that consumer's position. A predicate that throws fails only the await that owns it. A timeout is a TimeoutException; awaiting or declaring on a missing exchange is an InvalidOperationException naming the destination; an unreachable broker is an InvalidOperationException naming the sanitized address and ProtoTest:Messaging:RabbitMq:ConnectionString.
Owning a broker
When the run should start RabbitMQ itself, register the container as infrastructure and let the host fill the keys:
builder.AddInfrastructure(
RabbitMqBroker.Container(),
RabbitMqOptions.ConnectionStringSetting, // ProtoTest:Messaging:RabbitMq:ConnectionString
"Messaging:RabbitMq:ConnectionString"); // what the application reads
builder.AddMessaging(messaging => messaging.UseRabbitMq());
AddInfrastructure starts the container with the host and fills every key with the started connection string, so the adapter and the application under test reach the same broker. It starts before any test-level skip condition is evaluated, so a machine without a container runtime fails the run at start. RabbitMqBroker.Container() creates the resource without starting it; Start() starts now or throws with the reason; TryStart(configure, out broker, out error) reports the reason instead, for a fixture that decides before registering infrastructure. The default image is rabbitmq:3, configurable through the builder passed to Container. Registering with AddResource only owns the release — it neither starts the container nor fills settings.
Attachments
CaptureAttachments records every published payload and every payload matched by an await as a test attachment:
builder.AddMessaging(messaging => messaging.CaptureAttachments());
A publish attaches message-publish-{destination}-{sequence}-payload after the broker call succeeded; a matched await attaches message-receive-{destination}-{sequence}-payload. {sequence} is the client's per-test capture number, so repeated captures on one destination stay distinct. Payloads are redacted with the shared JSON rules (password, token, secret, … become [REDACTED]) and truncated to MaxDiagnosticBodyLength; the trace's Message section is redacted with the same rules. An explicit content type wins, otherwise a payload starting with { or [ is application/json and everything else is text/plain. Without CaptureAttachments nothing is captured. Capture never fails the operation: a failed capture is a messaging.attachment.failed event and the publish or await still succeeds.
Destinations
Destinations a suite awaits should be declared before the run, so the RabbitMQ adapter can bind each test's own tap during setup:
{
"ProtoTest": {
"Messaging": {
"Destinations": [ "invoice.paid" ],
"DefaultTimeout": "00:00:15"
}
}
}
From the moment a tap is declared, anything the application publishes is queued for that test, so the usual act-then-await order works. Binding at await time instead would miss everything published in between — which is exactly what happens for an undeclared destination, where the consumer binds just in time and can only see later messages. The in-memory broker needs no declaration because it keeps its own history.
Tracing and coverage
Every publish and await is recorded:
- Operations
messaging.publishandmessaging.awaitwithmessaging.system(the broker name),messaging.destination, andmessaging.timeout_mson the await. The payload is recorded as a redactedMessagecode section. - Observations
messaging.publishfor a successful publish andmessaging.receivefor a matched await — target is the broker name (InMemoryorRabbitMQ), identifier is the destination, metadata carriesmessaging.system.ShouldMatchShapeadds amessaging.contract.shapeobservation carryingMessagingShapeMatchData(Destination, MatchedProperties). - Resources: the run-scoped
messaging:brokerresource with kindbroker, and the per-testmessaging:consumer:Defaultresource with kindconsumer. The container addsbroker:rabbitmq. - The event
messaging.attachment.failedwithattachment.namewhen a capture cannot be registered.
The package ships no collector. The observations exist, but they never reach a report without a collector of your own (see coverage).
Skip
[RequiresCapability(ProtoCapabilityKinds.Broker)] guards tests that need a real broker, with a reason naming the configuration key:
[RequiresCapability(
ProtoCapabilityKinds.Broker,
Reason = "No broker is configured; set ProtoTest:Messaging:RabbitMq:ConnectionString.")]
AddMessaging registers the Messaging capability only when an adapter is configured. The container alone does not satisfy the condition — the capability comes from UseRabbitMq, not from owning a broker.
Limits
- The in-memory broker is a test double. It registers no
Brokercapability and itsPrepareAsyncdoes nothing. - No history on RabbitMQ. A tap holds only what arrived after it was declared; a destination declared just in time at the await sees only later messages.
- A non-matching message is consumed. Match on the destination and the start of the payload rather than re-awaiting the same message.
- UTF-8 strings only.
ProtoMessage.Payloadis astring?; there is no binary payload API. - Destinations are a flat list. There is no per-test destination declaration API on
ProtoMessageClient. - Capture is opt-in. Payload attachments exist only after
CaptureAttachments. - One run connection, serialized channels. RabbitMQ uses a single connection and one publish channel, with consumer operations serialized per channel; the exchange must already exist and there is no retry or backoff.
Links
- The demo's full journey:
samples/ProtoTest.Demo/MessagingJourney.csand the host wiring insamples/ProtoTest.Demo/Setup.cs. - Recipe: API publishes an event.
- Related: Coverage and observations, ProtoTrace, Infrastructure, Skip conditions.