去年做跨境结算订单服务,23 个集成测试把 CI 从 11 分钟拖到 27 分钟。最开始我们图快,用 H2 2.2.224 顶 PostgreSQL 16.2,结果上线前发现 JSONB 字段的 GIN 索引根本没测到,ON CONFLICT DO UPDATE 在 H2 里语法都不一样,时区 Asia/Shanghai 写进去变成 UTC,财务对账差 8 小时。那次周五晚上修到凌晨两点,从此我不信“内存数据库能替代集成测试”这句话。后来换 Testcontainers 1.19.7 + postgres:16.2-alpine,配合容器复用和 Failsafe 3.2.5 拆分,CI 回到 9 分 12 秒。不是所有测试都变快,是大部分测试不再重复启动 Spring 上下文。
我的独立观点:集成测试按“契约切片”切,别按 Controller/Service/DAO 切
很多教程一上来就 @SpringBootTest 全量启动,然后 MockMvc 打 Controller,这其实是在写“慢速的单元测试”。我现在把集成测试切成三种契约切片:1)HTTP 入参出参 + 鉴权 + 异常码,用 TestRestTemplate 或 RestAssured 5.4.0,端口 RANDOM_PORT,断言 201、400、409、422 这些业务码;2)数据库事务与约束,用 @DataJpaTest + Testcontainers,验证唯一索引、外键、乐观锁 version、Flyway 9.22.3 迁移后的表结构;3)外部依赖契约,第三方支付用 WireMock 3.3.1,Kafka 用 confluentinc/cp-kafka:7.5.0,Redis 用 redis:7.2.4。每个切片只碰 1 到 2 个真实依赖,其余能 stub 就 stub。我们团队比例大概是单元 62%、集成 31%、E2E 7%,集成里 80% 是 @SpringBootTest + Testcontainers,20% 是 @DataJpaTest + Testcontainers。别追求 80% 集成覆盖率,那个数字会把 CI 拖死。
对比:H2、@DataJpaTest、@SpringBootTest、Testcontainers 到底怎么选
| 维度 | H2 2.2.224 | PostgreSQL 16.2-alpine + Testcontainers | @DataJpaTest | @SpringBootTest | | 启动耗时 | 0.2 秒左右 | 首次 3 到 8 秒,复用后 0.5 到 1 秒 | 1 到 2 秒 | 5 到 15 秒,取决于上下文缓存 | | SQL 兼容 | JSONB、RETURNING、ON CONFLICT 容易翻车 | 和生产一致 | 只适合 Repository 层 | 全链路,但容易变慢 | | 事务 | 默认回滚 | 不回滚,需要手动清 | 默认回滚 | 不回滚 | | 适合场景 | 早期冒烟 | 核心数据库契约 | 约束、索引、查询 | HTTP + 数据库 + MQ 组合 |
这张表不是让你二选一。我的做法是:单元测试继续用 Mockito 5.10.0,Repository 的复杂查询用 @DataJpaTest + PostgreSQL 容器,Controller 到 DB 的核心链路用 @SpringBootTest(webEnvironment = RANDOM_PORT),第三方 HTTP 全部 WireMock。Hikari 参数改成 maximumPoolSize=10、connectionTimeout=3000、validationTimeout=1000,不然容器启动慢时连接池会先报 timeout。
可抄步骤:从 0 到 1 搭一个不慢的集成测试
-
依赖:org.testcontainers:junit-jupiter:1.19.7、org.testcontainers:postgresql:1.19.7、org.testcontainers:kafka:1.19.7、org.springframework.boot:spring-boot-testcontainers:3.2.5、org.junit.jupiter:junit-jupiter:5.10.2、org.assertj:assertj-core:3.25.3。Maven 用 Failsafe 3.2.5 跑 IT.java,Surefire 只跑 Test.java。
-
基类写 @Testcontainers、@SpringBootTest(webEnvironment = RANDOM_PORT)、@ActiveProfiles("integration"),static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.2-alpine").withDatabaseName("app_test").withUsername("test").withPassword("test").withReuse(true)。@DynamicPropertySource 里把 spring.datasource.url 指向 postgres.getJdbcUrl(),再设置 spring.jpa.hibernate.ddl-auto=validate,让 Flyway 管表结构。
-
本地复用容器:在 ~/.testcontainers.properties 写 testcontainers.reuse.enable=true,CI 加 -Dtestcontainers.reuse.enable=true。但注意 GitHub Actions 每次新 runner 都无效,复用只适合本地和固定 runner。
-
数据清理别用 @Transactional 包 HTTP 测试。我们写 clean.sql:TRUNCATE TABLE order_item, orders, users RESTART IDENTITY CASCADE;然后 @Sql(scripts = "/clean.sql", executionPhase = BEFORE_TEST_METHOD)。测试数据用 UUID 后缀,别写死 userId=1,并行时会互相踩。
-
Kafka 等待别用 Thread.sleep。用 Awaitility 4.2.0:await().atMost(30, SECONDS).pollInterval(200, MILLISECONDS).until(() -> consumer.poll(Duration.ofMillis(100)).count() == 1)。KafkaContainer 超时设 60 秒,镜像锁 confluentinc/cp-kafka:7.5.0。
-
并行参数:junit-platform.properties 里 junit.jupiter.execution.parallel.enabled=true、junit.jupiter.execution.parallel.mode.default=concurrent、junit.jupiter.execution.parallel.config.strategy=fixed、junit.jupiter.execution.parallel.config.fixed.parallelism=4。但 Spring 上下文配置不同的测试类别并行,否则缓存从 2 个爆到 10 个,内存直接上去。
-
报错排查:Could not find a valid Docker environment 先看 DOCKER_HOST,Mac 用 Colima 时 export DOCKER_HOST=unix://$HOME/.colima/default/docker.sock;Timed out waiting for container port 把 withStartupTimeout(Duration.ofSeconds(60)) 加上;Ryuk 权限问题在 CI 里挂 /var/run/docker.sock,实在不行临时 TESTCONTAINERS_RYUK_DISABLED=true,但别当长期方案。
最后说点得罪人的:Pact、EmbeddedKafka、全量 E2E 都别神化
Pact 4.6.14 做消费者驱动合同测试很好,但它不验证你的 SQL 和事务,替代不了集成测试。EmbeddedKafka 3.2.5 启动快,但版本漂移会骗你,我就遇到过本地 EmbeddedKafka 通过、测试环境 Kafka 3.7.0 因为 acks 配置不同丢消息。全量 E2E 更别天天跑,我们只保留 7 条,覆盖下单、支付回调、退款、对账四个主流程,跑一次 4 分 30 秒。集成测试的核心不是覆盖多少代码,而是把不可控的外部依赖变成可控契约。容器版本锁死,测试数据可重复,断言落在业务不变量上。我们最后只留 14 个集成测试,覆盖 9 个核心契约,CI 9 分钟,我晚上能睡好觉。文章有点散,但都是我踩过的坑。