RabbitMQ Integration Testing — Local and CI Strategies
RabbitMQ integration tests need a real broker to catch routing failures, binding misconfiguration, and dead-letter queue behavior that mocks will never surface. This guide covers testing exchanges, queues, bindings, and DLQs with Testcontainers — for both local development and CI pipelines.
Key Takeaways
Test the routing topology, not just message delivery. A message reaching the right consumer tells you delivery works. Testing with the wrong routing key deliberately tells you your dead-letter configuration works.
Use the management API to inspect broker state. RabbitMQ's HTTP API lets you assert queue depth, binding existence, and message rates directly from tests — no consumer required.
Dead-letter queues need dedicated tests. DLQ routing is configured at queue creation time. A queue that silently drops rejected messages instead of routing to a DLQ is a production incident waiting to happen.
One @Container per test class saves startup cost. RabbitMQ initializes faster than Kafka, but reusing the container across a test class still saves 3–5 seconds per test.
Test nack behavior explicitly. Consumers that nack with requeue=false should route to the DLQ. Consumers that nack with requeue=true re-enqueue. If you never test nack, you don't know which path your code actually takes.
RabbitMQ's flexibility is also its test surface. Unlike Kafka's append-only log model, RabbitMQ routes messages through exchanges via binding rules. A direct exchange, a topic exchange, and a fanout exchange all behave differently. Headers exchanges are their own universe. The routing topology is configuration — and configuration bugs are silent until they're in production.
The goal of integration testing with RabbitMQ is to run your application's channel, exchange, queue, and binding setup against a real broker, publish messages, consume them, and assert on outcomes. Testcontainers makes this self-contained and CI-friendly.
Dependencies
For Java with Spring AMQP:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>rabbitmq</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.rabbitmq</groupId>
<artifactId>amqp-client</artifactId>
<version>5.20.0</version>
</dependency>For Python with pika:
testcontainers[rabbitmq]==3.7.6
pika==1.3.2
requests==2.31.0
pytest==8.1.0Container Setup
Testcontainers ships a RabbitMQContainer that wraps the official rabbitmq:management image. The management image exposes port 15672 for the HTTP API — useful for assertions.
@Testcontainers
class RabbitMQIntegrationTest {
@Container
static final RabbitMQContainer rabbit = new RabbitMQContainer(
DockerImageName.parse("rabbitmq:3.13-management")
).withExposedPorts(5672, 15672);
private Connection connection;
private Channel channel;
@BeforeEach
void setUp() throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost(rabbit.getHost());
factory.setPort(rabbit.getMappedPort(5672));
factory.setUsername(rabbit.getAdminUsername());
factory.setPassword(rabbit.getAdminPassword());
connection = factory.newConnection();
channel = connection.createChannel();
}
@AfterEach
void tearDown() throws Exception {
channel.close();
connection.close();
}
}Testing Exchange and Queue Binding
A direct exchange routes messages to queues whose binding key exactly matches the routing key. Let's test that messages go where they're supposed to:
@Test
void directExchangeRoutesMessageToCorrectQueue() throws Exception {
String exchange = "orders";
String routingKey = "order.created";
String queue = "order-processing";
// Declare topology
channel.exchangeDeclare(exchange, "direct", true);
channel.queueDeclare(queue, true, false, false, null);
channel.queueBind(queue, exchange, routingKey);
// Publish
String message = "{\"orderId\":\"42\",\"status\":\"CREATED\"}";
channel.basicPublish(exchange, routingKey, null, message.getBytes());
// Consume and assert
List<String> received = new ArrayList<>();
channel.basicConsume(queue, true, (tag, delivery) ->
received.add(new String(delivery.getBody())),
tag -> {}
);
await().atMost(5, SECONDS).until(() -> !received.isEmpty());
assertThat(received).hasSize(1);
assertThat(received.get(0)).contains("order-42");
}Now test the negative case — a message with the wrong routing key should not reach the queue:
@Test
void directExchangeDoesNotRouteToUnmatchedQueue() throws Exception {
String exchange = "orders";
channel.exchangeDeclare(exchange, "direct", true);
channel.queueDeclare("order-processing", true, false, false, null);
channel.queueBind("order-processing", exchange, "order.created");
// Publish with wrong routing key
channel.basicPublish(exchange, "order.deleted", null,
"{\"orderId\":\"99\"}".getBytes());
// Queue should remain empty
Thread.sleep(500); // Brief wait — we're asserting absence, not presence
long messageCount = channel.messageCount("order-processing");
assertThat(messageCount).isZero();
}For testing absence, a short sleep is acceptable. You cannot poll for the absence of something — the absence is the assertion after a reasonable wait.
Topic Exchange Testing
Topic exchanges route by pattern matching. order.* matches order.created and order.deleted. order.# matches order.created.us and order.created.eu.west. Bugs here are easy to introduce and hard to spot in code review.
@Test
void topicExchangeRoutesWildcardPatterns() throws Exception {
String exchange = "events";
channel.exchangeDeclare(exchange, "topic", true);
// Two queues with different binding patterns
channel.queueDeclare("us-orders", true, false, false, null);
channel.queueBind("us-orders", exchange, "order.*.us");
channel.queueDeclare("all-orders", true, false, false, null);
channel.queueBind("all-orders", exchange, "order.#");
// Publish
channel.basicPublish(exchange, "order.created.us", null,
"msg1".getBytes());
channel.basicPublish(exchange, "order.created.eu", null,
"msg2".getBytes());
List<String> usMessages = consumeAll("us-orders", 2000);
List<String> allMessages = consumeAll("all-orders", 2000);
// "order.created.us" matches both patterns
// "order.created.eu" matches only "order.#"
assertThat(usMessages).hasSize(1);
assertThat(allMessages).hasSize(2);
}
private List<String> consumeAll(String queue, long waitMillis) throws Exception {
List<String> messages = new ArrayList<>();
channel.basicConsume(queue, true,
(tag, delivery) -> messages.add(new String(delivery.getBody())),
tag -> {});
Thread.sleep(waitMillis);
return messages;
}Dead-Letter Queue Testing
Dead-letter queues are the part of RabbitMQ that most teams configure once and never test. The DLQ is configured via queue arguments at declaration time. If you get those arguments wrong, rejected messages disappear silently.
@Test
void rejectedMessageRoutesToDeadLetterQueue() throws Exception {
String dlxExchange = "orders.dlx";
String dlq = "orders.dead";
String mainQueue = "orders.main";
String exchange = "orders";
// Set up DLX infrastructure
channel.exchangeDeclare(dlxExchange, "fanout", true);
channel.queueDeclare(dlq, true, false, false, null);
channel.queueBind(dlq, dlxExchange, "");
// Declare main queue with DLX configured
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", dlxExchange);
channel.queueDeclare(mainQueue, true, false, false, args);
channel.exchangeDeclare(exchange, "direct", true);
channel.queueBind(mainQueue, exchange, "order");
// Publish a message
channel.basicPublish(exchange, "order", null,
"{\"orderId\":\"bad-order\"}".getBytes());
// Consumer nacks with requeue=false (simulating processing failure)
channel.basicConsume(mainQueue, false, (tag, delivery) -> {
// Simulate a processing error
channel.basicNack(delivery.getEnvelope().getDeliveryTag(),
false, false); // requeue=false → goes to DLX
}, tag -> {});
// Assert message arrives in DLQ
List<String> deadMessages = new ArrayList<>();
channel.basicConsume(dlq, true,
(tag, delivery) -> deadMessages.add(new String(delivery.getBody())),
tag -> {});
await().atMost(5, SECONDS).until(() -> !deadMessages.isEmpty());
assertThat(deadMessages).hasSize(1);
assertThat(deadMessages.get(0)).contains("bad-order");
}This test would fail if the DLX argument were missing or spelled incorrectly. Without it, basicNack with requeue=false drops the message entirely.
Using the Management API for State Assertions
RabbitMQ's HTTP management API is a powerful assertion tool that most developers overlook. You can query queue depth, check binding existence, and inspect exchange configuration — without consuming any messages.
private int getQueueDepth(String vhost, String queue) throws Exception {
String url = "http://" + rabbit.getHost() + ":" +
rabbit.getMappedPort(15672) +
"/api/queues/" + URLEncoder.encode(vhost, "UTF-8") +
"/" + URLEncoder.encode(queue, "UTF-8");
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", basicAuth(
rabbit.getAdminUsername(),
rabbit.getAdminPassword()))
.GET()
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode node = new ObjectMapper().readTree(response.body());
return node.get("messages").asInt();
}Use this to assert that a consumer has drained a queue, or that published messages are accumulating as expected before a consumer starts:
@Test
void publisherEnqueuesMessagesBeforeConsumerStarts() throws Exception {
channel.queueDeclare("batch-jobs", true, false, false, null);
for (int i = 0; i < 10; i++) {
channel.basicPublish("", "batch-jobs", null,
("job-" + i).getBytes());
}
// Verify queue depth via management API
await().atMost(3, SECONDS).untilAsserted(() ->
assertThat(getQueueDepth("/", "batch-jobs")).isEqualTo(10)
);
}Python Integration Tests
Using pika with pytest and the same Testcontainers setup:
import pytest
import pika
import json
import time
from testcontainers.rabbitmq import RabbitMqContainer
@pytest.fixture(scope="module")
def rabbit():
with RabbitMqContainer("rabbitmq:3.13-management") as container:
yield container
@pytest.fixture
def channel(rabbit):
credentials = pika.PlainCredentials(
rabbit.RABBITMQ_DEFAULT_USER,
rabbit.RABBITMQ_DEFAULT_PASS
)
params = pika.ConnectionParameters(
host=rabbit.get_container_host_ip(),
port=rabbit.get_exposed_port(5672),
credentials=credentials
)
connection = pika.BlockingConnection(params)
ch = connection.channel()
yield ch
ch.close()
connection.close()
def test_fanout_exchange_broadcasts_to_all_queues(channel):
channel.exchange_declare("notifications", exchange_type="fanout")
channel.queue_declare("email-notifs")
channel.queue_declare("sms-notifs")
channel.queue_bind("email-notifs", "notifications", "")
channel.queue_bind("sms-notifs", "notifications", "")
channel.basic_publish(
exchange="notifications",
routing_key="",
body=json.dumps({"type": "alert", "text": "Server down"})
)
time.sleep(0.5)
_, _, email_body = channel.basic_get("email-notifs", auto_ack=True)
_, _, sms_body = channel.basic_get("sms-notifs", auto_ack=True)
assert email_body is not None
assert sms_body is not None
assert json.loads(email_body)["type"] == "alert"CI Configuration
RabbitMQ starts faster than Kafka, so the CI overhead is lower. Still, warm the image before tests:
name: RabbitMQ Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Pull RabbitMQ image
run: docker pull rabbitmq:3.13-management
- name: Run tests
run: mvn test -pl :rabbitmq-integration-testsIf you prefer running a real RabbitMQ service rather than Testcontainers in CI (some teams find this simpler for GitLab pipelines), use the service container:
rabbitmq-tests:
image: maven:3.9-eclipse-temurin-21
services:
- name: rabbitmq:3.13-management
alias: rabbitmq
variables:
RABBITMQ_HOST: rabbitmq
RABBITMQ_PORT: "5672"
script:
- mvn test -Drabbitmq.host=$RABBITMQ_HOST -Drabbitmq.port=$RABBITMQ_PORTThe trade-off: service containers run for the full job lifetime and don't support the dynamic port mapping that Testcontainers provides. If you have multiple jobs, each gets its own RabbitMQ instance, which is actually a benefit for isolation.
Testing With Spring AMQP
Spring Boot apps using spring-boot-starter-amqp can use @SpringBootTest with a dynamic bootstrap configuration:
@SpringBootTest
@Testcontainers
class OrderListenerIntegrationTest {
@Container
static final RabbitMQContainer rabbit =
new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.13-management"));
@DynamicPropertySource
static void rabbitProperties(DynamicPropertyRegistry registry) {
registry.add("spring.rabbitmq.host", rabbit::getHost);
registry.add("spring.rabbitmq.port", rabbit::getAmqpPort);
registry.add("spring.rabbitmq.username", rabbit::getAdminUsername);
registry.add("spring.rabbitmq.password", rabbit::getAdminPassword);
}
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
OrderRepository orderRepository;
@Test
void listenerPersistsOrderOnMessage() {
rabbitTemplate.convertAndSend("orders.exchange", "order.created",
new OrderCreatedEvent("order-789", 150.00));
await().atMost(10, SECONDS).untilAsserted(() ->
assertThat(orderRepository.findById("order-789")).isPresent()
);
}
}@DynamicPropertySource injects the container's host and port into the Spring application context before the context starts. This gives you a fully wired Spring application talking to a real RabbitMQ — no mocking, no in-memory stubs.
Common Pitfalls
Vhost isolation. By default, all queues and exchanges share the / vhost. In tests, this is fine. In production, different services often use separate vhosts for isolation. If your tests use / but production uses /payments, you may have untested vhost-level permission issues. Add a vhost to your container setup to match production.
Message TTL and queue TTL. If your queues have x-message-ttl or x-expires configured, messages can expire before your test consumer reads them. Always set timeouts shorter than your test TTLs.
Connection reset between tests. Creating a new connection per test is slow but guarantees clean channel state. Reusing a connection across tests is faster but requires careful cleanup — especially if a test leaves unacked messages or unconsumed queues.
Prefetch count. Default channel prefetch is unlimited. In production, your consumers likely set basic.qos to limit in-flight messages. Test with the same prefetch count to catch backpressure behavior.
RabbitMQ integration tests pay their startup cost once and give you confidence in the entire routing path — from exchange declaration through binding rules to dead-letter handling. That's coverage that no unit test of your consumer logic can provide.