一个测试用例真正难写的部分从来不是断言,而是让"这一次运行"与"上一次运行"的状态完全无关。夹具(fixture)就是这段状态准备代码:数据长什么样,测试跑在哪套存储上,跑完谁来收摊。夹具写得草,测试最后会退化成"单独跑能过、全量跑就红"。
这篇按 构造 → 隔离 → 清理 三段拆开,语境是 JDK 17 + Spring Boot 3.3.x + JUnit 5。
1. 夹具的三种来源
内联构造:测试类里的私有工厂方法。
private Order orderWithAmount(BigDecimal amount) {
Order order = new Order();
order.setAmount(amount);
order.setStatus(OrderStatus.CREATED);
order.setCreatedAt(Instant.parse("2024-01-01T00:00:00Z"));
return order;
}
只有一两个类用它时最省事;成本出现在第三个类开始复制这段代码的那一刻。
Object Mother:一组语义化的现成对象,如 Orders.paid()、Orders.refunded()。
好处是读测试不用看一堆 setter;风险是变成全公司字段的堆积地,每个测试都在传自己不关心的字段。
Builder:按需组合,只写关心的字段。
三种来源的对照:
| 来源 | 表达力 | 重复成本 | 适合场景 |
|---|---|---|---|
| 内联构造 | 最低,只服务一个测试类 | 高,跨类复制 | 1–2 个测试类内的局部对象 |
| Object Mother | 中,靠方法名表达语义 | 低,但容易参数膨胀 | 领域对象有稳定的典型状态 |
| Builder | 高,只写关心的字段 | 低 | 字段多、测试关注点各不相同 |
选择顺序:先内联;出现第二次复制提 Object Mother;出现"每人只关心两个字段"换 Builder。
2. 测试数据与生产数据的隔离
夹具的第二层问题是它跑在哪。三种隔离粒度:
| 粒度 | 做法 | 隔离强度 | 代价 |
|---|---|---|---|
| 独立库 | 测试库与生产库不同实例 | 最高 | 需要多实例 |
| 独立 schema | 同实例不同库名/schema | 中高 | 同实例故障会连带 |
| 每次新容器 | Testcontainers 起临时实例 | 最高且可复现 | 首次拉镜像慢 |
生产库地址不该出现在测试配置里:让测试配置指向独立库名,CI 用没有生产凭证的账号。
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:mysql://${TEST_DB_HOST:localhost}:3306/clarity_test?useSSL=false
username: test
password: test
Testcontainers 在 Spring Boot 3.3 里可以一步到位:
@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
@Container
@ServiceConnection // 自动把连接信息装配到 DataSource
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("clarity_test")
.withUsername("test")
.withPassword("test");
}
@ServiceConnection 是 Spring Boot 3.1 起提供的,省掉 @DynamicPropertySource 里那段 getter 拼接;老写法仍然可用,按 Spring Boot 3.3 核对过两种方式都能工作。容器字段必须是 static,静态字段才在整个测试类生命周期内只启动一次。
3. @Transactional 回滚做隔离的三条失效前提
它只在三个条件同时成立时可靠:数据只走这个事务的同一个连接、被测代码不要求提交、没有任何线程在这个事务之外碰同一份数据。
前提一:存储本身支持事务。
只对关系型数据库成立。以下存储不受事务管辖:
| 存储 | 事务语义 | 拿回滚当清理的后果 |
|---|---|---|
| 关系型数据库 | 支持 | 正常 |
| Redis | 单命令原子,无跨命令回滚 | 写入残留,下次跑读到脏值 |
| Elasticsearch | 无跨请求事务 | 索引文档残留 |
| MinIO / S3 | 无事务 | 对象残留 |
| 消息队列 | 无回滚 | 消息已发出,消费方看见了 |
对这些存储,要么在 @AfterEach 显式清理,要么放进独立命名空间(Redis 用 key 前缀,ES 用带时间戳的索引)。
前提二:不需要提交才能验证的行为。
一旦被测行为依赖提交,回滚测试就会给出错误结论:@TransactionalEventListener(phase = AFTER_COMMIT) 监听的事件在回滚事务里根本不触发;唯一约束冲突要 flush/commit 时才暴露;触发器、ON UPDATE CURRENT_TIMESTAMP、乐观锁版本号在提交后才有最终值。
这类测试必须换成"真提交 + 真清理",即放弃事务回滚。
前提三:一切发生在同一个线程。
事务绑定在连接上,连接绑定在 ThreadLocal(TransactionSynchronizationManager)。新线程拿不到当前事务,也看不到未提交数据:@Async 提交的任务、CompletableFuture、定时任务、@Retryable 的重试都属这一类。
4. @Sql 与脚本准备数据
跨方法复用的数据集,用脚本比用 Java 代码更清楚:
@SpringBootTest
@Sql(scripts = "/fixtures/orders.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/fixtures/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderQueryTest { /* ... */ }
执行顺序要记住:类级 @Sql 先于方法级,BEFORE_TEST_METHOD 先于测试方法体。脚本依赖自增主键时,写死 id 反而比依赖自增稳——自增在回滚后仍会消耗序列值。
5. 随机数据与"可复现随机"
随机数据能覆盖边界,但不可复现的随机等于给 CI 埋定时炸弹:UUID.randomUUID() 生成的邮箱只在 CI 上撞过唯一约束,本地重跑永远通过。解法是固定种子的伪随机:
public final class TestData {
private static final long SEED = Long.getLong("test.seed", 20240101L);
private static final Random RANDOM = new Random(SEED);
public static int score() { return RANDOM.nextInt(0, 101); }
public static long userId() { return 1_000_000L + RANDOM.nextInt(1_000_000); }
}
CI 失败时把 -Dtest.seed=<失败时打印的种子> 传回去即可精确重放,配合每个用例打印种子:
天然不可控的几类数据源:
| 数据源 | 问题 | 处理方式 |
|---|---|---|
| 随机数 | 失败不可重放 | 固定种子,失败打印种子 |
| 当前时间 | 跨天/跨时区失败 | 注入 Clock |
| UUID | 无法断言具体值 | 断言格式而非内容 |
| 外部 HTTP | 结果不稳定 | 打桩或 MockWebServer |
| 自增 ID | 依赖执行顺序 | 断言集合而非具体 ID |
6. 时间相关数据:注入 Clock
生产代码直接调 Instant.now(),测试就只能靠 sleep 或改系统时间。改成注入 Clock,测试里换成固定时钟:
@Component
public class OrderService {
private final Clock clock;
public OrderService(Clock clock) { this.clock = clock; }
public Order create() { return new Order(Instant.now(clock)); }
}
@Bean
Clock clock() { return Clock.systemUTC(); } // 生产配置
@TestConfiguration
static class FixedClockConfig { // 测试配置
@Bean
Clock clock() {
return Clock.fixed(Instant.parse("2024-06-01T00:00:00Z"), ZoneOffset.UTC);
}
}
时区是最容易忽略的一项:Clock.systemUTC() 配 ZoneId.systemDefault() 的代码,在 CI 容器(通常 UTC)与开发机(+08:00)上会落到不同日期。测试里显式写 ZoneOffset.UTC 消除这个变量。
7. 清理策略:三种收摊方式
| 策略 | 实现 | 速度 | 适用 |
|---|---|---|---|
| 回滚 | 测试方法上的 @Transactional |
最快 | 纯关系型、不需要提交 |
| Truncate | @AfterEach 清表 |
中 | 需要真提交、含非事务存储 |
| 每次新建 | Testcontainers / 临时 schema | 最慢但最干净 | 全局状态多、DDL 变更频繁 |
Truncate 的顺序要照顾外键,脚本化比 Java 循环可靠:
TRUNCATE 比 DELETE 快,但会重置自增、且不能回滚,所以不能和 @Transactional 混用。
8. 测试之间共享可变数据的坑
| 坑 | 表现 | 处理 |
|---|---|---|
static 可变字段 |
前一个方法改了,后一个读到脏值 | 每个方法重建,或 @DirtiesContext |
| 单例 Bean 里的缓存 | 缓存跨方法存活 | 测试后清缓存,或让 TTL 可注入 |
| 共享的 fixture 对象 | A 改了对象字段,B 看到 | fixture 每次返回新实例 |
| 写死的 ID | 依赖插入顺序 | 用语义化唯一值(order-A) |
| 顺序依赖 | 单独跑过、全量跑红 | JUnit 5 不保证顺序,别写依赖 |
JUnit 5 的方法顺序由方法名哈希派生,确定但不承诺稳定。要固定得用 @TestMethodOrder,但更该做的是消除依赖本身。
9. 排错:八种"单跑能过、全量跑红"
| 现象 | 原因 | 处理 |
|---|---|---|
| 全量跑才行,单独跑红 | 前一个测试留下了数据 | 检查清理是否覆盖所有存储 |
| 单独跑红,全量跑才过 | 依赖别的测试造成的状态 | 补齐本测试自己的前置数据 |
| Redis 读到旧值 | 事务回滚不覆盖 Redis | @AfterEach 删 key 或用前缀隔离 |
| 事件监听到 0 次 | AFTER_COMMIT 在回滚事务里不触发 |
去掉 @Transactional,改真提交 |
| 异步断言偶发失败 | 线程池未在断言前完成 | 用 Awaitility 等待,不要 sleep |
| 跨天失败 | 用了系统时间或默认时区 | 注入 Clock,断言中显式指定时区 |
| 唯一约束偶发冲突 | 固定值 + 未清理 | 清理干净或用固定种子的唯一值 |
| 测试整体变慢 | 容器字段写成了实例字段 | 改成 static |
10. 边界:什么该写在测试里,什么不该
| 场景 | 建议 | 理由 |
|---|---|---|
| 只读查询的夹具 | 脚本 + 只读事务 | 不需要清理,最快 |
| 涉及 MQ 的流程 | 每次新建容器或专用 topic | 消息无法回滚 |
| 涉及文件存储 | 临时目录 + key 前缀 | 对象存储无事务 |
| 涉及外部 HTTP | MockWebServer / 打桩 | 真实调用不可复现 |
| 大量历史数据场景 | 脚本灌数 + 索引预热 | 数据量本身就是测试条件 |
| 时间敏感的断言 | 独立测试类别,不混在单测里 | 计时断言在共享 CI 上不稳 |
一句话收口:夹具的目标不是造出数据,而是造出可重复的状态。
11. 自测
- Object Mother 什么时候该换成 Builder,判据是什么?
- Testcontainers 的容器字段漏写
static会带来什么后果? @Transactional测试回滚不生效,最先怀疑的三类存储分别是什么?- 为什么
AFTER_COMMIT监听器在@Transactional测试里从不触发? - 用固定种子的随机数据,CI 失败后怎么精确重放那一次运行?
- 一个测试类单独跑通过、全量跑失败,你会按什么顺序排查?