集成测试用H2还是Testcontainers?我跑了1247次CI后的真实数据对比

🔑 关键词:集成测试, Testcontainers, H2数据库, Spring Boot, 测试策略

📖 摘要:业余开发者复盘集成测试踩坑:H2与真实PostgreSQL差异导致37%的测试在CI失败。本文对比H2、Testcontainers、嵌入式Postgres的启动时间与资源占用,并给出从H2迁移到Testcontainers的详细步骤。

上个月我又双叒叕因为集成测试在CI上挂了,这已经是这个季度第三次了。每次都是本地跑得好好的,一到CI就报错,不是JSONB类型不匹配,就是now()函数返回的时区不对。我盯着屏幕上的PSQLException: ERROR: column "metadata" is of type jsonb but expression is of type character varying,心里只有一个念头:我到底在测什么?后来我统计了一下,过去半年里,我们项目用H2做集成测试,总共跑了1247次CI构建,其中因为数据库行为差异导致的失败有463次,占比37.1%。这个数字让我彻底放弃了H2。

图片

先说说背景。我们是一个Spring Boot 3.2.5 + PostgreSQL 15.3的项目,JUnit 5,用了Spring Data JPA。最开始为了图快,集成测试用了H2内存数据库,模式设成PostgreSQL兼容。一开始挺爽,测试启动只要1.2秒,整个测试套件跑完不到8秒。但后来业务复杂了,用到了PostgreSQL的JSONB、数组类型、ON CONFLICT DO UPDATE、窗口函数,H2就各种不支持或者行为不一致。比如ON CONFLICT,H2的语法是MERGE INTO,而且对唯一索引的判定和PostgreSQL不同。我不得不写两套SQL,或者用@Sql脚本手动适配,结果测试代码比业务代码还长。

图片

后来我决定试试Testcontainers。一开始觉得重,每个测试类都要启动一个PostgreSQL容器,慢。但实际用下来,Testcontainers 1.19.7配合Docker的容器复用(withReuse(true)),第一次启动容器大概需要8-12秒,之后同一个容器可以被多个测试类复用,每个测试类只需要清空数据或者用事务回滚。我跑了一次全量集成测试,一共89个测试方法,总耗时从原来的H2的6.8秒变成了21.4秒。看起来慢了3倍,但关键是:这21.4秒里,没有一次因为数据库行为不一致而失败。而在之前,那6.8秒里,平均每次CI都要重跑2-3次才能通过,算上重跑的时间,实际耗时反而更长。而且,我再也不用写那些丑陋的H2兼容代码了。

图片

这里我要抛出一个可能有点暴论的观点:集成测试的“快”是个伪命题。你为了快用H2,结果测试不能发现真实问题,那这个测试就是无效测试,无效测试再快也是浪费时间。集成测试的本质是验证你的代码与外部依赖的契约,而Mock和H2都是在伪造契约。只有用真实依赖,你才能发现契约漂移。比如PostgreSQL 15.3的jsonb类型,你往里面存一个Java的Map<String, Object>,Hibernate 6.4.4会把它序列化成jsonb,但H2会存成varchar,然后你查询时用@Querymetadata->>'key',H2直接报语法错误。这种问题,只有真实数据库才能暴露。

图片

如果你也想从H2迁移到Testcontainers,下面是我的步骤。第一步,在pom.xml里添加依赖:org.testcontainers:postgresql:1.19.7org.testcontainers:junit-jupiter:1.19.7,记得test scope。第二步,创建一个@TestConfiguration类,定义@Container@DynamicPropertySource。具体代码:static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.3").withDatabaseName("testdb").withUsername("test").withPassword("test").withReuse(true); 然后@DynamicPropertySource里设置spring.datasource.urlpostgres.getJdbcUrl()。第三步,在application-test.yml里把spring.jpa.hibernate.ddl-auto设成validate或者none,然后用Flyway或Liquibase管理schema。第四步,处理测试数据,用@Sql或者@BeforeEach清空表,注意外键约束,可以用TRUNCATE ... CASCADE。第五步,在CI里确保Docker可用,比如GitHub Actions用services: postgres或者直接让Testcontainers自己拉容器,但要配置DOCKER_HOST。我踩过的坑:Testcontainers在GitHub Actions的ubuntu-latest上默认能用,但如果你用container job,需要挂载/var/run/docker.sock

图片

最后说一个对比。除了Testcontainers,还有嵌入式Postgres,比如Zonky的io.zonky.test:embedded-postgres:2.0.7。它启动更快,大概3-5秒,不需要Docker,但需要下载对应平台的二进制包,第一次会慢,而且CI缓存二进制比较麻烦。我实测Zonky在M1 Mac上启动PostgreSQL 15.3需要4.2秒,在CI的x86机器上需要3.8秒。Testcontainers在M1 Mac上启动需要9.7秒,CI上需要11.3秒。但Zonky的版本更新滞后,PostgreSQL 15.3支持是有的,但如果你要用PostGIS之类的扩展,就麻烦。所以我的选择是:本地开发用Testcontainers(Docker Desktop有缓存),CI用Zonky或者Testcontainers,看你的CI环境是否支持Docker。但无论如何,别再用H2做集成测试了。真的。

图片

🏷️ 标签: