Fixture、隔离与 Flaky Test:测试为什么会随机失败
拆解 Fixture Builder、固定时钟、共享资源、并行执行、失败分类、隔离区和修复证据。
JUnit 的 Parallel Execution 要求显式声明执行和资源策略;Spring Context Cache 也会因共享可变上下文产生套件级耦合。
Fixture 要表达场景意图而不是数据库全量快照
一个测试只关心“已支付、库存未确认的订单”,Fixture Builder 应给其余字段安全默认值并让关键差异在调用处可见。复制生产 JSON 或共享巨型 Object Mother 会把无关字段、旧 Schema 和隐含关联带入所有用例;字段一变,全套测试同时噪声失败。
Builder 的默认值必须合法且稳定,时间、随机、ID 和租户通过显式端口生成。需要非法对象时使用专门构造路径,避免全局 Builder 放宽不变量。测试断言聚焦行为,Fixture 自己用少量契约测试保证生成的数据满足约束。
Flaky 的本质是未建模输入
常见隐藏输入包括 wall clock、时区、Locale、随机 seed、端口、DNS、CPU 调度、线程池残留、共享数据库、测试顺序、文件系统大小写和外部服务。失败时采集这些环境指纹、线程与资源状态;简单重跑只判断“存在随机性”,不能定位根因。
分类可分时序竞态、资源碰撞、外部依赖、数据污染、环境差异、概率算法与真实产品竞态。每类有不同修复:可控时钟、分配端口、Fake Server、独立 schema、固定 Locale、保存 seed 或修复生产同步。统一加 sleep 会同时拖慢套件并保留竞态。
并行执行前先声明资源所有权
测试类默认是否并行、同类方法是否并行、共享容器是否线程安全必须明确。对系统属性、固定端口、全局缓存、静态 mock 等资源使用资源锁或移除共享;数据库数据按 runId 隔离;文件使用临时目录;Executor 在 teardown 关闭并等待终止。
顺序依赖测试单独运行可能全绿,整套或随机顺序才失败。周期性随机化顺序、分片方式和并发度能发现耦合,但每次执行保存顺序 seed。修复后用原失败顺序连续运行,而不是凭一次绿色关闭问题。
隔离区不是永久忽略列表
高频 flaky 会吞噬开发者对红灯的信任。确认随机后可临时 quarantine,主门禁仍展示数量、owner、首次/最近失败、失败率和期限;核心安全、资金、迁移测试不能简单移出阻断,需降并行或固定环境优先修复。
修复证据包含原始失败可复现、修改前在固定调度或 seed 下失败、修改后在同条件及放大重复下通过。删除测试、无限重试或放宽断言不是修复。套件目标是任何一次红灯都值得调查,而不是让 CI 最终变绿,并让失败证据能够被稳定追溯。
全局状态清单是并行化前置产物
逐项枚举 System properties、environment adapter、default Locale/TimeZone、静态缓存、单例注册表、日志 MDC、线程上下文、端口、临时目录、数据库、Broker 和容器。每项标注 owner、分配方式、清理断言与并行策略。没有清单就直接提高 worker 数,只会把潜伏耦合变成随机红灯。
Teardown 使用 try/finally 或 extension close hook,即使断言失败也执行;清理本身失败要追加到原异常而不是覆盖。Executor 调用 shutdown、awaitTermination 并在超时输出线程;临时文件删除前关闭流;MockedStatic 和 MDC 必须在当前线程范围归还。
Flaky 指纹需要统计而不是印象
保存 test id、失败签名、attempt、worker、机器、JDK、顺序 seed、耗时、资源和提交。相同堆栈但不同资源可能是共同基础设施问题,不同堆栈围绕同一端口可能是碰撞。失败率按近窗口计算并展示置信区间,不能因为连续十次绿色就宣布万分之一竞态消失。
自动重跑仅用于诊断:第一次失败仍让报告不稳定或至少产生阻断阈值,重跑使用相同现场与 seed。若重跑重新创建所有条件,绿色不能解释原失败。隔离区定期在专用作业高频运行,超过期限自动升级而不是静默跳过。
修复策略必须消除隐藏变量
端口冲突改为操作系统分配并把端口注入客户端;时间边界改用固定/可推进 Clock;最终一致改用条件轮询和明确 deadline;顺序依赖改为独立 Fixture;外部服务改为 Fake/容器或分类基础设施门禁;真实生产竞态则先修生产同步再保留回归调度。
Awaitility 类轮询也需要稳定条件、间隔和超时证据,不能用“等待某日志出现”代替权威状态。测试结束还断言没有未消费任务和后台异常,避免迟到回调污染下一用例;清理完成本身也是必须验证的终态,而不是 teardown 的默认假设。
先写证明对象,再选择测试层
测试替身越多,速度越快但与真实运行的距离越大;真实依赖越多,协议证据越强但状态和资源更难隔离。组合策略应让同一高风险结论至少由两种不同失败模式的证据支持,例如单元验证版本条件,真实数据库验证条件更新确实只影响一行。
失败必须保存成可重放状态
失败包至少包含测试 id、代码提交、依赖版本、seed、时钟、Locale、执行顺序、资源标识、容器日志、线程信息和业务见证。结果未知与产品断言失败分开统计;基础设施掉线不能自动归为产品缺陷,产品超时也不能全部甩给 CI。
可维护测试也需要性能、数据和安全边界
套件容量模型包含用例数、上下文/容器启动、数据初始化、并发 worker、日志与制品体积。并行度超过数据库连接、CPU 或容器资源只会增加尾延迟和 flaky;先量测各阶段临界路径,再决定共享、分片或下沉。
演进遵循“读取端先兼容、写入端后切换、旧证据归零再删除”。框架升级先固定当前发现数、执行顺序、上下文 key 和报告格式,再迁移版本;不能把升级造成的少跑测试误当提速。
用两个 Java 17 模型固定测试见证
模型不依赖测试框架或外部服务,只把边界输入、状态转换和期望见证压成确定输出;真实项目再用 JUnit、Mockito、Spring、容器或 WireMock 驱动同一不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/testing/fixtures-isolation-flaky/DeterministicFixtureDemo.java examples/backend-development/testing/fixtures-isolation-flaky/FlakyClassificationDemo.java
java -cp examples/backend-development/testing/fixtures-isolation-flaky DeterministicFixtureDemo
java -cp examples/backend-development/testing/fixtures-isolation-flaky FlakyClassificationDemoclock=T0 seed=42 ids=[id-1, id-2] locale=zh-CN deterministic=true
attempts=3 passOnRetry=true quarantined=true rootCause=PORT_COLLISION repair=ALLOCATED_PORT实际测试若改变输出,必须解释是业务规格、测试层、Fixture、调度还是门禁发生变化;禁止用放宽断言或无限重试吸收差异。
从失败现场回推测试体系缺口
每个用例显式拥有输入、资源与时序,失败必须能以原始条件单次重放。围绕这条不变量记录测试 owner、独特证据、运行成本、失败率和最近一次真实发现。长期从不失败且与其他用例完全重复的测试需要合并;只在生产才失败的关键路径需要升级替身或增加真实边界。测试资产和生产代码一样接受删除、重构与版本治理。
团队门禁拒绝没有证据的绿色
禁止无断言 smoke、只验证 mock 自己返回值、依赖执行顺序、真实 sleep、共享固定端口、隐式 wall clock、失败后不保存首次现场、用重跑掩盖 flaky、为覆盖率调用无意义代码,以及在契约失败时改成万能匹配。例外写 owner、原因、截止和替代观测。
代码评审要求说明为何选这一层、替身与真实系统差异、数据如何隔离、失败如何重放和哪个发布风险被覆盖。CI 报告展示未运行、跳过、隔离与重试,不允许只汇总“最终通过”。
测试资产的容量、升级与恢复
围绕“每个用例显式拥有输入、资源与时序,失败必须能以原始条件单次重放”建立资产台账:用例 owner、被保护规则、依赖资源、平均/p95 耗时、失败率、独特证据和最后发现缺陷。套件增长时按临界路径和资源饱和定位,而不是只看用例总数。共享容器降低启动成本但提高污染半径,并行 worker 提升吞吐但会争用数据库、CPU 和端口;每次提速都用隔离失败率与反馈时间共同验收。
测试环境事故按基础设施、Fixture、产品断言和未知四类分流。保留首次失败,不让自动重跑覆盖现场;基础设施恢复后补跑精确 commit,产品失败保持阻断,未知结果收集更多证据后再定性。测试日志和制品有容量与保留期,失败见证优先保留,成功调试噪声可采样清理。
最终验收不以一次全绿结束:在相同 seed、调度、版本和资源边界下重复关键反例,确认失败能稳定出现、修复能稳定消除,且无关重构不会破坏测试。
