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

  • RabbitMQContainer setup and @ServiceConnection
  • Configuring exchanges, queues, and bindings for tests
  • Testing @RabbitListener consumers 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.

Next: Redis Testing with Testcontainers