NashTech Blog

Building API Integration Tests in .NET #5: Messaging, Outbox, and RabbitMQ on the Fidelity Ladder

Table of Contents

Building API Integration Tests in .NET #5: Messaging, Outbox, and RabbitMQ on the Fidelity Ladder


1. Introduction

In the previous articles, we climbed to the last Aspire rung in the propose Fidelity Ladder with Aspire orchestration — databases, optional sibling services, health waits, and the same in-process HTTP tests as lower rungs. That answers questions about persistence and cross-service wiring. However, It does not fully answer questions about what leaves the service after a successful HTTP call.

In a typical microservice API, a state change does not end at the response body. The service also announces what happened — a price change, an order submitted, a payment failed — so other services can react without chaining synchronous calls. Those announcements travel as integration events through a message broker. If the wrong event fires, or none fires, staging breaks even when the HTTP response is successful. The earlier articles in this series — from repository mock through Testcontainers to Aspire — rarely catch that class of bug because they stop at status codes and database storage.

This article adds messaging fidelity on top of the Aspire rung:

LayerWhat you verify
HTTPStatus code and response body (same as earlier rungs)
OutboxIntegration events persisted in the same DB transaction as business data
BusEvents actually published — via an in-process spy or a real RabbitMQ broker

In this article, we will move from concepts (queues, RabbitMQ, transactional outbox) to test shape (what differs from API-only tests) to implementation (two Aspire modes, assertions, runsettings). The demo project supplies working fixtures; the ideas apply to any broker-backed API that uses an outbox.

Prerequisites: Docker running (Aspire provisions Postgres and, in one mode, RabbitMQ). Familiarity with the Aspire hybrid host is helpful.


2. A quick review — earlier rungs on the ladder

The series introduction established the fidelity ladder — same API tests with swappable dependency realism. Mock and InMemory, Testcontainers, and Aspire each climbed one rung. This article extends the top of that ladder with messaging.

ConceptSummary
Fidelity ladderSame API tests; fixture wiring swaps how realistic dependencies are
In-process test hostHosts the API inside the test process; dependency injection is the seam for test doubles
Aspire hybridAspire starts containers; the test host runs the API under test
Messaging is orthogonalYou can record published events without a real broker — but outbox tests need a real database

Earlier articles focus on request → handler → database → HTTP response. This one extends assertions to side effects that happen after the transaction commits — integration events written to an outbox table and published to the message bus.

Sections 3–5 build the messaging vocabulary and the outbox pattern that the test sections assume. If you already know queues and transactional outbox, skim to Section 6.


3. Message queue architecture — a short primer

This section establishes the vocabulary used throughout the post — producer, consumer, and why teams accept eventual consistency rather than chaining synchronous HTTP calls inside a single request.

What problem do queues solve?

In a monolith, Service A calls Service B with an HTTP request. Both succeed or both fail in one process. In microservices, Service A must not block on Service B being up, fast, or even deployed. To fulfill that, we will need an asynchronous, loosely coupled communication.

A message queue (or message broker) sits between producers and consumers:

Below are the terms that we will use in this article

TermWhat it means
ProducerA service that publishes a message when something happened (“price changed”)
ConsumerA service that subscribes and reacts (“update cart line price”)
DecouplingProducer does not know consumer implementation; only the message contract
AsyncHTTP returns before every downstream handler finishes
Eventual consistencySystem state converges over time; not everything is atomic across services
Dead letterA message moved to a separate queue after repeated processing failures — held for inspection rather than retried forever

Publish/subscribe in practice

Brokers differ in topology. Many stacks use RabbitMQ with a direct exchange: each message type gets a routing key, and each consumer binds a queue to the keys it cares about.

That is pub/sub at the application level: one publish can fan out to multiple subscribers, each with its own queue.

Why not “just call HTTP”?

A message broker adds infrastructure to run, operational complexity (retries, dead letters, monitoring), and eventual consistency to reason about. The benefit is looser coupling between services — but only when that trade-off is worth the cost. Teams might ask why a request handler cannot simply call the next service over HTTP before returning to the client. The table below compares the two approaches: the abstract trade-off, a concrete scenario, and the customer impact.

