Testing
Integration Testing with the Service Bus Emulator
The Voltimax.Messaging.IntegrationTests project runs against the Azure Service Bus Emulator via Testcontainers. The emulator runs as a Docker container and provides a local Service Bus instance with full protocol support.
Topology-Driven Configuration
Instead of maintaining a separate JSON config file for the emulator, the test fixture builds its configuration directly from IServiceBusTopology. This makes the messaging library's topology the single source of truth for entity definitions:
private static byte[] BuildEmulatorConfig()
{
static Task NoOp(MessageContext _) => Task.CompletedTask;
var services = new ServiceCollection();
var bus = services.AddMessaging(opts =>
opts.ConnectionString = "Endpoint=sb://localhost;SharedAccessKeyName=all;SharedAccessKey=...;EntityPath=sbemulatorns");
// Declare all entities needed by tests
bus.MapQueue<object>("orders");
bus.MapQueue<object>("reply-queue").WithRequiresSession();
bus.MapQueue<object>("commands").WithMaxDeliveryCount(3);
bus.MapTopic<object>("events").MapSubscription("audit", NoOp);
bus.MapTopic<object>("session-topic").MapSessionSubscription("session-sub", NoOp);
using var provider = services.BuildServiceProvider();
var topology = provider.GetRequiredService<IServiceBusTopology>();
return topology.ToEmulatorConfigBytes();
}The ToEmulatorConfigBytes() extension method serializes the topology to the emulator's JSON format and returns UTF-8 bytes ready for container resource mapping. Null entity settings are filled with sensible defaults:
| Setting | Default |
|---|---|
MaxDeliveryCount | 10 |
LockDuration | PT1M (1 minute) |
DefaultMessageTimeToLive | PT1H (1 hour) |
Assembly Fixture
A shared [AssemblyInitialize] fixture starts the emulator once for all test classes:
[TestClass]
public class ServiceBusEmulatorFixture
{
private static ServiceBusContainer? _container;
public static string ConnectionString { get; private set; } = "";
[AssemblyInitialize]
public static async Task AssemblyInitialize(TestContext context)
{
var configBytes = BuildEmulatorConfig();
_container = new ServiceBusBuilder("mcr.microsoft.com/azure-messaging/servicebus-emulator:latest")
.WithAcceptLicenseAgreement(true)
.WithResourceMapping(configBytes, "/ServiceBus_Emulator/ConfigFiles/Config.json")
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilMessageIsLogged("Emulator Service is Successfully Up!",
o => o.WithTimeout(TimeSpan.FromMinutes(5))))
.Build();
await _container.StartAsync();
ConnectionString = _container.GetConnectionString();
}
[AssemblyCleanup]
public static async Task AssemblyCleanup()
{
if (_container is not null)
await _container.DisposeAsync();
}
}Class-Level Parallelism
Tests run in parallel at the class level with a fixed worker limit to avoid exhausting the emulator's connection pool:
[assembly: Parallelize(Scope = ExecutionScope.ClassLevel, Workers = 2)]Each test class uses dedicated queue/topic names to prevent messages from one class contaminating another. Tests within the same class run sequentially, so a single queue per class is usually sufficient.
If multiple tests within the same class could leave messages behind that interfere with each other (for example, tests that deliberately abandon or dead-letter messages), give each test method its own dedicated queue:
[TestClass]
public class ErrorHandlingTests
{
// Each test gets its own queue so abandoned/redelivered messages from one test
// cannot be received by another test's processor on the same queue.
private const string QueueGlobalHandler = "queue.eh.global";
private const string QueueAbandon = "queue.eh.abandon";
private const string QueueDeadLetter = "queue.eh.deadletter";
// ...
}[TestClass]
public class OrderProcessingTests
{
// One queue is sufficient when tests don't leave residual messages
private const string Queue = "queue.orderprocessing";
[TestMethod]
public async Task Should_process_order()
{
var builder = ServiceBusEmulatorFixture.CreateBuilder();
var messaging = builder.Services.AddMessaging(ServiceBusEmulatorFixture.ConnectionString);
messaging.MapQueue<OrderCreated>(Queue)
.MapHandler(async (OrderCreated msg, MessageContext ctx, CancellationToken ct) =>
{
// ...
await ctx.CompleteAsync(ct);
});
// ...
}
}The fixture provides CreateBuilder() which returns a WebApplicationBuilder on a unique port to avoid port conflicts between parallel test classes.
Port Isolation
Each call to ServiceBusEmulatorFixture.CreateBuilder() allocates a unique port via Interlocked.Increment, ensuring parallel tests don't collide on HTTP bindings:
public static WebApplicationBuilder CreateBuilder()
{
var port = 15000 + Interlocked.Increment(ref _portCounter);
return WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = [$"--urls=http://localhost:{port}"]
});
}Adding a New Test Class
- Choose a unique queue/topic name (convention:
queue.{classname},topic.{classname},sub.{classname}) - Add the entity to
BuildEmulatorConfig()inServiceBusEmulatorFixture - Reference the entity name as a
const stringin your test class
If any test in the class deliberately leaves messages on the queue (abandoned, dead-lettered, or scheduled), consider giving each test its own queue to avoid interference. Use WithMaxDeliveryCount() and WithLockDuration() on the emulator entity if a test needs redelivery behaviour or fast lock expiry.
Unit Testing with Voltimax.Messaging.Testing
For unit tests that don't need a real Service Bus, use the in-memory transport from Voltimax.Messaging.Testing. This avoids Docker dependencies and runs much faster.
See the Voltimax.Messaging.Testing package for details.
Behavior Testing with LightBDD
The Voltimax.Edge.Messaging.BehaviorTests project validates multi-gateway message routing and session isolation using LightBDD (Behavior-Driven Development) against a real Service Bus Emulator in Docker.
What is being tested
The key invariant: each gateway process only receives messages whose SessionId matches its own gateway ID, enforced by the SessionIds filter on ServiceBusSessionProcessorOptions (surfaced via WithAcceptedSessionId). The scenarios would pass trivially if they only checked the message payload - instead they assert on ReceivedPing.HandlerGatewayId, the identity of the handler instance that ran, which can only be set correctly by the process that actually acquired the session.
Architecture
MultiGatewayTestHost creates N separate IHost instances - one per simulated gateway - each with its own DI container, its own IGatewayIdAccessor, and its own session processor filtered to its gateway ID. This mirrors the real deployment exactly:
All three hosts share the same gateway-commands queue on the emulator and compete for sessions - but each only accepts sessions matching its own ID.
Core Components
ServiceBusEmulatorFixture ([SetUpFixture]):
- NUnit assembly-level fixture; starts the Service Bus Emulator container once for all tests
- Builds emulator config from the real
Voltimax.Edge.Messagingtopology - single source of truth for entity definitions
MultiGatewayTestHost:
- Constructs one
IHostper gateway ID, each callingWithAcceptedSessionId(gatewayId)on the session processor StartAsync(): starts all hosts concurrently, waits for processors to connectSendPingAsync(targetGatewayId, message): sends togateway-commandswithSessionId = targetGatewayIdand theMessageTypediscriminatorGetReceivedMessagesFor(gatewayId): returnsList<ReceivedPing>recorded by that gateway's handler instance
TestPingGatewayHandler:
- Implements
IMessageHandler<PingGatewayRequest, PingGatewayResponse> - Injects
IGatewayIdAccessorfrom the host's DI container - keys all recordings ongatewayIdAccessor.GatewayId, not onmessage.GatewayId - If the wrong host's handler processes a message,
HandlerGatewayIdwill not match the session's target - the test fails
ReceivedPing record:
public sealed record ReceivedPing(string HandlerGatewayId, PingGatewayRequest Request);HandlerGatewayId is set from IGatewayIdAccessor inside the host that processed the message - not from the payload - making isolation assertions meaningful.
Test Scenarios
Scenario 1: Gateway Receives Ping Request
Given test host with three gateways
When ping message is sent to gateway-1
Then gateway-1 handler has processed the message ← asserts HandlerGatewayId == "gw-1"
And the response contains expected dataScenario 2: Message Does Not Leak to Other Gateways
Given test host with three gateways
When ping message is sent to gateway-1
Then gateway-2 handler has NOT processed the message
And gateway-3 handler has NOT processed the messageWithout WithAcceptedSessionId, this scenario would fail intermittently - the host that wins the session race records the message under its own ID, which may not be gw-1.
Scenario 3: Multiple Gateways Receive Independently
Given test host with three gateways
When ping messages are sent to all three gateways (concurrently)
Then each gateway handler has processed exactly its own message
And no message leakage between gatewaysRunning Behavior Tests
Prerequisites: Docker daemon running
dotnet build src/Voltimax.Edge.Messaging.BehaviorTests
dotnet test src/Voltimax.Edge.Messaging.BehaviorTestsFirst run takes ~3–5 minutes (emulator image pull + startup). Subsequent runs are faster.
Expected output:
Passed Scenario_GatewayReceivesPingRequest
Passed Scenario_MessageDoesNotLeakToOtherGateways
Passed Scenario_MultipleGatewaysReceiveIndependently
Passed! - Failed: 0, Passed: 3, Skipped: 0Extending Behavior Tests
Add a new scenario:
- Add a
[Scenario]method toGatewayMessagingBehavior.cs - Implement
async TaskGiven/When/Then step methods - Use
Runner.RunScenarioAsync(step1, step2, ...)- steps are async
Add support for a new message type:
Define the contract in
Voltimax.Iot.ContractsCreate a handler implementing
IMessageHandler<TMessage, TResponse>that injectsIGatewayIdAccessorRegister it inside
MultiGatewayTestHost.BuildGatewayHost:csharpservices.AddScoped<MyHandler>(); topology.GatewayCommands.MapMessage<MyMessage, MyHandler>();Add a
SendXxxAsyncmethod toMultiGatewayTestHostthat setsSessionIdand theMessageTypediscriminator propertyAdd assertions and a new
[Scenario]
INFO
Use the WaitForHandlerAsync helper in scenario steps instead of Thread.Sleep. It polls until the expected message count is seen (or times out after 15 s) and calls Assert.Fail with a descriptive message, making test failures much easier to diagnose.