霸王餐接口单元测试:Testcontainers启动Redis/MongoDB多容器联动

霸王餐接口单元测试: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开发者团队,转载请注明出处!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值