ConcernHTTP call from handlerMessage via brokerIn practiceConsequence
AvailabilityCouples availability — caller fails or waits if callee is downProducer can finish once the broker accepts the messageA price update must reach the cart service so totals stay correctHTTP: checkout errors or stale cart prices while the cart service is down.
Messaging: checkout keeps working; totals refresh when the cart service consumes the event
RetriesRetries pollute the caller — slow or failed HTTP loops extend the original requestBroker and consumer own retry and dead-letter policiesPlacing an order must eventually trigger payment captureHTTP: spinning checkout or timeout while payment errors are retried inline.
Messaging: fast confirmation; payment finishes moments later in the background
ExtensibilityHarder to add new reactors — each new downstream means new code in the producerNew subscriber binds to an event without changing the publisherA search indexer should react to catalog changes without risky edits to the publisherHTTP: slower, riskier releases for customer-facing features.
Messaging: search and recommendations improve without blocking unrelated fixes
TransactionsCannot atomically commit your DB write and an outbound HTTP callFire-and-forget publish is also unsafe without an outboxCheckout must not confirm an order unless downstream services will see the same factsHTTP: “order placed” with no charge, or charged with no order.
Messaging: confirmation matches what downstream services process

The last row — transactions — is the hinge for integration testing. A successful HTTP response is not proof that downstream services will see the change. Section 5 explains the standard pattern for closing that gap. Section 4 maps the broker side to the moving parts you will assert in tests.


4. Publishing events with RabbitMQ

The primer above is broker-agnostic. In practice, many .NET microservice applications — including our demo — hide the broker behind an event bus abstraction so domain code publishes events without knowing RabbitMQ details.

The diagram below is the mental model for the rest of this post. Read it left to right: the API publishes an event; the broker routes it; queues and handlers receive only the message types they subscribed to.

Follow the numbered path:

StepWhat happens
① PublishApplication code calls the event bus (“an order was submitted”)
② RouteThe broker adapter serializes the message and sets a routing key — usually the event type name
③ ExchangeA direct exchange forwards the message only to queues bound to that routing key
④ ConsumeA background worker pulls from each queue and invokes the matching typed handler

In the demo, one exchange (eshop_event_bus) fans out to many queues. Basket might bind to OrderStarted; a search service might bind to ProductPriceChanged; a test might bind to the same keys through a capture handler — without starting every downstream microservice.

The four moving parts in code:

PieceTypical role
Event bus interfaceApplication code publishes integration events through one contract
Broker adapterOpens connections, declares exchanges, serializes and routes messages
Event handlersOne handler per message type on the consumer side
Background workersPull messages from the broker and dispatch to handlers

The broker adapter in the demo is only a few lines at the publish point:

// Broker adapter — routing key is the event type name
var routingKey = @event.GetType().Name;
await channel.ExchangeDeclareAsync(exchange: "eshop_event_bus", type: "direct");
await channel.BasicPublishAsync(exchange: ExchangeName, routingKey: routingKey, ...);

Exchange names and routing conventions are application-specific. The test questions are universal: was the right message published, with the right payload, through the real adapter?

In tests, RabbitMQ appears only in the higher-fidelity messaging mode. The fixture asks Aspire to start a broker container alongside Postgres — the same wiring path as production, scoped to the test host.

What RabbitMQ tests add over a spy bus: serialization to bytes, exchange declaration, routing keys, subscription wiring, and the background worker pipeline that delivers messages back to handlers (steps ②–④ in the diagram).

Production still has a coordination gap between the database and the broker. Section 5 explains the pattern used to close it.


5. The transactional outbox pattern

Section 4 showed how events leave the service and reach subscribers. That pipeline assumes the message should exist. It does not guarantee the message is consistent with the database — the harder problem in distributed systems.

The problems without an outbox

