系列专栏:测试工程师每日一博 · 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"
})
class UserRepositoryTest { ... }
跑起来飞快,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 最小可运行例子
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class MinExampleTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void 容器应当能启动并返回 jdbc url() {
String url = postgres.getJdbcUrl();
System.out.println("JDBC URL = " + url); // 类似 jdbc:postgresql://localhost:32999/test
assertTrue(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")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column(name = "deleted_at")
private Instant deletedAt; // 软删除:为空表示有效用户
// 构造器 / getter / setter 省略
}
// 仓储接口
public interface UserRepository extends JpaRepository<User, Long> {
// 只查询未软删的用户
List<User> findByDeletedAtIsNull();
Optional<User> findByEmailAndDeletedAtIsNull(String email);
}
注意 deletedAt 这个字段:它是软删除标记——删用户时不真的 DELETE,而是写一个时间戳。查询有效用户都得带 WHERE deleted_at IS NULL。这种软删除 + 部分索引的组合,H2 根本模拟不出真实的索引行为,只有 PostgreSQL 能验。
3.2 完整集成测试类
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
@SpringBootTest
class UserRepositoryIT {
// 1️⃣ 容器声明:static 保证整个测试类共享一个实例
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// 2️⃣ 把容器的 JDBC 信息动态注入 Spring
@DynamicPropertySource
static void overrideProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository repo;
@BeforeEach
void 清空表() {
repo.deleteAll(); // 每个用例前清掉残留
}
// —— 1. 基础 CRUD ——
@Test
@DisplayName("保存并按 id 查询")
void should_saveAndFindById() {
User u = new User("张三", "zhangsan@example.com");
User saved = 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")
void should_rejectDuplicateEmail() {
repo.save(new User("张三", "dup@example.com"));
// 第二个用户用同样的 email
assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
() -> repo.saveAndFlush(new User("李四", "dup@example.com")));
}
// —— 3. 软删除查询 ——
@Test
@DisplayName("findByDeletedAtIsNull 只返回未删用户")
void should_onlyReturnActiveUsers() {
repo.save(new User("活跃", "a@example.com"));
User toDelete = repo.save(new User("已删", "b@example.com"));
toDelete.setDeletedAt(Instant.now());
repo.save(toDelete);
var active = repo.findByDeletedAtIsNull();
assertEquals(1, active.size());
assertEquals("活跃", active.get(0).getName());
}
// —— 4. PostgreSQL 特性:大小写敏感 ——
@Test
@DisplayName("email 在 PostgreSQL 中大小写敏感:'A@x.com' 与 'a@x.com' 是两个用户")
void email_shouldBeCaseSensitive_inPostgres() {
repo.save(new User("大A", "A@x.com"));
repo.save(new User("小a", "a@x.com"));
assertEquals(2, repo.count());
// 在 MySQL 默认排序规则下,count 可能是 1(违反唯一约束)
// 只有 PostgreSQL 能稳定验证这一点
}
// —— 5. 并发写入:验证事务隔离级别 ——
@Test
@DisplayName("并发插入同样 email:最终只有一个成功")
void should_onlyOneWin_underConcurrentInsert() throws Exception {
int threads = 10;
var executor = java.util.concurrent.Executors.newFixedThreadPool(threads);
var success = new java.util.concurrent.atomic.AtomicInteger(0);
var barrier = new java.util.concurrent.CyclicBarrier(threads);
for (int i = 0; i < threads; i++) {
executor.submit(() -> {
try {
barrier.await(); // 一起开跑
repo.saveAndFlush(new User("u", "race@example.com"));
success.incrementAndGet();
} catch (Exception ignored) {
// 唯一约束冲突,预期
}
return null;
});
}
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.1 static 容器,全类共享
@Container
static PostgreSQLContainer<?> postgres = ...; // ← 注意 static
加 static 后,整个测试类只起一次容器,所有用例共用。不加的话,每个测试方法各起一次——本地随便跑跑就被这种用法拖死。
4.2 容器单例模式(跨测试类共享)
如果你想在整个测试套件里全局共享一个 PostgreSQL,用一个抽象基类:
public abstract class BaseIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start(); // 手动启动,不会被 JUnit 的 @Container 生命周期管
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", POSTGRES::getJdbcUrl);
r.add("spring.datasource.username", POSTGRES::getUsername);
r.add("spring.datasource.password", POSTGRES::getPassword);
}
}
class UserRepositoryIT extends BaseIntegrationTest { ... }
class OrderRepositoryIT extends BaseIntegrationTest { ... }
两个测试类共享同一个 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,每个测试方法起一个容器
// ❌ 慢到怀疑人生
@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// ✅ 整个类共享一个
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
这是 Testcontainers 新人第一大坑。看到本地测试跑了 5 分钟,先检查这个。
坑 2:依赖容器外的本地数据库端口
// ❌ 假设本机 5432 有 postgres 就不用容器
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", () -> "jdbc:postgresql://localhost:5432/mydb");
}
这就退化成"手动管环境"——同事没装 postgres 就跑不了。Testcontainers 的核心价值就是不依赖本机预装,回到这种写法等于放弃了工具。
坑 3:数据没清理,用例间相互污染
// ❌ 没有 @BeforeEach deleteAll()
@Test void 测试1() {
repo.save(new User("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 假装一个调用方,完成接口级别的全链路测试。
如果这篇对你有帮助,欢迎点赞收藏 ⭐。下篇见。

1396

被折叠的 条评论
为什么被折叠?



