RabbitMQ Testing with Testcontainers
RabbitMQ’s exchange-queue model, routing keys, dead-letter exchanges, and message TTL are features that cannot be tested with mocks. The broker’s routing logic — topic exchange patterns, fanout behavior, message acknowledgment, and requeue behavior on rejection — all require a real broker. This article covers RabbitMQ integration testing with RabbitMQContainer.
What You’ll Learn
RabbitMQContainersetup and@ServiceConnection- Configuring exchanges, queues, and bindings for tests
- Testing
@RabbitListenerconsumers with Awaitility - Testing dead-letter exchange and dead-letter queue routing
- Testing message TTL and automatic expiry
- Testing acknowledgment modes
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>rabbitmq</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
RabbitMQ Configuration
Define the exchange/queue topology in a configuration class:
@Configuration
public class RabbitMQConfiguration {
public static final String ORDER_EXCHANGE = "order.events";
public static final String ORDER_QUEUE = "order.processing";
public static final String ORDER_DLX = "order.dead-letter";
public static final String ORDER_DLQ = "order.dead-letter.queue";
public static final String ORDER_ROUTING_KEY = "order.created";
@Bean
public TopicExchange orderExchange() {
return new TopicExchange(ORDER_EXCHANGE);
}
@Bean
public TopicExchange deadLetterExchange() {
return new TopicExchange(ORDER_DLX);
}
@Bean
public Queue orderQueue() {
return QueueBuilder.durable(ORDER_QUEUE)
.withArgument("x-dead-letter-exchange", ORDER_DLX)
.withArgument("x-dead-letter-routing-key", "order.dead")
.build();
}
@Bean
public Queue deadLetterQueue() {
return QueueBuilder.durable(ORDER_DLQ).build();
}
@Bean
public Binding orderBinding(Queue orderQueue, TopicExchange orderExchange) {
return BindingBuilder.bind(orderQueue).to(orderExchange).with(ORDER_ROUTING_KEY);
}
@Bean
public Binding deadLetterBinding(Queue deadLetterQueue, TopicExchange deadLetterExchange) {
return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with("order.dead");
}
}
Producer and Consumer
@Service
public class OrderMessagePublisher {
private final RabbitTemplate rabbitTemplate;
public OrderMessagePublisher(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void publishOrderCreated(OrderEvent event) {
rabbitTemplate.convertAndSend(
RabbitMQConfiguration.ORDER_EXCHANGE,
RabbitMQConfiguration.ORDER_ROUTING_KEY,
event
);
}
}
@Service
public class OrderMessageConsumer {
private final List<OrderEvent> processedEvents = new CopyOnWriteArrayList<>();
private final List<OrderEvent> deadLetterEvents = new CopyOnWriteArrayList<>();
private boolean shouldFail = false;
@RabbitListener(queues = RabbitMQConfiguration.ORDER_QUEUE)
public void handleOrderEvent(OrderEvent event) {
if (shouldFail) {
throw new AmqpRejectAndDontRequeueException("Simulated failure");
}
processedEvents.add(event);
}
@RabbitListener(queues = RabbitMQConfiguration.ORDER_DLQ)
public void handleDeadLetter(OrderEvent event) {
deadLetterEvents.add(event);
}
public void setShouldFail(boolean shouldFail) { this.shouldFail = shouldFail; }
public List<OrderEvent> getProcessedEvents() { return processedEvents; }
public List<OrderEvent> getDeadLetterEvents() { return deadLetterEvents; }
}
Basic Test Setup
@SpringBootTest
@Testcontainers
class OrderMessageConsumerTest {
@Container
@ServiceConnection
static RabbitMQContainer rabbitmq =
new RabbitMQContainer("rabbitmq:3.12-management-alpine");
@Autowired
private OrderMessagePublisher publisher;
@Autowired
private OrderMessageConsumer consumer;
@BeforeEach
void reset() {
consumer.setShouldFail(false);
}
@Test
void shouldPublishAndConsumeOrderEvent() {
OrderEvent event = new OrderEvent(
"order-1", "customer-1", OrderStatus.PENDING,
BigDecimal.valueOf(99.99), LocalDateTime.now()
);
publisher.publishOrderCreated(event);
await()
.atMost(Duration.ofSeconds(10))
.until(() -> consumer.getProcessedEvents().stream()
.anyMatch(e -> e.orderId().equals("order-1"))
);
assertThat(consumer.getProcessedEvents()).hasSize(1);
assertThat(consumer.getProcessedEvents().get(0).customerId()).isEqualTo("customer-1");
}
}
Testing Dead-Letter Queue Routing
@Test
void shouldRouteRejectedMessageToDeadLetterQueue() {
// Tell the consumer to reject messages
consumer.setShouldFail(true);
OrderEvent event = new OrderEvent(
"order-fail", "customer-1", OrderStatus.PENDING,
BigDecimal.valueOf(49.99), LocalDateTime.now()
);
publisher.publishOrderCreated(event);
// Message should not appear in processed events
// It should be routed to the DLQ
await()
.atMost(Duration.ofSeconds(15))
.until(() -> consumer.getDeadLetterEvents().stream()
.anyMatch(e -> e.orderId().equals("order-fail"))
);
assertThat(consumer.getProcessedEvents()).isEmpty();
assertThat(consumer.getDeadLetterEvents())
.extracting(OrderEvent::orderId)
.contains("order-fail");
}
Testing Topic Exchange Routing
Topic exchanges route messages based on routing key patterns. Test that routing keys match the correct queues:
@Test
void shouldRouteMessagesToCorrectQueuesBasedOnRoutingKey() {
// Set up multiple queues for different order events
List<OrderEvent> createdEvents = new CopyOnWriteArrayList<>();
List<OrderEvent> shippedEvents = new CopyOnWriteArrayList<>();
// Direct template sends for specific routing keys
rabbitTemplate.convertAndSend(ORDER_EXCHANGE, "order.created",
new OrderEvent("order-1", "c1", OrderStatus.PENDING, BigDecimal.valueOf(99.99), LocalDateTime.now()));
rabbitTemplate.convertAndSend(ORDER_EXCHANGE, "order.shipped",
new OrderEvent("order-2", "c2", OrderStatus.SHIPPED, BigDecimal.valueOf(149.99), LocalDateTime.now()));
// Verify routing: order.created goes to ORDER_QUEUE, order.shipped does not match
await()
.atMost(Duration.ofSeconds(10))
.until(() -> consumer.getProcessedEvents().size() >= 1);
assertThat(consumer.getProcessedEvents())
.extracting(OrderEvent::orderId)
.contains("order-1")
.doesNotContain("order-2"); // order.shipped doesn't match "order.created" routing key
}
Testing with the Management API
RabbitMQContainer includes the management plugin by default. Use it to inspect broker state from tests:
@Test
void shouldVerifyQueueExistsWithCorrectConfiguration() throws Exception {
String managementUrl = "http://" + rabbitmq.getHost() + ":"
+ rabbitmq.getMappedPort(15672) + "/api/queues/%2F/"
+ RabbitMQConfiguration.ORDER_QUEUE;
// Use Spring's RestTemplate or RestClient to call the management API
RestTemplate rest = new RestTemplate();
rest.getInterceptors().add((req, body, exec) -> {
req.getHeaders().setBasicAuth(rabbitmq.getAdminUsername(), rabbitmq.getAdminPassword());
return exec.execute(req, body);
});
ResponseEntity<Map> response = rest.getForEntity(managementUrl, Map.class);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
Map<String, Object> queueInfo = response.getBody();
assertThat(queueInfo).isNotNull();
assertThat(queueInfo.get("durable")).isEqualTo(true);
Map<String, Object> arguments = (Map<String, Object>) queueInfo.get("arguments");
assertThat(arguments).containsEntry("x-dead-letter-exchange", ORDER_DLX);
}
Common Pitfalls
Not using AmqpRejectAndDontRequeueException in consumer failure scenarios. If you throw a generic exception, Spring AMQP will retry the message by default (requeueing it). For DLQ testing, throw AmqpRejectAndDontRequeueException to send the message to the DLQ immediately.
RabbitMQ startup time. RabbitMQ with the management plugin takes 5–15 seconds to start. The default wait strategy uses log message detection. If you see connection failures, increase the startup timeout.
Exchange/queue auto-declaration order. Exchanges and queues declared via @Bean are created when the application context loads. If the connection is not ready by then, RabbitMQ will retry automatically. With Testcontainers, the container should be ready before the context loads, so this is rarely a problem.
Summary
RabbitMQContainer gives you a real RabbitMQ broker for integration tests. Exchange routing, dead-letter queue configuration, topic exchange patterns, and message acknowledgment all test correctly against the real broker. Awaitility handles async consumer assertion. The management plugin enables broker state inspection from tests.
The next article covers Redis testing — Spring Cache, Spring Session, and distributed locks.