一、先得搞清楚:集成测试里为什么总会碰到消息队列的坑
很多朋友写单元测试的时候,逻辑都通得很顺。可一旦做到集成测试,麻烦就来了。因为你的服务里可能用到了消息队列,比如订单下了单要发一条消息出去,积分服务要监听这条消息。测试的时候,消息队列如果没配好,服务根本起不来,或者消息发不出去,测试就卡在那里了,半天找不到原因。
更让人头疼的是,不同项目用的中间件还不一样。有的用RabbitMQ,有的用Kafka,还有的用RocketMQ。每个中间件的连接方式、配置项、测试方法都不太一样。你今天给RabbitMQ配好了,明天换Kafka,又得从头折腾一遍。所以这篇文章想说的,就是怎么把这块依赖配置搞清楚,让集成测试能轻轻松松适配不同的消息中间件。我们不会讲太高深的理论,就用大家平时写代码的视角,一步步来。
1.1 别把消息队列当数据库来测
有些人习惯把消息队列当成数据库来测试,直接连上,然后往里面塞数据,再读出来。这么做不是不行,但会忽略一个关键点:消息队列是异步的。也就是说,你发了一条消息,它不会像数据库写一条记录那样立刻返回成功。中间要经过网络、交换机、队列、消费者的很多环节。测试的时候,必须把异步变成同步,去等待最终结果,否则就会出现“明明发了消息,测试却没过”的假象。
1.2 连接信息本身就是一个大坑
RabbitMQ有host、port、username、password、vhost。Kafka有bootstrap.servers、group.id、acks。RocketMQ有nameServer、producerGroup。这些参数在测试环境里,很可能和开发环境不一样。如果代码里硬编码了localhost,测试环境就必然搞不定。所以第一步,要把这些配置从代码里抽离出来,放到独立的配置文件里去管理。
二、先拿RabbitMQ给大家演示一套完整例子
为了让大家看得明白,我们用Java的Spring Boot来写。这套代码在本地怎么跑,集成测试怎么搞,都一步一步展示出来。技术栈就统一用:Java 17 + Spring Boot 3.x + Spring AMQP + JUnit 5。
2.1 项目的基础依赖
在pom.xml里加依赖,这个是Java项目的常规操作。我们加入spring-boot-starter-amqp和spring-boot-starter-test。注意,后面如果要换Kafka,我们只需要把amqp换成kafka,业务代码尽量不动。
<!-- 技术栈: Java Maven + Spring Boot -->
<dependencies>
<!-- 消息队列依赖,这里先引入RabbitMQ的 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- 测试依赖,JUnit 5那一套都在里面 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
这里要解释一下,Spring Boot的starter-test会把JUnit 5、AssertJ之类的测试库都带进来,所以不需要再单独加什么。而spring-boot-starter-amqp是我们连接RabbitMQ的基础,里面包含了Spring AMQP和Rabbit客户端。
2.2 消息发送与监听的代码
我们定义一个订单服务,下单后发送一条消息到“order-exchange”这个交换机,路由键是“order.created”。然后我们写一个消费者,监听队列“order.created.queue”,把消息内容打印出来。这样测试的时候就能看到消息有没有真的走通。
下面这段Java代码就是完整的一套发送和接收:
// 技术栈: Java + Spring Boot + Spring AMQP
package com.example.mqtest;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
// RabbitTemplate 是Spring AMQP里负责发消息的类,放心用
private final RabbitTemplate rabbitTemplate;
public OrderService(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
// 下单方法:真正项目里这里的入参可能是一个订单对象
public void createOrder(String orderId) {
// 模拟业务逻辑,比如写数据库之类
System.out.println("下单成功,订单号:" + orderId);
// 发一条消息到交换机,路由键是 order.created,消息内容是订单号
rabbitTemplate.convertAndSend("order-exchange", "order.created", orderId);
}
// 消费者:监听队列 order.created.queue
@RabbitListener(queues = "order.created.queue")
public void handleOrderCreated(String orderId) {
// 收到消息后,这里可以做一些异步处理,比如发积分、发短信
System.out.println("收到新订单消息,订单号:" + orderId);
}
}
// 配置交换机、队列、绑定关系的类
@Configuration
public class RabbitConfig {
@Bean
public DirectExchange orderExchange() {
// 创建一个direct类型的交换机,名字和发送时保持一致
return new DirectExchange("order-exchange");
}
@Bean
public Queue orderCreatedQueue() {
// 创建一个队列,队列名和@RabbitListener里的保持一致
return new Queue("order.created.queue", true);
}
@Bean
public Binding binding(Queue orderCreatedQueue, DirectExchange orderExchange) {
// 把队列绑定到交换机,路由键是order.created
return BindingBuilder.bind(orderCreatedQueue).to(orderExchange).with("order.created");
}
}
大家注意,上面这段代码里面,我们没有把任何连接信息写死。连接信息全部放到application.yml里,后面要切换中间件,只要改配置就行。
2.3 集成测试怎么设计
集成测试的思路是:启动一个真实的Spring Boot应用,让它连上我们测试用的RabbitMQ。然后我们调用createOrder方法,再尝试去接收那条消息,看能不能消费到。这里我们不能像单元测试那样直接断言方法的返回值,因为下单成功不代表消息发成功。我们得去队列里拿消息,或者等消费者的回调。
下面是一个比较稳的测试写法:
// 技术栈: Java + Spring Boot + JUnit 5 + Spring AMQP
package com.example.mqtest;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
public class OrderIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
void testOrderMessageSent() throws InterruptedException {
// 先调用下单服务,这会触发消息发送
orderService.createOrder("10001");
// 然后我们主动从队列里拉一条消息,而不只是等监听器去消费
// 这里设置一个超时,避免无限等待
String received = null;
for (int i = 0; i < 10; i++) {
// receive方法从队列取一条消息,如果没有会返回null
received = (String) rabbitTemplate.receiveAndConvert("order.created.queue");
if (received != null) {
break;
}
// 每200毫秒重试一次,总共等待2秒
TimeUnit.MILLISECONDS.sleep(200);
}
// 如果能拿到这条消息,说明消息队列整个链路是通的
assertNotNull(received, "应该能在队列里收到消息");
System.out.println("测试收到的消息:" + received);
}
}
这里有一个特别注意的地方:因为RabbitTemplate的receiveAndConvert会真的把队列里的消息取走,如果监听器先抢到了消息,测试里的receive可能拿到null。这会造成测试不稳定。所以更推荐的用法是,在测试里不启动监听器,或者给消费者单独用一个队列。这只是个小坑,大家注意一下就行。
2.4 用Testcontainers自动启动一个RabbitMQ
很多人的本地机器上其实并没有装RabbitMQ,那怎么办?我们可以用一个叫Testcontainers的Java库,它能在测试的时候自动用Docker启动一个RabbitMQ容器,测试结束自动关掉。这样团队每个人跑出来的环境一模一样,不会出现“我本地明明好的,你那里怎么不行”的问题。
先加依赖:
<!-- 技术栈: Java Maven + Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>rabbitmq</artifactId>
<version>1.19.8</version>
<scope>test</scope>
</dependency>
然后写一个专门针对RabbitMQ的测试启动类,让Spring Boot在测试时直接连上容器。下面是完整的示例:
// 技术栈: Java + JUnit 5 + Testcontainers
package com.example.mqtest;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
@Testcontainers
public class RabbitMqContainerTest {
// 这里指定了要用的RabbitMQ镜像版本,也可以用 "rabbitmq:3-management"
@Container
static RabbitMQContainer rabbit = new RabbitMQContainer("rabbitmq:3-management");
@DynamicPropertySource
static void configureprops(DynamicPropertyRegistry registry) {
// 把容器里的host和amqp端口动态配置到Spring Boot里
registry.add("spring.rabbitmq.host", rabbit::getHost);
registry.add("spring.rabbitmq.port", rabbit::getAmqpPort);
// 默认用户名密码就是guest,这里也可以不写
registry.add("spring.rabbitmq.username", rabbit::getAdminUsername);
registry.add("spring.rabbitmq.password", rabbit::getAdminPassword);
}
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
void testSendAndReceive() {
// 发送一条消息到默认交换机,路由键是test.queue
rabbitTemplate.convertAndSend("", "test.queue", "hello-container");
String msg = null;
for (int i = 0; i < 5; i++) {
msg = (String) rabbitTemplate.receiveAndConvert("test.queue");
if (msg != null) break;
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
assertNotNull(msg);
System.out.println("从容器队列里面拿到的消息:" + msg);
}
}
你看,这样就不用提前装任何中间件,只要机器上有Docker,测试就能跑起来。而且代码里面配置是动态拿到的,根本不需要关心本机端口冲不冲突。
三、怎么把RabbitMQ的例子换成Kafka
很多项目后来把消息中间件从RabbitMQ换成了Kafka。如果代码写得乱七八糟,那就得重写一遍。如果配置和业务分离,其实改动很小。
3.1 配置文件分开写
我们准备两个配置文件,一个叫application-rabbit.yml,一个叫application-kafka.yml。然后通过Spring的Profile来决定用哪个。先看Rabbit的配置:
# 技术栈: Java Spring Boot 配置文件
spring:
rabbitmq:
host: ${MQ_HOST:localhost}
port: ${MQ_PORT:5672}
username: ${MQ_USER:guest}
password: ${MQ_PASS:guest}
virtual-host: ${MQ_VHOST:/}
再看Kafka的配置:
# 技术栈: Java Spring Boot 配置文件
spring:
kafka:
bootstrap-servers: ${KAFKA_SERVERS:localhost:9092}
consumer:
group-id: order-test-group
auto-offset-reset: earliest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
注意,这里我们把host、port都做成了环境变量,在测试环境里可以直接通过环境变量去覆盖。这是最推荐的做法,因为同一个配置文件可以适配本地、测试服务器、CI流水线,只要环境变量不一样就行。
3.2 代码层面要做抽象
虽然我在这里没有给出完整的Kafka发送代码,但如果你想让同一套Service代码既能跑RabbitMQ又能跑Kafka,可以定义一个消息发布接口,然后分别实现RabbitMqPublisher和KafkaPublisher。这样业务代码只依赖接口,换中间件只是换个实现类的事。下面是一个接口定义,非常简单:
// 技术栈: Java + Spring Boot
public interface MessagePublisher {
/**
* 发送消息,不管底层是Rabbit还是Kafka,方法签名都一样
*
* @param exchangeOrTopic 交换机名或topic名
* @param routingKeyOrKey 路由键或消息key
* @param payload 消息内容
*/
void publish(String exchangeOrTopic, String routingKeyOrKey, Object payload);
}
然后针对RabbitMQ的实现:
// 技术栈: Java + Spring Boot + Spring AMQP
@Service
public class RabbitMqPublisher implements MessagePublisher {
private final RabbitTemplate rabbitTemplate;
public RabbitMqPublisher(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
@Override
public void publish(String exchange, String routingKey, Object payload) {
rabbitTemplate.convertAndSend(exchange, routingKey, payload);
}
}
这样当你想换Kafka的时候,只需要再写一个KafkaPublisher的实现,然后通过@ConditionalOnProperty之类的注解,让Spring在kafka profile下加载这个实现。具体细节不展开,但思路大家应该能懂。顺便提一句,Spring Cloud Stream这个框架其实就是为了做这件事而生的,它把不同中间件的接入方式又统一了一把。不过那个东西有点重,大家按需使用。
3.3 测试环境如何选择中间件
在跑集成测试的时候,你只需要告诉Spring用哪个profile就行。比如在Maven里跑:
# 技术栈: Shell 命令
mvn test -Dspring.profiles.active=rabbit
如果想要用Kafka跑,就改成:
# 技术栈: Shell 命令
mvn test -Dspring.profiles.active=kafka
是不是很方便?当然,前提是测试环境真的有对应的服务。如果本地没有RabbitMQ、也没有Kafka,那就用前面说到的Testcontainers。Testcontainers也支持Kafka,用法差不多,这里就不再重复写一遍代码了,免得文章变得又臭又长。你只要记住一个核心原则:业务代码和连接配置彻底分开,测试环境里通过配置决定连什么中间件。
四、真实项目中的应用场景、优缺点和注意事项
4.1 应用场景
消息队列依赖配置这件事,在下面几个场景里特别重要。
首先是个人本地开发。很多小伙伴从公司clone下来一个项目,本地没装RabbitMQ,一跑测试就报连接拒绝。如果你用了配置隔离,并且能用容器来启动中间件,那么只需要一条命令就能把依赖拉起来。
其次是团队的CI流水线。在GitLab或GitHub Actions里跑集成测试,通常不希望去安装一个固定的中间件服务,这样既慢又不稳定。最好是在测试脚本里动态启动一个消息队列容器,测试结束再关掉。这样每次都是干净的环境,也不会污染开发用的队列。
最后是一个项目同时对接多个中间件的情况。比如消息系统还在从RabbitMQ迁移到Kafka的过程中,两个中间件都在用。这时候,测试就要能够分别针对两个中间件跑一遍,配置切换能力就特别有用了。你只要在流水线里把同一套测试跑两遍,一遍带上rabbit的profile,一遍带上kafka的profile,就知道两边是不是都正常。
4.2 技术优缺点
直接连本地安装的MQ,优点是很简单,不用额外启动容器,缺点是你得保证机器上装了一模一样的版本。版本都不一样的话,测试结果可能骗人。比如RabbitMQ 3.x和4.x在虚拟主机特性上就有一点点差别,本地跑得好好的,CI里用另一个版本可能就有怪问题。
使用Testcontainers启动容器,优点是可以做到临时、干净、版本可控,缺点之一是首次拉镜像会比较慢,尤其在公司网络环境下可能要等几分钟。缺点之二是测试机必须能访问Docker守护进程,有些CI环境里需要额外配置。
使用Spring Profile切换配置,优点是代码不需要大变,缺点是需要维护好几套配置。如果中间件参数很多,配置也有点繁琐。直接把连接信息写死是最不推荐的,看起来当时省事,换环境就要了老命。
4.3 需要特别注意的细节
第一,消息队列的容器化测试,不要用生产级别的配置。比如Kafka,单节点模拟不了生产环境的多分区多副本,但集成测试本来就是验证“业务逻辑+配置是否走得通”,不是压测,所以单节点完全够用。别一上来就整三节点的Kafka集群,测试时间会大幅增加。
第二,测试里消费消息要设超时。异步系统的通病就是“等不起”。你永远不知道消息会不会在网络里卡一下。所以一定要用循环加休眠,或者直接使用awaitility这种专门的测试库。千万别用那种“睡十秒再断言”的写法,那样不但慢,而且很容易出现竞态。
第三,注意消息重复消费的问题。在很多中间件里,消费端默认的提交策略可能导致同一消息被消费两次。测试断言别写得那么死,比如你往队列里发了一条消息,正常应该只消费一次,但有时候因为重试机制会多消费几次,所以最好别用“消费次数等于1”这种断言,可以放宽到“大于等于1”。
第四,清理测试数据。集成测试结束后,最好把队列、topic里的残留消息清掉。否则下一次测试可能会拿到上一次没消费完的消息,导致断言错误。尤其是你用了持久化队列的时候,残留消息会一直在那里。建议在测试开始时先把队列清空,或者给每个测试方法一个唯一的队列名。
五、总结
说到底,集成测试里的消息队列依赖配置就是两件事:第一,把连接信息从代码里彻底抽离出来,用配置文件加环境变量的方式管理。第二,根据你的中间件类型,选择合适的测试运行方式。用RabbitMQ就用Spring AMQP的路由,用Kafka就用Spring Kafka的模板,最好再通过一个发布接口把两者都罩住。这样一来,不管是本地调试还是流水线测试,你都能轻松切换,不再为“换了个中间件就得改代码”这种破事烦心。
在实际动手搭的时候,你会发现最大的坑往往不在代码,而在于你测试时到底连的是哪个服务。如果条件允许,用配置文件Profile加容器化临时中间件这两种手段配合在一起,基本可以解决你遇到的大部分问题。好了,今天就聊到这儿,希望这些经验能帮大家少走几步弯路。
评论
围绕“集成测试中消息队列依赖配置:适配不同消息中间件的测试环境搭建技巧”参与讨论