2720 字
约 9 分钟
17
S16-X04 · 测试数据与夹具:构造、隔离与清理
2024-12-31
2026-11-08

一个测试用例真正难写的部分从来不是断言,而是让"这一次运行"与"上一次运行"的状态完全无关。夹具(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. 自测

  1. Object Mother 什么时候该换成 Builder,判据是什么?
  2. Testcontainers 的容器字段漏写 static 会带来什么后果?
  3. @Transactional 测试回滚不生效,最先怀疑的三类存储分别是什么?
  4. 为什么 AFTER_COMMIT 监听器在 @Transactional 测试里从不触发?
  5. 用固定种子的随机数据,CI 失败后怎么精确重放那一次运行?
  6. 一个测试类单独跑通过、全量跑失败,你会按什么顺序排查?
S16-X04 · 测试数据与夹具:构造、隔离与清理
https://www.clxhxhhr.top/posts/5730/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。