Administrator
发布于 2022-10-11 / 2573 阅读
45

JUnit 5 与测试容器 Testcontainers 集成测试

背景

上周联调一个订单状态机,本地用 Redis 做幂等缓存。单元测试里我习惯用 Mockito 把 RedisTemplate 整个 mock 掉,结果上线后在缓存过期那块直接炸了。mock 根本没覆盖 TTL 和原子自增的真实语义,自测全绿却带着隐患上了线。

复盘那次事故,根因是我们对"缓存是否存在""过期后是否回源"这些边界,从来没在接近真实的环境里验证过。单测验证的是方法是否被调用、返回值是否匹配,而 Redis 的语义(expire、NX、incrby)是另一回事。Mockito 一桩桩 stub 出来,等于自己骗自己。

为什么必须用真实依赖

我们一直说测试金字塔,但集成测试那一层长期是真空。单测验证了方法逻辑,E2E 又太慢太脆,中间缺了一块"用真实中间件跑一遍"的东西。Testcontainers 正好补这个洞,它在测试启动时拉一个 Docker 容器,跑完自动销毁,不需要你机器常驻一套 Redis。

我们团队之前也试过嵌入式 Redis(embedded-redis),但它用的端到端协议实现和官方 Redis 7 有差异,个别命令行为对不上,反而制造假绿。Testcontainers 直接拉官方镜像,行为一致,这是它比嵌入式方案靠谱的地方。

接入方式

在 Spring Boot 3.0 项目里加依赖:

testImplementation 'org.testcontainers:junit-jupiter:1.17.6'
testImplementation 'org.testcontainers:redis:1.17.6'

写一个 Redis 集成测试基类:

@Testcontainers
@SpringBootTest
class RedisIdempotentTest {

    @Container
    static RedisContainer redis =
        new RedisContainer(DockerImageName.parse("redis:7.0"))
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.redis.host", redis::getHost);
        r.add("spring.redis.port", redis::getFirstMappedPort);
    }

    @Autowired
    private IdempotentService service;

    @Test
    void 重复请求应被拦截() {
        String key = "order:10086";
        assertTrue(service.tryLock(key));
        assertFalse(service.tryLock(key));
    }
}

这里 @Container 用 static,保证容器在类级共享,多个测试方法之间复用同一个 Redis,省去反复启动的开销。tryLock 底层用的是 SET key val NX EX 30,第二次进来因为已存在而返回 false。

排查与数据

之前 mock 版本跑 0.3 秒,Testcontainers 首次因为要拉镜像冷启动约 12 秒,后续命中本地镜像稳定在 4 秒左右。CI 上我们开了复用(testcontainers 的 Ryuk 复用机制 + 本地镜像缓存),整条流水线增加约 40 秒,但换来了"缓存行为真实可信"。

为了说服团队,我拉了一组数据对比:在 32 个用例里,mock 版本全部通过;换成真实容器后,有 2 个用例暴露了问题——一个是 TTL 设成了 0 导致立即失效,一个是用的 del 而非 unlink,在高并发压测下主线程被阻塞。这些在 mock 下永远不会被发现。

把 MySQL 也包进来

Redis 只是一个开头。我们后来把 MySQL 8.0 也用容器跑起来,做仓库层的真实 SQL 验证:

@Container
static MySQLContainer<?> mysql =
    new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
        .withDatabaseName("order_db");

配合 @Sql 注解灌测试数据,再跑业务方法,能抓到分页 SQL 在真实 MySQL 上的隐式类型转换、索引失效这些只在特定数据量下才暴露的问题。

踩过的一个坑:容器网络

最早我们在 Mac 上跑,Docker Desktop 的端口映射和 Linux 不一致,@DynamicPropertySource 里拿到的 getFirstMappedPort 在 CI(Linux 机器)上没问题,本地却连不上。后来统一在本地也走容器网络,问题消失。另一个坑是镜像版本漂移:一开始写的是 redis:latest,某次升级后命令行为变了,测试开始飘红。现在所有容器都钉死小版本(redis:7.0.11),CI 才稳定。

CI 上的复用技巧

Testcontainers 默认每次测试类起一个容器,CI 上多个模块各自拉镜像会很慢。我们开启了容器的复用模式(配置 ryuk 和复用环境变量),让同一个流水线的多个测试共享已拉起的容器,整体时间从单模块 40 秒降到全量约 90 秒(含首次启动)。另外把常用镜像(redis:7.0.11、mysql:8.0)预拉到 CI runner 的本地缓存,避免每次现拉。这两招让集成测试在 CI 上从"慢得想关"变成"可接受"。

小结

测试金字塔不是空话。把 Redis、MySQL、Kafka 这类依赖用容器包起来,集成测试才不是摆设。代价是构建稍慢,但这个交换在我们团队是划算的——毕竟线上一次缓存语义 bug,排查成本远超这 40 秒。建议从最容易被 mock 骗过的中间件开始接入,循序渐进;容器版本和端口取法的坑提前踩过,后面就顺了。

参考