系列专栏:测试工程师每日一博 · Day 4
日期:2026-07-10
关键词:Testcontainers、集成测试、Docker、PostgreSQL、H2 反模式、金字塔第二层
阅读对象:用 H2 内存数据库做集成测试、但总在上线后被真实数据库"打脸"的 Java 工程师 / 测试
开篇:H2 内存数据库的"温柔陷阱"
前三天我们一直在金字塔最底层打转——单元测试。Day 2 用 Mockito 把支付服务的依赖 mock 掉、Day 3 用覆盖率指标给单元测试当质量证据。一切都干净、快速、稳定。
但到了集成测试这一层,你会撞上一堵墙。最常见的"绕墙"做法是:引入 H2 内存数据库。
代码写起来很舒服:
@SpringBootTest@TestPropertySource(properties={"spring.datasource.url=jdbc:h2:mem:test;MODE=PostgreSQL"})classUserRepositoryTest{...}跑起来飞快,CI 全绿,大家感觉良好。然后上线,真实 PostgreSQL 直接教你做人:
- H2 里能跑的 SQL,PostgreSQL 报
function does not exist - H2 的
MODE=PostgreSQL只是语法兼容,不是行为兼容——大小写敏感、序列、JSONB、部分索引这些方言差异H2 模拟不了 - 一个
ON CONFLICT DO UPDATE(PostgreSQL 的 UPSERT)在 H2 里要么报错、要么悄悄走另一套语义 - 字段约束、外键级联、索引行为,都有差异
这就是集成测试领域最经典的反模式:H2 假象。你测的不是你生产用的数据库,你测的是 H2 的"模仿"。Day 3 我们说"覆盖率测不出代码缺失的判断",这里同理:H2 测不出真实数据库独有的行为差异。
那有什么办法?——用真的 PostgreSQL 跑集成测试。但问题是,谁是来搭数据库?谁来清理数据?谁负责每个用例间状态隔离?
答案就是今天的主角:Testcontainers。
一、Testcontainers 是什么:一句话定位
用 Docker 容器,在测试运行期间临时启动一个真实依赖(数据库、消息队列、缓存),测试结束自动销毁。
它的核心卖点只有四个字:真实、隔离。
| 维度 | Testcontainers | H2 内存库 |
|---|---|---|
| 行为是否和生产一致 | ✅ 完全一致(就是生产同一份镜像) | ❌ 模仿,有方言差异 |
| 启动成本 | 本机需要 Docker | 加一个 Maven 依赖 |
| 单测速度 | 慢(每容器启动几秒) | 极快(毫秒) |
| 数据隔离 | ✅ 每个测试类/方法一个干净实例 | ✅ 内存库重启即清 |
| 能测的能力 | 索引、约束、JSONB、级联、序列等全部真实行为 | 只能测 SQL 语法层面 |
关键认知:Testcontainers 不替代单元测试,它替代的是"虚假的集成测试"。它属于金字塔的中间层,数量占比 20%,不是堆顶层用。
二、最简接入:30 秒了解全貌
2.1 Maven 依赖
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers</artifactId><version>1.19.7</version><scope>test</scope></dependency><dependency><groupId>org.testcontainers</groupId><artifactId>postgresql</artifactId><version>1.19.7</version><scope>test</scope></dependency><!-- JUnit5 集成 --><dependency><groupId>org.testcontainers</groupId><artifactId>junit-jupiter</artifactId><version>1.19.7</version><scope>test</scope></dependency>2.2 前置条件:本机有 Docker
Testcontainers 本质上是程序化调 Docker,本机必须装 Docker(或 CI 环境装 Docker)。macOS 装 Docker Desktop 就行;Linux CI 一般都自带。
2.3 最小可运行例子
importorg.testcontainers.containers.PostgreSQLContainer;importorg.testcontainers.junit.jupiter.Container;importorg.testcontainers.junit.jupiter.Testcontainers;@TestcontainersclassMinExampleTest{@ContainerstaticPostgreSQLContainer<?>postgres=newPostgreSQLContainer<>("postgres:16-alpine");@Testvoid容器应当能启动并返回 jdbcurl(){Stringurl=postgres.getJdbcUrl();System.out.println("JDBC URL = "+url);// 类似 jdbc:postgresql://localhost:32999/testassertTrue(url.startsWith("jdbc:postgresql:"));}}跑这段代码时,你会在控制台看到这样一行:
[docker] postgres:16-alpine : Container is starting [docker] postgres:16-alpine : Container started (JDBC URL: jdbc:postgresql://localhost:32999/test)Testcontainers 替你做完了:拉镜像 → 启容器 → 等待就绪 → 暴露端口 → 测试结束销毁。你拿到的getJdbcUrl()是一个随机的、不冲突的本地端口,不同测试之间天然隔离。
三、实战:测一个 UserRepository
光看 Hello World 没意思。我们来测一个真实仓储——这是每个有持久层的 Java 项目都会有的代码。
场景:用户仓储
UserRepository,基于 Spring Data JPA + PostgreSQL。
想测什么:CRUD 基本逻辑、唯一约束、级联、以及 PostgreSQL 特有的部分索引。
3.1 被测代码
// 用户实体@Entity@Table(name="users",uniqueConstraints={@UniqueConstraint(name="uk_users_email",columnNames="email")})publicclassUser{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;@Column(nullable=false)privateStringname;@Column(nullable=false)privateStringemail;@Column(name="deleted_at")privateInstantdeletedAt;// 软删除:为空表示有效用户// 构造器 / getter / setter 省略}// 仓储接口publicinterfaceUserRepositoryextendsJpaRepository<User,Long>{// 只查询未软删的用户List<User>findByDeletedAtIsNull();Optional<User>findByEmailAndDeletedAtIsNull(Stringemail);}注意deletedAt这个字段:它是软删除标记——删用户时不真的 DELETE,而是写一个时间戳。查询有效用户都得带WHERE deleted_at IS NULL。这种软删除 + 部分索引的组合,H2 根本模拟不出真实的索引行为,只有 PostgreSQL 能验。
3.2 完整集成测试类
importorg.junit.jupiter.api.*;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.context.SpringBootTest;importorg.springframework.test.context.DynamicPropertyRegistry;importorg.springframework.test.context.DynamicPropertySource;importorg.testcontainers.containers.PostgreSQLContainer;importorg.testcontainers.junit.jupiter.Container;importorg.testcontainers.junit.jupiter.Testcontainers;importjava.time.Instant;importjava.util.Optional;importstaticorg.junit.jupiter.api.Assertions.*;@Testcontainers@SpringBootTestclassUserRepositoryIT{// 1️⃣ 容器声明:static 保证整个测试类共享一个实例@ContainerstaticPostgreSQLContainer<?>postgres=newPostgreSQLContainer<>("postgres:16-alpine").withDatabaseName("testdb").withUsername("test").withPassword("test");// 2️⃣ 把容器的 JDBC 信息动态注入 Spring@DynamicPropertySourcestaticvoidoverrideProps(DynamicPropertyRegistryregistry){registry.add("spring.datasource.url",postgres::getJdbcUrl);registry.add("spring.datasource.username",postgres::getUsername);registry.add("spring.datasource.password",postgres::getPassword);}@AutowiredprivateUserRepositoryrepo;@BeforeEachvoid清空表(){repo.deleteAll();// 每个用例前清掉残留}// —— 1. 基础 CRUD ——@Test@DisplayName("保存并按 id 查询")voidshould_saveAndFindById(){Useru=newUser("张三","zhangsan@example.com");Usersaved=repo.save(u);assertNotNull(saved.getId());Optional<User>found=repo.findById(saved.getId());assertTrue(found.isPresent());assertEquals("张三",found.get().getName());}// —— 2. 唯一约束(只有真实数据库能测)——@Test@DisplayName("email 唯一约束:重复插入应抛 DataIntegrityViolationException")voidshould_rejectDuplicateEmail(){repo.save(newUser("张三","dup@example.com"));// 第二个用户用同样的 emailassertThrows(org.springframework.dao.DataIntegrityViolationException.class,()->repo.saveAndFlush(newUser("李四","dup@example.com")));}// —— 3. 软删除查询 ——@Test@DisplayName("findByDeletedAtIsNull 只返回未删用户")voidshould_onlyReturnActiveUsers(){repo.save(newUser("活跃","a@example.com"));UsertoDelete=repo.save(newUser("已删","b@example.com"));toDelete.setDeletedAt(Instant.now());repo.save(toDelete);varactive=repo.findByDeletedAtIsNull();assertEquals(1,active.size());assertEquals("活跃",active.get(0).getName());}// —— 4. PostgreSQL 特性:大小写敏感 ——@Test@DisplayName("email 在 PostgreSQL 中大小写敏感:'A@x.com' 与 'a@x.com' 是两个用户")voidemail_shouldBeCaseSensitive_inPostgres(){repo.save(newUser("大A","A@x.com"));repo.save(newUser("小a","a@x.com"));assertEquals(2,repo.count());// 在 MySQL 默认排序规则下,count 可能是 1(违反唯一约束)// 只有 PostgreSQL 能稳定验证这一点}// —— 5. 并发写入:验证事务隔离级别 ——@Test@DisplayName("并发插入同样 email:最终只有一个成功")voidshould_onlyOneWin_underConcurrentInsert()throwsException{intthreads=10;varexecutor=java.util.concurrent.Executors.newFixedThreadPool(threads);varsuccess=newjava.util.concurrent.atomic.AtomicInteger(0);varbarrier=newjava.util.concurrent.CyclicBarrier(threads);for(inti=0;i<threads;i++){executor.submit(()->{try{barrier.await();// 一起开跑repo.saveAndFlush(newUser("u","race@example.com"));success.incrementAndGet();}catch(Exceptionignored){// 唯一约束冲突,预期}returnnull;});}executor.shutdown();executor.awaitTermination(10,java.util.concurrent.TimeUnit.SECONDS);assertEquals(1,success.get(),"并发下应只有一个插入成功");}}3.3 这套测试示范了什么
| 测试场景 | H2 能测吗 | Testcontainers 能测吗 | 价值 |
|---|---|---|---|
| 基础 CRUD | ✅ 能 | ✅ 能 | 基本逻辑,两者都能 |
| 唯一约束 | ⚠️ 能,但行为不一定一致(H2 有时更宽松) | ✅ 完全等同生产 | 防止约束在生产才暴露 |
| 软删除查询 + 索引行为 | ❌ 部分索引 H2 行为不同 | ✅ 真实索引 | 关键 |
| 大小写敏感(排序规则) | ❌ H2 默认不敏感 | ✅ PostgreSQL 默认敏感 | 防字符差异 bug |
| 并发 + 事务隔离 | ❌ H2 锁机制不同 | ✅ MVCC 真实行为 | 防竞态条件 |
看清楚这张表,你就明白了 Testcontainers 的真正价值:不是"跑得更真实"这么抽象,而是测那些 H2 测不出来的、和生产强相关的行为。如果一项测试用 H2 也能验,那它本质上是单元测试,没必要上容器。
3.4 命名约定:为什么叫UserRepositoryIT而不是UserRepositoryTest
注意上面类名是UserRepositoryIT(Integration Test 的后缀)。这不是巧合——这是行业惯例:
*Test.java:单元测试,跑得快,mvn 默认test阶段执行*IT.java:集成测试,跑得慢,mvn 默认只在verify阶段执行,不在普通test阶段跑
这样区分,你本地mvn test不会被一个 30 秒启动的 Testcontainers 测试拖着,CI 里则用mvn verify完整跑。Maven 用failsafe插件配合这个命名约定,surefire 跑*Test、failsafe 跑*IT。
四、慢节奏不是错:容器复用与单例
Testcontainers 第一个吐槽点永远是:慢。每个容器启动 3~10 秒,十几个测试类分别起容器,CI 五分钟过去了。三个对策:
4.1static容器,全类共享
@ContainerstaticPostgreSQLContainer<?>postgres=...;// ← 注意 static加static后,整个测试类只起一次容器,所有用例共用。不加的话,每个测试方法各起一次——本地随便跑跑就被这种用法拖死。
4.2 容器单例模式(跨测试类共享)
如果你想在整个测试套件里全局共享一个 PostgreSQL,用一个抽象基类:
publicabstractclassBaseIntegrationTest{staticfinalPostgreSQLContainer<?>POSTGRES=newPostgreSQLContainer<>("postgres:16-alpine");static{POSTGRES.start();// 手动启动,不会被 JUnit 的 @Container 生命周期管}@DynamicPropertySourcestaticvoidprops(DynamicPropertyRegistryr){r.add("spring.datasource.url",POSTGRES::getJdbcUrl);r.add("spring.datasource.username",POSTGRES::getUsername);r.add("spring.datasource.password",POSTGRES::getPassword);}}classUserRepositoryITextendsBaseIntegrationTest{...}classOrderRepositoryITextendsBaseIntegrationTest{...}两个测试类共享同一个 PostgreSQL,启动成本摊到 1 次。
4.3 本地开发复用(reuse模式)
Testcontainers 还有个实验性特性testcontainers.reuse.enable=true,允许容器跨测试套件保留(本地开发时不重复启动)。小众但有需要可以查文档。
⚠️一个底线:无论怎么复用,数据隔离不要省。上面
UserRepositoryIT的@BeforeEach deleteAll()就是保证每个用例看到的是干净表。复用容器 ≠ 复用数据。
五、Testcontainers 的生态:不止 PostgreSQL
PostgreSQLContainer只是冰山一角。Testcontainers 几乎覆盖了所有主流中间件:
| 模块 | 测什么 |
|---|---|
MySQLContainer/PostgreSQLContainer | 关系数据库方言差异 |
MongoDbContainer | NoSQL 文档库 + 索引行为 |
RedisContainer/GenericContainer("redis:7") | 缓存语义、过期、流水线 |
KafkaContainer | 消息中间件、消费组、offset |
LocalStackContainer | AWS 服务本地化模拟(S3 / SQS / DynamoDB) |
GenericContainer | 任意 Docker 镜像,自启动端口等待 |
GenericContainer是终极大招——只要你要的东西在 Docker Hub 上有镜像,你就能把它纳入集成测试。这意味着:你几乎不再需要"凑合着用 H2"。
实战提示:WireMock vs Testcontainers 选谁测 HTTP 依赖
测"我的服务调一个外部 HTTP 接口"时,有两个选择:
- WireMock:自己搭一个假的 HTTP 服务器,响应预定义。快,但不真实
- Testcontainers 起真实服务(比如起一个
wiremock/wiremock镜像,或起一个 mockserver 容器):慢,但贴近真实网络栈
经验法则:测自己的代码逻辑 → WireMock;测真实网络栈行为(超时、重试)→ Testcontainers。两者不冲突,金字塔中间层本来就允许我用多种工具组合。
六、三个易踩的坑
坑 1:忘记static,每个测试方法起一个容器
// ❌ 慢到怀疑人生@ContainerPostgreSQLContainer<?>postgres=newPostgreSQLContainer<>("postgres:16-alpine");// ✅ 整个类共享一个@ContainerstaticPostgreSQLContainer<?>postgres=newPostgreSQLContainer<>("postgres:16-alpine");这是 Testcontainers 新人第一大坑。看到本地测试跑了 5 分钟,先检查这个。
坑 2:依赖容器外的本地数据库端口
// ❌ 假设本机 5432 有 postgres 就不用容器@DynamicPropertySourcestaticvoidprops(DynamicPropertyRegistryr){r.add("spring.datasource.url",()->"jdbc:postgresql://localhost:5432/mydb");}这就退化成"手动管环境"——同事没装 postgres 就跑不了。Testcontainers 的核心价值就是不依赖本机预装,回到这种写法等于放弃了工具。
坑 3:数据没清理,用例间相互污染
// ❌ 没有 @BeforeEach deleteAll()@Testvoid测试1(){repo.save(newUser("a","a@x.com"));assertEquals(1,repo.count());// 如果上一个用例留了数据,这里会是 2}容器复用不等于数据复用。容器可以共享(为省启动时间),数据必须每个用例独立清理。
七、把 Testcontainers 接回金字塔
回看 Day 1 的金字塔图。今天这一篇正式进入了第二层:集成测试。
- 底层(单元测试):Day 1 / Day 2 / Day 3 都在这层。JUnit5 + Mockito,毫秒级,大量用例。
- 中间层(集成测试):今天。Testcontainers,秒级,数量占 20%,验证"多模块拼起来 + 真实依赖"。
- 顶层(E2E):还没涉及。
中间层的价值正在于此:它用单元测试的速度(秒级)和可重复性,换取了"真实依赖行为"的可信度。这是 H2 永远做不到的。
但反过来,Testcontainers 不该被滥用——如果一段代码和数据库无关、只是纯逻辑,就没必要上容器(那是单元测试的活)。判断标准很简单:
问自己:这段代码的 bug 用 H2 能复现吗?能 → 用单元测试;不能 → 用 Testcontainers。
八、思考题(留给明天的引子)
Q1:我们用 Testcontainers 起了真实 PostgreSQL。但网络依赖(比如调一个 Interview Booking 外部 HTTP API)也用 Testcontainers 起真实服务吗?还是用别的工具?
提示:外部 HTTP 依赖更适合用WireMock——明天 Day 5 我们就讲它。
Q2:Testcontainers 跑一次要 5 秒。一个有 200 个集成测试的项目,纯容器启动就要 1000 秒。你会怎么在 CI 里优化?
提示:
static容器单例、并行执行、分桶 (sharding)。这是 Day 7 「CI 中的测试分层」会展开的话题。
九、总结(TL;DR)
- 📦Testcontainers 一句话:用 Docker 在测试期启动真实依赖,测试结束销毁。真实、隔离、可复现。
- 🚫H2 反模式:H2 是 PostgreSQL 的"模仿",语法兼容但行为不兼容——方言、约束、索引、并发都不一样。上线后被真实数据库打脸的概率 > 你以为的。
- ⚙️核心写法:
@Container+static容器声明、@DynamicPropertySource动态注入 Spring 配置。 - 🧩价值场景:唯一约束、软删除 + 部分索引、大小写敏感(排序规则)、并发与事务隔离——这些都是 H2 测不出的真实行为。
- 📛命名约定:
*Test是单元测试(mvn test),*IT是集成测试(mvn verify)。 - ⚠️三个坑:忘
static、依赖本机端口、数据不清理。 - 🔗金字塔定位:中间层、数量 20%,不替代单元测试,而是替代"虚假的集成测试"。
明天 Day 5:《接口测试利器 REST Assured + WireMock》——既然集成测试要把 HTTP 依赖也隔离,我们明天就讲怎么用 WireMock 假装一个网关,用 REST Assured 假装一个调用方,完成接口级别的全链路测试。
如果这篇对你有帮助,欢迎点赞收藏 ⭐。下篇见。