ProblemWhat goes wrong
Dual-writeSaving business data and publishing to the broker are two separate writes to two different systems. They cannot share a single atomic transaction.
Publish before commitThe broker receives an event, then the database transaction rolls back. Downstream services react to something that never became true — a ghost event.
Commit before publishThe database commits, but the broker publish fails. Downstream never learns of a change customers already see — a lost event.
Fire-and-forget publishPublishing directly after commit without recording intent still loses messages on process crash between commit and publish — no durable record to retry from.

Section 3’s transactions row showed the customer impact: “order placed” with no charge, or charged with no order. The root cause is always the same — two systems updated without a single source of truth.

For a fuller walkthrough of the dual-write problem and why the outbox exists, see James Carr’s article The Transactional Outbox Pattern.

The pattern

Transactional outbox: write the event to an outbox table in the same DB transaction as the business update. After commit, a worker (or the same request scope) reads pending rows, publishes to the bus, then marks rows as published. The database becomes the system of record; the broker is fed from that record.

Transactional outbox pattern

A typical implementation splits the work into save to outbox inside the transaction and publish after commit. The demo’s ordering service follows that shape:

// Save inside the DB transaction
public async Task AddAndSaveEventAsync(IntegrationEvent evt)
{
    await _eventLogService.SaveEventAsync(evt, _orderingContext.GetCurrentTransaction());
}

// Publish after commit
public async Task PublishEventsThroughEventBusAsync(Guid transactionId)
{
    var pendingLogEvents = await _eventLogService.RetrieveEventLogsPendingToPublishAsync(transactionId);
    foreach (var logEvt in pendingLogEvents)
    {
        await _eventLogService.MarkEventAsInProgressAsync(logEvt.EventId);
        await _eventBus.PublishAsync(logEvt.IntegrationEvent);
        await _eventLogService.MarkEventAsPublishedAsync(logEvt.EventId);
    }
}

What tests should verify:

  1. Correct event types appear in the outbox with a published state.
  2. Published payloads match the business operation (identifiers, amounts, status fields).
  3. (RabbitMQ mode) Messages survive serialization and reach a subscriber.

With the production path defined, we can ask what fails in practice — and how that failure differs from the API-only tests covered from mock and InMemory through Aspire.


6. Why test messaging at all?

Unit tests can assert that application code attempts to publish an event. That proves intent, not end-to-end behavior through the outbox and broker pipeline described above.

RiskWhat breaks without messaging tests
Wrong payloadEvent published with stale price, wrong id, or missing field
Wrong event typeHandler emits a cancellation event instead of a creation event
Outbox not writtenDB commit succeeds but no outbox row — downstream never sees the change
Outbox not publishedRow stuck in a pending state — broker never receives the message
Serialization / routingPayload shape or routing key mismatch — consumers silently ignore messages
Subscription gapsHandler exists but is never registered — messages arrive nowhere useful

Testing only HTTP catches none of the risks in the table. You need assertions on persisted events and published events — which changes the shape of the test itself.

When messaging tests earn their keep:

  • Commands that must emit specific integration events
  • Refactors to the event bus, outbox services, or message contracts
  • CI gates before release or before merging event schema changes
  • Regression after fixing bugs like “green API test, broken staging event”

The risks are about correctness. The next section is about test mechanics — why the comfortable API-only rhythm from earlier articles is not enough.


7. Why messaging tests are different from API-only tests

The previous articles in this series all follow the same testing flow: arrange → send an HTTP request → verify the HTTP response → optionally verify the database. Messaging tests keep that familiar foundation, then extend it with outbox and message bus assertions. Because publishing happens outside the request/response cycle, they also introduce asynchronous timing and additional fixture wiring.

7.1 Side effects are asynchronous

After the database commit, publishing may still run in the same request — but with a real broker, the test process is also a subscriber waiting for messages to arrive. Assertions often need a timeout and poll loop, not an immediate read after a successful HTTP response.

7.2 Two surfaces to assert

Section 5 listed three things to verify; in code there are two read paths:

