Testing

The library ships an in-process broker. Use a memory:// URL and nothing needs to be installed or running.

using var mq = await AceMqConnection.ConnectAsync("memory://" + Guid.NewGuid());

Naming the broker after the test keeps two tests from seeing each other's messages. InMemoryTransport.Reset() forgets every broker, which is worth calling between tests that share a name.

What it does, exactly

It routes the way RabbitMQ routes: direct, fanout and topic, with * matching one word and # matching zero or more. A binding that matches here matches on a real broker. That is the property that makes it worth using — the topic rules are tested against the same table the documentation states.

Dispositions behave as they do in production: Retry redelivers with the attempt counter advanced, Release redelivers without advancing it, DeadLetter removes the message and keeps it where a test can assert on it.

var dead = InMemoryTransport.DeadLettered(brokerName, "orders.placed");
Assert.Single(dead);

What it does not do

Durability, clustering, flow control, network failure, memory alarms, or anything that depends on a broker restart. It cannot tell you that your quorum queue survives a node loss, and it will happily let a test pass that a real broker would reject.

That is a real limit, not a caveat to skim: an in-memory broker verifies your logic, not your operations. Both need testing, and only one of them can be done without a broker.

Against a real broker

Transports.Register(new RabbitMqTransport());
using var mq = await AceMqConnection.ConnectAsync("amqp://localhost");

Testcontainers is the usual way to get one per test run. The things worth testing there are the ones the in-memory broker cannot answer: that your dead-letter topology is actually declared, that a quorum queue is configured the way you think, and that your consumer recovers from a dropped connection.

Cross-language conformance

This is how we know the five AceMQ libraries agree, and it is worth understanding before trusting that a message retried by a .NET consumer and then by a Go one waits the same amount.

Java, Go, .NET, Python and Ruby each carry a byte-identical copy of two generated fixtures and assert against them. Both are generated by the Java library's own test suite, where CI regenerates them; no library writes its own.

Fixture What it pins
envelope-fixtures.json the wire headers — defaults, encodings, which headers are absent rather than null
contract-fixtures.json the retry schedules and their unjittered delays, the jitter bounds, the broker-wait threshold table, the .dlq/.parked/rung names, the rung arguments, the declared topology and the queue-type defaults
../scripts/check-fixtures.sh

That compares all five copies of both files and fails on a single changed character. It is the load-bearing part: a library that quietly edits its own copy still passes its own suite, because it is agreeing with the wrong file in private.

The assertions are in EnvelopeConformanceTests and ContractConformanceTests. They derive their expectations wherever they can rather than reading a number out of the fixture and comparing it to the same number read out of this library — the doubling is checked by asserting each delay is twice its predecessor, the threshold table is recomputed from the rule the fixture states in prose, the rung names are rendered a second time by a separate implementation in the test file, and the jitter bounds are sampled from this library rather than read back.

Four disagreements between this library and the fixture are known, recorded and asserted as named exceptions rather than smoothed away — sub-second rung names, the default message-age ceiling, when the dead-letter queues are declared, and the jitter floor. They are listed in the repository's README.md.

Waiting for a message

Consumption is asynchronous, so a test has to wait for it. Waiting on a signal is better than sleeping:

var arrived = new TaskCompletionSource<IMessage<OrderPlaced>>();

using var consumer = await mq.ConsumeAsync<OrderPlaced>("orders.placed", message =>
{
    arrived.TrySetResult(message);
    return Task.FromResult(Ack.Accept());
});

await publisher.SendAsync(new OrderPlaced("A-1", 42.50m));

var received = await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5));

A fixed Task.Delay in place of that is the most common way a messaging test suite becomes slow and flaky at the same time: too short and it fails on a loaded build machine, too long and the suite takes minutes.

Create the source with RunContinuationsAsynchronously. Without it, TrySetResult inside the handler resumes the awaiting test on the client's consumer dispatch thread. Anything the test then does that the dispatch thread itself has to service blocks that thread against itself, and the test hangs rather than failing. (Closing the connection or the consumer from there is safe since 0.7.7: it returns at once and finishes on the thread pool. Waiting synchronously on the next delivery is not.)

var arrived = new TaskCompletionSource<IMessage<OrderPlaced>>(
    TaskCreationOptions.RunContinuationsAsynchronously);

This is not hypothetical. It is how the first version of this library's own integration suite behaved, and the hang looked like a broker problem for long enough to be worth writing down.