Skip to content

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:

csharp
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:

SettingDefault
MaxDeliveryCount10
LockDurationPT1M (1 minute)
DefaultMessageTimeToLivePT1H (1 hour)

Assembly Fixture

A shared [AssemblyInitialize] fixture starts the emulator once for all test classes:

csharp
[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:

csharp
[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:

csharp
[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";
    // ...
}
csharp
[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:

csharp
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

  1. Choose a unique queue/topic name (convention: queue.{classname}, topic.{classname}, sub.{classname})
  2. Add the entity to BuildEmulatorConfig() in ServiceBusEmulatorFixture
  3. Reference the entity name as a const string in 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.Messaging topology - single source of truth for entity definitions

MultiGatewayTestHost:

  • Constructs one IHost per gateway ID, each calling WithAcceptedSessionId(gatewayId) on the session processor
  • StartAsync(): starts all hosts concurrently, waits for processors to connect
  • SendPingAsync(targetGatewayId, message): sends to gateway-commands with SessionId = targetGatewayId and the MessageType discriminator
  • GetReceivedMessagesFor(gatewayId): returns List<ReceivedPing> recorded by that gateway's handler instance

TestPingGatewayHandler:

  • Implements IMessageHandler<PingGatewayRequest, PingGatewayResponse>
  • Injects IGatewayIdAccessor from the host's DI container - keys all recordings on gatewayIdAccessor.GatewayId, not on message.GatewayId
  • If the wrong host's handler processes a message, HandlerGatewayId will not match the session's target - the test fails

ReceivedPing record:

csharp
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

gherkin
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 data

Scenario 2: Message Does Not Leak to Other Gateways

gherkin
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 message

Without 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

gherkin
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 gateways

Running Behavior Tests

Prerequisites: Docker daemon running

bash
dotnet build src/Voltimax.Edge.Messaging.BehaviorTests
dotnet test src/Voltimax.Edge.Messaging.BehaviorTests

First run takes ~3–5 minutes (emulator image pull + startup). Subsequent runs are faster.

Expected output:

text
Passed  Scenario_GatewayReceivesPingRequest
Passed  Scenario_MessageDoesNotLeakToOtherGateways
Passed  Scenario_MultipleGatewaysReceiveIndependently

Passed!  - Failed: 0, Passed: 3, Skipped: 0

Extending Behavior Tests

Add a new scenario:

  1. Add a [Scenario] method to GatewayMessagingBehavior.cs
  2. Implement async Task Given/When/Then step methods
  3. Use Runner.RunScenarioAsync(step1, step2, ...) - steps are async

Add support for a new message type:

  1. Define the contract in Voltimax.Iot.Contracts

  2. Create a handler implementing IMessageHandler<TMessage, TResponse> that injects IGatewayIdAccessor

  3. Register it inside MultiGatewayTestHost.BuildGatewayHost:

    csharp
    services.AddScoped<MyHandler>();
    topology.GatewayCommands.MapMessage<MyMessage, MyHandler>();
  4. Add a SendXxxAsync method to MultiGatewayTestHost that sets SessionId and the MessageType discriminator property

  5. Add 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.