| name | unit-test-style |
| description | Guidelines and conventions for writing unit tests in this repo. Use when creating, reviewing, or discussing unit tests that run without a real broker. |
Unit Test Style Guide
This skill describes the required style for all unit tests in this repository. Follow it exactly when writing new tests or reviewing existing ones.
Two Kinds of Unit Tests
This repo has two flavours of unit test, each with its own infrastructure:
| Kind | Projects | Infrastructure |
|---|
| Core unit tests | test/ArtemisNetClient.UnitTests/ | ActiveMQNetSpec base class + TestContainerHost (in-process AMQP server) |
| TestKit unit tests | test/ArtemisNetClient.Testing.UnitTests/ | TestKit (the in-process broker from the test kit library), no base class |
| Pure model tests | either project | No infrastructure — pure C# assertions |
Choose the infrastructure that matches what you're testing:
- Testing AMQP protocol behaviour, connection/producer/consumer lifecycle → Core unit test
- Testing
TestKit itself → TestKit unit test
- Testing data types, factory methods, or pure logic → Pure model test
File & Class Naming
- Files use the
*Spec.cs suffix (BDD-style), e.g. ConsumerReceiveMessageSpec.cs
- Subtopics go in subdirectories, e.g.
AutoRecovering/AutoRecoveringProducerSpec.cs
Core Unit Tests — Base Class
Unit test classes that need in-process AMQP infrastructure inherit from ActiveMQNetSpec; pure model tests (no AMQP, no broker) do not need a base class at all:
public class MyFeatureSpec : ActiveMQNetSpec
{
public MyFeatureSpec(ITestOutputHelper output) : base(output) { }
}
The base class provides:
| Member | What it gives you |
|---|
GetUniqueEndpoint() | Unique local endpoint via EndpointUtil |
CreateConnection(endpoint) | IConnection via ConnectionFactory with logging and GUID message IDs |
CreateConnectionWithoutAutomaticRecovery(endpoint) | Same but with auto-recovery disabled |
CreateConnectionFactory() | Configured ConnectionFactory for customisation |
CreateTestLoggerFactory() | XUnitLoggerFactory wired to ITestOutputHelper |
CreateOpenedContainerHost(endpoint?, handler?) | Running in-process AMQP server |
CreateContainerHost(endpoint?, handler?) | Same but not yet opened |
CreateContainerHostThatWillNeverSendAttachFrameBack(endpoint) | Host that ignores link attachment |
DisposeHostAndWaitUntilConnectionNotified(host, conn) | Drops the host and waits for the connection close event |
WaitUntilConnectionRecovered(conn) | Waits for the connection recovered event |
Timeout | 1 minute (DEBUG) / 10 seconds (Release) |
ShortTimeout | 100 ms — use for negative assertions |
CancellationToken | Token backed by Timeout |
TestKit Unit Tests — No Base Class
TestKit specs do not inherit from ActiveMQNetSpec. Use EndpointUtil directly to get a unique endpoint, and use TestKit directly for the in-process broker:
public class MyTestKitSpec
{
[Fact]
public async Task Should_do_something()
{
var endpoint = EndpointUtil.GetUniqueEndpoint();
using var testKit = new TestKit(endpoint);
var connectionFactory = new ConnectionFactory();
await using var connection = await connectionFactory.CreateAsync(endpoint);
}
}
If you need AMQP frame-level tracing (e.g., to diagnose link-attach sequences), set it up in the constructor — but only when you have an ITestOutputHelper and actually need it:
public class MyTestKitSpec
{
public MyTestKitSpec(ITestOutputHelper output)
{
Trace.TraceLevel = TraceLevel.Information;
var logger = new XUnitLogger(output, "logger");
Trace.TraceListener += (_, format, args) => logger.LogTrace(format, args);
}
}
Pure Model Tests — No Infrastructure
For tests that only exercise data types, factory methods, or pure logic, omit both the base class and ITestOutputHelper:
public class EndpointSpec
{
[Fact]
public void Should_create_endpoint()
{
var endpoint = Endpoint.Create("localhost", 5672, "guest", "guest");
Assert.Equal("localhost", endpoint.Host);
}
}
Synchronous [Fact] tests are fine here — only use async Task when you're actually doing async I/O.
Test Method Conventions
- Core and TestKit tests are
async Task — no synchronous tests unless there is no async I/O at all
- Method names read as sentences:
Should_receive_message, Throws_when_invalid_scheme_specified
- Both
[Fact] and [Theory] are allowed; use [Theory] with [MemberData] or [InlineData] when the same behaviour must be verified across a set of inputs
[Fact]
public async Task Should_do_something_meaningful()
{
}
[Theory, MemberData(nameof(RoutingTypesData))]
public async Task Should_behave_differently_per_routing_type(RoutingType routingType, object expected)
{
}
In-Process AMQP Infrastructure
Use the helpers from ActiveMQNetSpec to build a local AMQP server:
using var host = CreateOpenedContainerHost(endpoint);
var testHandler = new TestHandler(@event =>
{
switch (@event.Id)
{
case EventId.ConnectionRemoteOpen:
break;
case EventId.LinkRemoteOpen when @event.Context is Attach attach && !attach.Role:
break;
}
});
using var host = CreateOpenedContainerHost(endpoint, testHandler);
var messageSource = host.CreateMessageSource("my-address");
messageSource.Enqueue(new Message("foo"));
var messageProcessor = host.CreateMessageProcessor("my-address");
var received = messageProcessor.Dequeue(Timeout);
var linkProcessor = host.CreateTestLinkProcessor();
linkProcessor.SetHandler(_ => true);
TestContainerHost implements IDisposable — prefer using var so it is disposed automatically when the test ends, unless you need to dispose it at a specific point mid-test (e.g., to simulate a broker outage):
using var host = CreateOpenedContainerHost(endpoint);
var host = CreateOpenedContainerHost(endpoint);
host.Dispose();
Resource Management
TestContainerHost → prefer using var (synchronous IDisposable); omit using only when you need to dispose at a specific point mid-test
IConnection → await using var (asynchronous IAsyncDisposable)
IProducer / IConsumer → await using var when you want automatic cleanup; declare without await using when you need to DisposeAsync() mid-test
When a test owns several resources whose lifetimes end together, use DisposeUtil.DisposeAll:
await DisposeUtil.DisposeAll(producer, connection, host);
Synchronisation
Async event waits — use TaskCompletionSource<T>
var tcs = new TaskCompletionSource<bool>();
using var cts = new CancellationTokenSource(Timeout);
await using var _ = cts.Token.Register(() => tcs.TrySetCanceled());
connection.ConnectionClosed += (_, _) => tcs.TrySetResult(true);
await tcs.Task;
Sync waits inside TestHandler — use ManualResetEvent or CountdownEvent
var attached = new ManualResetEvent(false);
var handler = new TestHandler(@event =>
{
if (@event.Id == EventId.LinkRemoteOpen)
attached.Set();
});
Assert.True(attached.WaitOne(Timeout));
Negative assertions (confirming something does NOT happen)
For async paths, cancel with ShortTimeout:
var cts = new CancellationTokenSource(ShortTimeout);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await consumer.ReceiveAsync(cts.Token));
For synchronous paths via ManualResetEvent, assert WaitOne returns false:
Assert.False(producerAttached.WaitOne(ShortTimeout));
Arrange-Act-Assert Structure
Follow a clear, unlabelled AAA structure. Keep each section visually separate with a blank line:
[Fact]
public async Task Should_receive_message()
{
var endpoint = GetUniqueEndpoint();
using var host = CreateOpenedContainerHost(endpoint);
var messageSource = host.CreateMessageSource("a1");
await using var connection = await CreateConnection(endpoint);
var consumer = await connection.CreateConsumerAsync("a1", RoutingType.Anycast);
messageSource.Enqueue(new Message("foo"));
var message = await consumer.ReceiveAsync();
Assert.NotNull(message);
Assert.Equal("foo", message.GetBody<string>());
}
The one exception is Should_create_connection_with_specified_client_id-style tests where the AAA sections are long and a // Arrange / // Act / // Assert comment genuinely helps navigation — add them only in that case.
Assertions
Use plain xUnit assertions — no custom extensions, no FluentAssertions:
Assert.Equal("expected", actual);
Assert.NotNull(value);
Assert.Null(value);
Assert.True(condition);
Assert.False(condition);
Assert.Same(expected, actual);
Assert.IsType<ConcreteType>(instance);
Assert.Contains("substring", message);
Assert.Throws<ArgumentNullException>(() => { ... });
await Assert.ThrowsAsync<ObjectDisposedException>(() => connection.CreateProducerAsync(...));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await consumer.ReceiveAsync(cts.Token));
Assert.True(manualResetEvent.WaitOne(Timeout));
Private Helper Methods
Extract repeated sequences into private helpers within the same class:
private async Task ShouldSendMessageWithPayload<T>(T payload)
{
using var host = CreateOpenedContainerHost();
var messageProcessor = host.CreateMessageProcessor("a1");
await using var connection = await CreateConnection(host.Endpoint);
await using var producer = await connection.CreateProducerAsync("a1", RoutingType.Anycast);
await producer.SendAsync(new Message(payload));
var received = messageProcessor.Dequeue(Timeout);
Assert.Equal(payload, received.GetBody<T>());
}
private async Task<(IProducer, MessageProcessor, TestContainerHost, IConnection)> CreateReattachedProducer()
{
}
Local static functions are also fine for single-test helpers (C# 9+):
static async Task SendMessagesToGroup(TestKit testKit, string address, string groupId, int count) { ... }
Comments
Add a comment only when the WHY is non-obvious — a hidden constraint, a workaround, or behaviour that would surprise a reader:
messageProcessor.SetHandler(_ => true);
var produceTask = Task.Run(() => producer.Send(new Message("foo")));
Never describe what the code does — well-named identifiers already do that.
Summary Checklist
When writing a new unit test, verify: