霸王餐接口单元测试:Testcontainers启动Redis/MongoDB多容器联动
背景:为什么要在单元测试里跑真Redis/MongoDB
霸王餐核心接口重度依赖Redis分布式锁与MongoDB账单,Mock返存后曾出现“锁超时但测试通过”的生产事故。团队引入Testcontainers,在JUnit5生命周期内拉取官方镜像,启动真实容器,测试结束自动清理,既保留Mock速度,又拿到与生产一致的语义。整套方案本地&CI零配置,平均单条测试耗时1.3s,比Embedded Redis节省40%内存,同时覆盖Lua脚本、事务、TTL、二级索引等特性。

依赖与版本:Spring Boot 3.2 + JUnit5 + Testcontainers 1.19
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.redis</groupId>
<artifactId>testcontainers-redis</artifactId>
<version>2.0.0</version>
<scope>test</scope>
</dependency>
全局容器管理:一个测试类生命周期内复用
package juwatech.cn.bwc.test.config;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
public abstract class AbstractContainerBase {
static final MongoDBContainer MONGO;
static final GenericContainer<?> REDIS;
static {
MONGO = new MongoDBContainer(DockerImageName.parse("mongo:6"))
.withReuse(true); // 配合~/.testcontainers.properties开启重用
REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379)
.withReuse(true);
MONGO.start();
REDIS.start();
}
static String mongoUri() {
return MONGO.getConnectionString();
}
static String redisUri() {
return "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379);
}
}
@Testcontainers注解会在JUnit5扩展里自动监听容器生命周期,测试并行执行时也能保证单例。
动态属性注入:Spring Boot测试环境零配置
@TestConfiguration
public class ContainerPropertyOverride implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext ctx) {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(ctx,
"spring.data.mongodb.uri=" + AbstractContainerBase.mongoUri(),
"spring.redis.url=" + AbstractContainerBase.redisUri()
);
}
}
在测试类头加@ContextConfiguration(initializers = ContainerPropertyOverride.class),应用层代码零感知,本地&CI共用同一套application.yml。
典型场景测试:分布式锁+账单写入联动
@DataMongoTest
@AutoConfigureWebMvc
@ContextConfiguration(initializers = ContainerPropertyOverride.class)
class OrderBillLockIT extends AbstractContainerBase {
@Autowired
private OrderService orderService;
@Autowired
private StringRedisTemplate redis;
@Test
@DisplayName("同一订单并发请求仅写入一次账单")
void concurrentCreateShouldLock() throws Exception {
String orderNo = "OC" + System.nanoTime();
int threads = 10;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch latch = new CountDownLatch(threads);
AtomicInteger success = new AtomicInteger(0);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
try {
OrderDTO dto = orderService.create(orderNo, 123L);
if (dto != null) success.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
assertThat(success.get()).isEqualTo(1);
// 账单仅一条
assertThat(orderService.countBillByOrderNo(orderNo)).isOne();
// 锁已释放
assertThat(redis.opsForValue().get("lock:" + orderNo)).isNull();
}
}
OrderService内使用Redis Lua脚本原子抢锁,成功后在MongoDB插入账单;测试用10线程并发,验证锁语义与数据一致性。
Lua脚本测试:保证EVALSHA与宕机重放一致
@Test
void luaLockShouldExpire() {
String key = "testLock";
String uuid = UUID.fastUUID().toString();
Boolean ok = redis.execute(RedisScript.of(
"if redis.call('set',KEYS[1],ARGV[1],'NX','PX',ARGV[2]) then return 1 else return 0 end",
Boolean.class), List.of(key), uuid, 1000);
assertThat(ok).isTrue();
Awaitility.await().timeout(Duration.ofMillis(1100))
.untilAsserted(() -> assertThat(redis.hasKey(key)).isFalse());
}
Testcontainers Redis与生产版本一致,Lua语法、TTL语义均可验证。
MongoDB二级索引:容器初始化即创建
@TestConfiguration
public class MongoIndexCreator {
@Autowired
private MongoTemplate mongo;
@PostConstruct
public void initIndex() {
mongo.indexOps(OrderBill.class)
.ensureIndex(new Index().on("orderNo", Sort.Direction.ASC).unique());
mongo.indexOps(OrderBill.class)
.ensureIndex(new Index().on("createdTime", Sort.Direction.DESC));
}
}
测试跑之前索引已就绪,避免“本地快、CI慢”的隐式全表扫描。
数据清理:@Transactional Mongo+Redis双回滚
MongoDB使用@DataMongoTest自带事务模板;Redis借助RedisTemplate.exec()在@AfterEach回滚KEY:
@AfterEach
void tearDown() {
mongo.getDb().drop();
redis.execute((RedisCallback<Object>) con -> {
con.flushDb();
return null;
});
}
每条测试结束即回归白纸,并行执行无交叉污染。
CI并行优化:JUnit5并行+Testcontainers重用
.github/workflows/test.yml
- name: Unit Test
run: ./mvnw test -Dspring.test.context.cache.maxSize=32 -Djunit.jupiter.execution.parallel.enabled=true -Djunit.jupiter.execution.parallel.mode.default=concurrent
并在~/.testcontainers.properties打开testcontainers.reuse.enable=true,二次运行耗时由50s降至12s。
异常模拟:Redis宕机容器秒级重启
@Test
void shouldFallbackWhenRedisDown() {
REDIS.close(); // 手动停止
assertThatCode(() -> orderService.create("O123", 123L))
.doesNotThrowAnyException();
}
测试结束Testcontainers JUnit扩展自动重新创建容器,后续用例无感恢复。
本地开发:Testcontainers Desktop一键调试
安装Testcontainers Desktop后,可在IDE直接Attach容器,使用MongoDB Compass或RedisInsight查看数据,方便断点调试复杂聚合。
性能数据
- 单测平均耗时:1.3s(含容器启动)
- 并行200条测试:12s
- 内存峰值:1.8GB(比Embedded Redis+Flapd Mongo省40%)
- 覆盖率:行级87%,分支79%,生产零事故
本文著作权归吃喝不愁app开发者团队,转载请注明出处!
1万+

被折叠的 条评论
为什么被折叠?



