Fixture、隔离与 Flaky Test:控制测试中的隐藏输入
String.format("%.2f", 12.5) 在一种默认 Locale 下得到 12.50,换一种则得到 12,50。代码和显式参数都没变,机器默认配置参与了结果。时钟、随机数、共享属性、线程调度和端口也会以类似方式进入测试。
Flaky Test 指测试在表面上相同的代码和条件下出现不稳定结果。定位它,需要找到遗漏的输入或共享状态,再让失败能够按这些条件重放。
用 Fixture 表达测试场景
合法默认值与局部变化
Fixture 是运行某个测试所需的数据和环境。订单 Fixture 可以提供合法客户、币种和金额,测试只修改本场景关心的部分:
OrderDraft ordinary = OrderFixture.anOrder().build();
OrderDraft free = OrderFixture.anOrder().totalCents(0).build();
assertEquals(1250, ordinary.totalCents());
assertEquals(0, free.totalCents());每次 anOrder() 返回新的 Builder,build() 返回不可变 record。一个测试修改金额后,下一个仍从正常金额开始。若把可变订单放在静态字段中反复修改,Fixture 很快会带上执行顺序。
合法默认让读者能直接看见“这次修改了什么”。非法输入则应明确构造,并断言被测入口怎样拒绝。不要用反射绕过全部约束来准备普通成功场景,也不要让 Builder 悄悄修正测试想传入的错误数据。
共享工厂适合表达稳定领域概念,例如“已支付订单”;全项目只有一个巨大 Fixture,靠几十个布尔参数切换状态,会让每次测试都难以判断实际准备了什么。工厂与生产实现共享基本类型即可,预期结果仍应来自规格,避免调用生产算法再次计算 expected。
ID、数据集合与快照
跨用例共享数据库时,可以使用唯一 ID 分隔对象;在断言和日志中保留本次 ID,便于查询和清理。随机 UUID 适合作为资源命名,但如果错误取决于这个 ID 的内容,仍需将实际值留在失败报告里。
列表断言要先确认业务是否承诺顺序。SQL 没有 ORDER BY、Map 迭代、多个线程汇总,都会影响输出排列。顺序是契约时检查排序字段和同值规则;只关心成员时采用集合比较,并单独考虑重复项。
大 JSON 快照便于观察整体变化,但动态 ID、时间和非契约字段可能制造噪声。可以先将允许变化的字段明确归一化,再比较稳定部分;金额、权限、错误码等重要字段应保留直接断言。无条件更新快照会把缺陷一并接受。
运行隔离实验
下载 Fixture 与隔离工程。Java 编译目标 17,支持 Java 17/25 执行,JUnit 6.0.3,Maven 3.9.12;测试只使用本地文件、回环 socket 和线程,不连接数据库。
src/main/java/example/testing/
├── OrderDraft.java 不可变订单输入
└── Expiry.java 接受 Clock 的过期判断
src/test/
├── java/example/testing/
│ ├── OrderFixture
│ ├── FixtureInputsTest Builder、时间表、seed、Locale、临时目录
│ ├── PortAndAsyncTest socket、Future、latch
│ ├── ParallelResourceTest 共享属性与资源锁
│ └── DefaultLocaleCase 单独选择的错误假设
└── resources/junit-platform.propertiesLinux 宿主具备 unzip 和 Docker CLI,当前普通用户能访问开发 daemon:
unzip testing-fixture-lab.zip
cd testing-fixture
mkdir -p .m2
LAB_DIR="$(pwd -P)"
MAVEN_IMAGE='maven:3.9.12-eclipse-temurin-25'
docker version
docker pull "$MAVEN_IMAGE"
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify容器内 Maven 使用宿主 UID/GID 写入目录,不挂载 Docker socket,也不需要创建其他容器。有本机 JDK/Maven 时直接执行同一目标。依赖下载受限时使用组织维护的 Maven mirror 与缓存,不将凭据写进公开 Fixture。
正常结果为 12 次执行,零失败、零错误、零跳过:输入类 7 次、端口与异步类 3 次、并行资源类 2 次。参数化测试按输入行计数;报告位于 target/surefire-reports。
把时间、随机性和进程环境变成显式输入
固定业务时钟
到期规则规定“当前时间达到截止时刻即过期”,可以直接列出三个输入:
@ParameterizedTest
@CsvSource({"-1,false", "0,true", "1,true"})
void expiryAtDeadline(long secondsAfter, boolean expected) {
Instant deadline = Instant.EPOCH;
Clock clock = Clock.fixed(deadline.plusSeconds(secondsAfter), ZoneOffset.UTC);
assertEquals(expected, new Expiry(clock).expired(deadline));
}业务判断读取注入的 Clock,测试不必真的等待一秒。Clock.offset 可表达相对偏移,可推进的测试时钟适合多步骤场景。涉及账期和营业日时,还需显式给出时区、夏令时与本地日期转换规则。Java Clock
业务时刻与等待耗时有不同用途。业务记录使用 Instant 或带业务时区的日期时间;deadline 的经过时间通常用单调的 System.nanoTime 差值,避免墙上时钟被校准后出现反向跳动。持久化时间也要考虑数据库的精度截断。
保存 seed,也保存真正失败的输入
工程从 JVM 属性读取 seed,并发布为 JUnit report entry:
long seed = Long.getLong("fixture.seed", 314159L);
reporter.publishEntry("fixture.seed", Long.toString(seed));
int[] values = new Random(seed).ints(12, 0, 10000).toArray();同一个 Random 实现、seed 和调用序列可以生成同样的结果。Java Random
如果生成过程新增一次随机调用,后续序列就会改变;多个线程竞争同一生成器时,值分配给哪个场景也可能变化。因此失败报告还要保存最终数据,而不只保存 seed。依赖随机库版本的生成算法时,也应记录对应版本。
在已有 JDK/Maven 的环境中,可用下面的命令选择同一输入类;容器方式将最初命令末尾 Maven 参数替换为同样参数:
mvn -B -ntp -Dfixture.seed=271828 -Dtest=FixtureInputsTest test输出仍应是该类的 7 次执行,报告保留所选 seed。边界值如 0、上限和恰好截止时刻仍用固定例子覆盖,不能期待随机抽样碰到每个重要输入。
Locale、时区与系统属性
面向机器协议的数字格式应显式选择 Locale 或使用确定的数值序列化。工程把默认 Locale 临时改为德国,分别检查默认格式的逗号、Locale.ROOT 的小数点,以及 BigDecimal 的 toPlainString:
Locale original = Locale.getDefault();
try {
Locale.setDefault(Locale.GERMANY);
assertEquals("12,50", String.format("%.2f", 12.5));
assertEquals("12.50", String.format(Locale.ROOT, "%.2f", 12.5));
assertEquals("12.50", new BigDecimal("12.50").toPlainString());
} finally {
Locale.setDefault(original);
}测试完成后恢复原值,而不是恢复成作者机器常用的某个值。系统属性原先不存在时,应使用 clearProperty;原先存在时恢复旧字符串。只恢复本次修改的属性,比替换整个 Properties 对象更少干扰其他代码。
这些默认配置属于 JVM 全局状态。多个测试并发修改同一资源时,finally 能保证单个测试清理,却不能阻止两个修改过程重叠;还需要下节的资源协调。生产代码能接受显式参数时,直接传入 Locale/ZoneId 会更简单。Java Locale
按生命周期拥有文件、端口与线程
从方法级到外部资源
每个测试方法
├── 新 Builder / 不可变输入
├── @TempDir 临时目录
└── 方法创建的 socket、流、executor
每个测试类或 Spring Context
├── 类级服务器、容器与共享 Fixture
└── 明确的 before/after owner
整个 JVM
├── static、默认 Locale/TimeZone、系统属性
└── 共享线程池、ThreadLocal、日志上下文
外部进程或服务
└── 数据库、队列、对象存储:按命名空间和对象 ID 清理关闭范围应覆盖资源的实际生命周期。JUnit 默认每方法实例隔离不了静态变量,重建 Spring Context 也不会自动删除外部数据库行。谁创建、谁关闭需要落实到代码中的 try/finally、扩展或容器 owner。
临时目录与动态端口
@TempDir Path directory 由 Jupiter 管理临时目录,测试在该目录内创建文件。清理策略可以配置,排查时保留目录应当是显式选择,避免长期把失败产物散落到系统临时区。JUnit 内建扩展
端口交给操作系统分配,并保持拥有它的 socket 打开:
try (ServerSocket owner = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
int port = owner.getLocalPort();
try (ServerSocket competitor = new ServerSocket()) {
assertThrows(BindException.class,
() -> competitor.bind(new InetSocketAddress(
InetAddress.getLoopbackAddress(), port)));
}
}这个测试实际检查了同一端口被占用时竞争者绑定失败。先找一个空闲端口、关闭探测 socket,再启动服务器,中间仍有被其他进程占用的窗口。服务器本身支持端口 0 时,让它直接绑定并读取最终端口即可。Java ServerSocket
容器的内部固定端口与宿主动态映射端口可以同时存在。测试客户端应使用映射后的 host/port,不把容器内部的 5432 或 5672 直接当成宿主端口。
异步完成必须送回测试线程
后台任务抛错不会自动变成当前 JUnit 线程的异常。工程通过 Future.get 获取结果或失败:
Future<Integer> future = executor.submit(() -> {
throw new IllegalStateException("BACKGROUND_FAILED");
});
ExecutionException failure = assertThrows(ExecutionException.class,
() -> future.get(2, TimeUnit.SECONDS));
assertInstanceOf(IllegalStateException.class, failure.getCause());
assertEquals("BACKGROUND_FAILED", failure.getCause().getMessage());get 有时间上限,异常由 ExecutionException 包装;测试继续检查其 cause。成功任务同样要读取 Future,才能确认任务实际完成。Java Future
有明确阶段的协作适合 latch/barrier。工程先等 Worker 发出 started,再确认结果尚未完成,释放 latch 后读取 READY。测试等待的是状态变化,避免按机器速度猜一段睡眠时间。
finally 中释放可能仍阻塞的 latch,取消 Future,调用 shutdownNow 并有界 awaitTermination。中断需要任务配合;不响应中断的循环应修复任务本身。ThreadLocal 和 MDC 等线程绑定数据也应在任务 finally 清除,防止线程池下一个任务继承。
外部系统只提供轮询查询时,可以使用有 deadline 的条件等待。轮询间隔控制查询频率,条件才决定成功;超时报告应带最后一次状态。Awaitility 的条件等待、poll interval 和超时说明见 Awaitility Usage。
JUnit 并行与资源锁
工程启用并行能力,但默认方法保持同线程,只有需要展示共享资源的类使用 @Execution(CONCURRENT):
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=2
junit.jupiter.execution.parallel.config.fixed.max-pool-size=2并行度是期望调度规模,最大线程数还受线程池配置影响。工程同时设置两者,便于在小资源环境观察行为。全局并行前,应先完成可并行类的资源清单。JUnit Parallel Execution
两个修改同一 JVM 属性的方法使用相同锁名:
@Test
@ResourceLock("test16.fixture.currency")
void cnyScope() { runScope("CNY"); }
@Test
@ResourceLock("test16.fixture.currency")
void usdScope() { runScope("USD"); }runScope 保存旧属性、设置本次币种、读取并断言,再在 finally 恢复。计数器记录同时进入的 scope,测试收尾确认最大值为 1。JUnit 根据资源声明避免冲突节点重叠,保护范围包括相关 before/after 回调。
只有使用同一资源名的 Jupiter 节点参与这项协调。另一个 Maven fork、外部进程或没有声明锁的测试仍能改动实际资源。读共享状态的测试也要按其需要声明 READ 或 READ_WRITE;对于 JVM 默认 Locale 等通用资源,团队应采用统一命名或 JUnit 预定义常量。
从首次失败回到可重复输入
用默认 Locale 反例练习定位
DefaultLocaleCase 固定德国 Locale,却断言输出一定含小数点。沿用前面的目录与变量:
set +e
docker run --rm --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work "$MAVEN_IMAGE" \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 \
-Dtest=DefaultLocaleCase test > locale-failure.log 2>&1
status=$?
set -e
test "$status" -ne 0
grep -F 'expected: <12.50> but was: <12,50>' locale-failure.log预期为断言失败且 Maven 非零退出。输入格式属于机器协议时,修复为显式 Locale.ROOT;面向用户的显示格式则应按用户 Locale 准备 expected。移除反例选择,重新执行最初的 clean verify,正常套件恢复 12 次成功执行。
按失败条件分类
| 复现条件 | 常见遗漏输入 | 优先动作 |
|---|---|---|
| 单个方法成功,整类失败 | 可变 Fixture、static、没有关闭的 mock | 保存失败方法顺序,检查共享对象 |
| 单类成功,整套失败 | JVM 默认配置、端口、全局缓存、Spring Singleton | 缩小类集合并固定资源名 |
| 本机成功,CI 失败 | JDK/依赖、Locale/时区、CPU、内存、文件系统大小写 | 比较实际运行参数和首次失败日志 |
| 只在并发下失败 | 无锁共享状态、误用连接、后台清理竞争 | 降到两个参与者并控制交错 |
| 只在特定随机输入失败 | 生成数据违反隐含约束或业务缺陷 | 保存 seed 与实际数据,再缩减输入 |
| 重跑成功但耗时接近上限 | 外部服务排队、机器饱和或等待条件错误 | 检查阶段耗时,分清慢与未完成 |
先保留第一次失败的完整报告,再开始重跑。需要保留的内容包括测试 ID、输入、seed、执行顺序、运行环境和后台异常。不要让第二次成功覆盖第一份文件。
Maven Surefire 可按类随机排序并保存 runOrder seed;JUnit 方法排序又是独立层次。复现时应确认自己改变的是哪一种顺序,以及并行调度是否仍在重排实际开始时间。Surefire test 参数
重复执行与暂时隔离
有限次重跑可以增加触发机会,不能证明所有竞争都被消除。修复后先重放原失败输入和顺序,再跑相关套件及有代表性的并行配置。若只能通过增加固定 sleep 稳定结果,应继续查找真正的完成条件。
暂时隔离不稳定测试时,保留原始失败、可运行方式、负责修复的人与恢复条件,并在独立任务中继续执行它。主流水线应明确列出哪些检查暂时不提供结果。无限重试直到绿色,容易掩盖业务缺陷和基础设施退化。
最后还要检查隔离动作本身是否结束:临时端口关闭、线程退出、属性恢复、外部记录清理。稳定测试既需要确定输入,也需要在完成后交还自己占用的资源。
权威资料与规范地址
Java 输入与资源
- Clock:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/time/Clock.html
- Random:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Random.html
- Locale:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Locale.html
- ServerSocket:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/net/ServerSocket.html
- Future:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Future.html
JUnit 与等待
- JUnit 内建扩展:https://docs.junit.org/6.0.3/writing-tests/built-in-extensions.html
- JUnit 并行与资源锁:https://docs.junit.org/6.0.3/writing-tests/parallel-execution.html
- Awaitility Usage:https://github.com/awaitility/awaitility/wiki/Usage
- Surefire test 参数:https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html