SurfaceWhat it proves
Outbox table (rows marked published)Event was recorded atomically with business data and marked published
Bus captureThe event bus received the message (spy) or a subscriber consumed it (RabbitMQ)

Either surface can pass in isolation while the other is broken. The demo asserts both for a stronger signal.

7.3 Infrastructure stripping in spy mode

The real broker adapter usually runs as a background service. If you swap in a spy bus but leave broker background services registered, the host still tries to connect to RabbitMQ.

Spy mode therefore removes the broker adapter and background services, then registers an in-memory recorder:

private static void RemoveRabbitMqInfrastructure(IServiceCollection services)
{
    services.RemoveAll<IEventBus>();
    foreach (var descriptor in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
        services.Remove(descriptor);
}

7.4 RabbitMQ mode needs capture handlers

With a real broker, the API under test is both publisher and subscriber (same as local development). Tests register lightweight handlers for each event type so the test process records consumed messages without starting every downstream microservice.

7.5 Still on the Aspire rung for persistence

Outbox rows live in PostgreSQL. Messaging modes are not a return to an in-memory database — they extend Aspire with an optional RabbitMQ container:

AspireMessagingOutbox   → Postgres (Aspire) + spy event bus
AspireMessagingRabbitMq → Postgres (Aspire) + RabbitMQ (Aspire) + capture handlers

7.6 Comparison: API-only vs messaging integration tests

AspectAPI-only tests (mock → InMemory → Testcontainers → Aspire)Messaging tests (this article)
Primary assertionHTTP + optional DB rowHTTP + outbox rows + events
TimingMostly synchronousMay wait on broker delivery
Fixture changesRepository, database, external stubsAbove + bus swap or capture subscriptions
DockerFrom Testcontainers or AspireAspire (Postgres; + RabbitMQ in one mode)
Flake sourcesContainer startupContainer startup + message latency

The next section wires two messaging modes on the Aspire hybrid fixture.


8. Two messaging modes on the Aspire rung

The Aspire article established the hybrid host: Aspire provisions dependencies; the test host runs the API. This article adds a mode switch on that same fixture — whether a real broker starts, and how published events are recorded in tests.

ModeRabbitMQ containerEvent bus in testsHow events are captured
AspireMessagingOutboxNoIn-memory spyEvents recorded on the spy
AspireMessagingRabbitMqYesProduction broker adapterTest-side capture handlers

Spy bus mode — fast, focused on outbox + publish call

Section 7 noted that spy mode must strip broker background services. Registration in the demo looks like this:

public static void ConfigureOutboxWithSpyBus(IServiceCollection services)
{
    RemoveRabbitMqInfrastructure(services);
    services.AddSingleton<CapturingEventBus>();
    services.AddSingleton<IEventBus>(sp => sp.GetRequiredService<CapturingEventBus>());
    // ... other test doubles for non-messaging dependencies
}

The spy implements the same event bus interface and records every publish call:

public sealed class CapturingEventBus : IEventBus
{
    private readonly ConcurrentQueue<IntegrationEvent> _published = new();
    public IReadOnlyCollection<IntegrationEvent> Published => _published.ToArray();

    public Task PublishAsync(IntegrationEvent @event)
    {
        _published.Enqueue(@event);
        return Task.CompletedTask;
    }
}

Best for: verifying correct events and payloads without broker startup cost; PR pipelines that already run Aspire database tests.

RabbitMQ mode — broker fidelity

Here the production broker adapter stays in place. Tests add capture handlers so the test process subscribes to its own events:

public static void ConfigureRabbitMqWithCaptureHandlers(IServiceCollection services)
{
    IntegrationEventCapture.Reset();
    services.AddCaptureSubscription<ProductPriceChangedIntegrationEvent>();
    // Demo: ordering tests register multiple event types
}

Capture handlers collect delivered messages; tests wait until the expected count arrives:

await IntegrationEventCapture.WaitForCountAsync(expectedCount, TimeSpan.FromSeconds(30), cancellationToken);

Best for: pre-release or nightly runs where serialization, routing, and subscription wiring must match production.

Shared outbox assertions

Both modes query the outbox the same way — the first assertion surface from Section 7:

var outboxEvents = await OutboxAssertions.GetPublishedEventsAsync<CatalogContext>(
    host.Fixture.Services,
    cancellationToken,
    nameof(ProductPriceChangedIntegrationEvent));

Assert.Single(outboxEvents);

Fixture wiring (unchanged hybrid shape)

The demo’s test fixtures branch on mode from the Aspire article. Messaging modes reuse the Aspire host and start RabbitMQ only in the broker fidelity mode:

_aspireHost = new CatalogAspireTestHost(testAssembly,
    includeRabbitMq: _mode == CatalogFunctionalTestMode.AspireMessagingRabbitMq);

var endpoints = await _aspireHost.StartAsync(
    waitForEventBus: _mode == CatalogFunctionalTestMode.AspireMessagingRabbitMq);

Section 9 applies these modes to two representative scenarios from the demo — a single-event update and a multi-event command.


9. In Action: messaging tests on the demo app

The examples below illustrate the patterns from Sections 7–9. They use the demo’s catalog and ordering APIs, but the structure — HTTP call, outbox assertion, bus assertion — transfers to any service that uses an outbox.

The tests follow the same shape in both modes: HTTP arrange, outbox check, then bus check (spy or capture handler). Only the RabbitMQ variants wait for asynchronous delivery.

Example 1 — single event after a data update

When a catalog item price changes, the demo expects one integration event. Spy bus test: update via HTTP, then assert outbox and spy.

[Fact]
[CatalogFunctionalTestMode(CatalogFunctionalTestMode.AspireMessagingOutbox)]
public async Task UpdateCatalogItem_PublishesPriceChangedEventToOutboxAndSpyBus()
{
    var host = await _session.CreateHostAsync(GetType(), nameof(UpdateCatalogItem_PublishesPriceChangedEventToOutboxAndSpyBus), handler);
    var capturingBus = host.Fixture.Services.GetRequiredService<CapturingEventBus>();
    capturingBus.Reset();

    var itemToUpdate = await GetCatalogItemAsync(host, 1);
    var originalPrice = itemToUpdate.Price;
    itemToUpdate.Price = originalPrice + 1.5m;

    var response = await host.Client.PutAsJsonAsync("/api/catalog/items", itemToUpdate, cancellationToken);
    response.EnsureSuccessStatusCode();

    var outboxEvents = await OutboxAssertions.GetPublishedEventsAsync<CatalogContext>(
        host.Fixture.Services, cancellationToken,
        nameof(ProductPriceChangedIntegrationEvent));

    Assert.Single(outboxEvents);
    Assert.Contains(capturingBus.Published, @event => @event is ProductPriceChangedIntegrationEvent priceChanged
        && priceChanged.ProductId == itemToUpdate.Id
        && priceChanged.NewPrice == itemToUpdate.Price
        && priceChanged.OldPrice == originalPrice);
}

RabbitMQ variant: same HTTP arrange; wait for one captured message; assert outbox and capture list.

Example 2 — multiple events in one transaction

Creating an order emits two integration events in one database transaction — a stronger check that the outbox handles multiple rows per commit.

[Fact]
[OrderingFunctionalTestMode(OrderingFunctionalTestMode.AspireMessagingOutbox)]
public async Task AddNewOrder_PublishesIntegrationEventsToOutboxAndSpyBus()
{
    var host = await _session.CreateHostAsync(GetType(), nameof(AddNewOrder_PublishesIntegrationEventsToOutboxAndSpyBus));
    var capturingBus = host.Fixture.Services.GetRequiredService<CapturingEventBus>();
    capturingBus.Reset();

    var response = await host.Client.PostAsync("api/orders", CreateOrderContent(), cancellationToken);
    response.EnsureSuccessStatusCode();

    var outboxEvents = await OutboxAssertions.GetPublishedEventsAsync<OrderingContext>(
        host.Fixture.Services, cancellationToken,
        nameof(OrderStartedIntegrationEvent),
        nameof(OrderStatusChangedToSubmittedIntegrationEvent));

    Assert.Equal(2, outboxEvents.Count);
    Assert.Contains(capturingBus.Published, @event => @event is OrderStartedIntegrationEvent started && started.UserId == "1");
    Assert.Contains(capturingBus.Published, @event => @event is OrderStatusChangedToSubmittedIntegrationEvent submitted && submitted.BuyerIdentityGuid == "1");
}

The RabbitMQ variant waits for two captured messages before the same assertions.

What these tests prove beyond API-only rungs

StepSingle-event updateMulti-event command
HTTP pipeline + handlerYesYes
Real Postgres + outbox schemaYesYes
Correct event types and payloadsYesYes (two events)
Event bus publish invokedSpy modeSpy + RabbitMQ mode
Broker serialization + consume pathRabbitMQ mode onlyRabbitMQ mode only

Compared with mock through Aspire, the last three rows are what messaging adds. Switch mode via runsettings or test traits — no changes to the test methods themselves.


10. Running, traits, and when to use which mode

Runsettings

# Spy bus + outbox (no RabbitMQ container)
dotnet test tests/Catalog.FunctionalTests --settings eShop.FunctionalTests.AspireMessagingOutbox.runsettings
dotnet test tests/Ordering.FunctionalTests --settings eShop.FunctionalTests.AspireMessagingOutbox.runsettings

# Real RabbitMQ
dotnet test tests/Catalog.FunctionalTests --settings eShop.FunctionalTests.AspireMessagingRabbitMq.runsettings
dotnet test tests/Ordering.FunctionalTests --settings eShop.FunctionalTests.AspireMessagingRabbitMq.runsettings

Runsettings set environment variables that select the messaging mode for each test project.

xUnit traits (filter in IDE or CI)

Trait valueMode
aspire-messaging-outboxSpy bus + outbox
aspire-messaging-rabbitmqReal RabbitMQ + capture handlers
dotnet test tests/Catalog.FunctionalTests --filter "FunctionalTestMode=aspire-messaging-outbox"

When to use which mode

ScenarioRecommended mode
Local dev — “did I emit the right event?”AspireMessagingOutbox
PR CI — outbox regression without broker costAspireMessagingOutbox
Nightly / pre-release — full messaging stackAspireMessagingRabbitMq
Debugging routing / serialization bugsAspireMessagingRabbitMq

Pros and cons

AspireMessagingOutboxAspireMessagingRabbitMq
ProsFaster; no broker flake; still tests outbox + publish pathExercises real broker adapter and handlers
ConsDoes not catch broker-specific failuresSlower cold start; timing-sensitive waits
DockerPostgres (+ sibling services when required)Postgres + RabbitMQ (+ siblings when required)

11. Conclusion

Messaging is the side effect behind the HTTP response. Earlier articles taught you to climb the ladder for databases and orchestration; this one teaches you to assert what the service announces to the rest of the system.

  • Message queues decouple services through async publish/subscribe.
  • RabbitMQ (behind an event bus abstraction) routes integration events through exchanges and typed handlers.
  • Transactional outbox keeps database writes and event records consistent.
  • Messaging tests differ because you assert outbox rows, bus behavior, and sometimes async delivery — not just status codes.
  • Two modes let you choose fidelity: a spy bus for speed, a real broker for realism — both on the Aspire hybrid host.

Across the series, the practical workflow stays the same: use Mock / InMemory for the local loop, Testcontainers when you need real database semantics, and Aspire (+ messaging) when orchestration and broker fidelity matter.


12. References

This series

Demo repo

Transactional outbox

RabbitMQ

Testing

Picture of Minh Kha Giai

Minh Kha Giai

I'm a Senior Automation Test Engineer with more than 7 years in software testing and development. I have experience in building various Automation Frameworks, creating pipelines for continuous integration, providing test approach and applying many testing types. My current research interests include Test Architecture, Mobile Automation, Payment and Financial Services, AI Tests, GIS and Image Processing

Leave a Comment

Suggested Article

Scroll to Top