测试也会过期:把重构、退役和删除前移到 Spec
清理 ETF Agent 的测试时,我们遇到了一条失败断言:即使执行租约已经过期,只要租约记录还存在,这个运行就不应该进入恢复候选列表。
这条断言曾经有明确的保护目标——避免恢复流程干扰仍有执行者持有的运行。但系统后来调整了恢复机制:缺失或过期的租约都可以被发现,是否允许恢复、是否可以结算,则交给后续的准入与状态检查。
当前实现和已经接受的规范都支持这个行为,测试却仍然守着旧约定。如果只以“让测试重新变绿”为目标,回退实现虽然能消除报错,却会破坏已经明确的新行为。
关于系统怎样安全演进和退出旧能力,以及测试通过能够证明什么,我们此前已有专门讨论。这次清理把问题推进到了测试本身:写 Spec 时,除了决定新增什么,还应该决定已有测试如何重构、合并、退役和删除。
一、失败断言还在保护什么
面对失败测试,我们通常先检查实现。这是合理的默认顺序,但不能因此把所有既有断言都当成永久有效的产品要求。
那条恢复测试把两个不同阶段混在了一起:发现一个运行值得进一步检查,以及获得恢复或修改这个运行的权限。过期租约可以成为检查线索,却不能直接授予执行权。后续仍然要确认当前执行者、运行身份和防止旧执行者写入的版本约束,候选读取本身不应该改变运行状态。
因此,修正后的测试需要同时约束几个行为:
- 过期租约可以被列出,活跃租约仍被排除。
- 候选读取不结算任何运行,也不改变原有运行状态。
- 活跃执行者仍能正常续租。
只把原来的“不包含”改成“包含”,不足以表达这次契约变化。新增的允许路径与继续保留的保护,必须放在一起检查。
我们先在内存实现中确认了这个冲突,随后用独立的 PostgreSQL 测试容器复现了数据库用例的同类失败。修正依据来自已经生效的行为约定,以及两种实现的一致执行结果。原始失败报告也保留了下来。
这里的判断顺序很重要。实现已经这样写,不足以证明测试应该这样改。如果找不到明确的需求变化或契约依据,失败仍应作为待调查的问题,不能通过放宽断言消除。
二、能力退役后,迁移测试怎样退出
另一组清理对象与 LangGraph 有关。
项目已经移除了 LangGraph 运行层,当前依赖与生产源码中都没有对应接线。但日常测试仍保留着一套退役检查:扫描整棵生产源码树,查找旧包名、旧类型、旧元数据,以及特定的历史身份字符串。
这套检查在迁移期间有实际价值。删除框架时,容易漏掉依赖、适配器和隐藏入口,集中检查可以帮助确认迁移范围。
迁移结束后,它的维护价值需要重新判断。原有扫描为了多条断言反复读取生产源码,还维护了一段用于区分注释、字符串和标识符的解析逻辑。与此同时,真实研究循环和恢复行为已经有各自的测试。
核对当前依赖、源码与测试接线后,我们移除了这份退役扫描,没有再给它增加缓存或维护一个更复杂的解析器。原始退役证据与有效的恢复契约继续保留,历史身份字段也没有因为名字里带着 graph 就被一起删除。
这类清理需要区分三个对象:旧实现可以退出,历史证据可以保留,仍有效的行为保护需要继续存在。它们不必住在同一份测试文件里。
为了保留迁移历史而永久运行旧扫描,会让日常回归继续承担已经结束的迁移成本;为了删除旧框架而顺手删掉持久化读者,则可能破坏真正还在使用的能力。
退役测试适合在建立时就写清退出条件,例如依赖与调用者已经移除、持久化兼容已经处理、必要行为已经由当前入口覆盖。满足条件后,重新判断它是否还值得进入每次回归。确实独占保护有效架构约束的扫描仍可以保留,退出条件不意味着所有静态检查都应删除。
三、已有测试的五种去向
维护测试容易落入两个极端:原有测试全部保留,新能力继续叠加;或者遇到阻力就删除,直到检查重新通过。更有用的做法,是按它保护的当前行为决定处置。
| 处置 | 适用情况 | 需要保留的判断 |
|---|---|---|
| 保留 | 仍保护有效行为或独有故障 | 这次改动是否影响原来的允许、拒绝或恢复路径 |
| 重构 | 行为有效,测试却依赖内部结构或不稳定前提 | 改写后是否仍能识别原来的缺陷 |
| 合并 | 多处测试证明相同契约,重复装配或初始化 | 独有反例有没有随重复代码一起消失 |
| 退役 | 旧能力正在退出,但仍有调用者或兼容要求 | 谁负责完成退出,满足什么条件后移除 |
| 删除 | 完全重复、没有有效契约价值,或退出条件已满足 | 删除理由是否成立,活动引用是否同步清理 |
删除测试不需要机械地“一删一补”。一个只锁定旧文件位置、没有现行契约价值的断言,可以直接退出;但一个独占保护跨账户隔离的反例,即使经常拖慢测试,也不能因为昂贵就被移除。
重构则需要尽量把断言放回稳定的行为接口。Google 的 Andrew Trenk 在《Test Behavior, Not Implementation》中讨论过这个原则:内部实现变化而对外行为不变时,测试通常不应被迫跟着变化。
调用顺序也要结合语义判断。某个内部辅助函数先执行还是后执行,可能只是实现细节;而“持久化成功前不能报告保存成功”,就属于需要保护的外部行为。
合并测试时同样不能简单认为,更高层的用例一定更强。端到端测试可能经过更多模块,却没有断言某个独有错误条件。合并的依据应该是故障覆盖,而不是测试层级或代码行数。
四、在写 Spec 时把处置落到文件
如果等到实现完成才整理测试,开发者会同时面对新行为、旧断言、共享夹具和验收选集。此时再讨论哪些测试应该退出,容易变成围绕当前报错的临时判断。
设计阶段更适合回答这些问题,因为行为差异和责任边界正在被明确。例如,准备调整恢复候选读取时,就应该同时说明:旧测试为什么排除过期租约,新契约允许它进入哪个阶段,原来的活跃租约保护留在哪里,内存与数据库用例分别如何表达。
这些答案可以按受影响测试组,在现有设计决策或执行计划里简记“文件、处置、理由、保留覆盖或退出条件”。不必逐断言建立一份新台账。
接缝已经明确,就在同一轮编写或修改可执行用例,并同步受影响的夹具、辅助函数、测试运行入口和活动选集。接口仍有未决问题,则记录具体缺口和最早可执行点,避免先放一个空壳或长期跳过的测试占位。
只改 Spec 模板仍然不够,后续环节需要接住同一个决定:
- 实现继续使用设计时已经准备的测试,核对已有执行记录的适用输入。
- 评审同时查看生产代码、测试、夹具和选集的差异,检查独有反例是否丢失。
- 验收核对处置是否落实,以及保留的断言是否仍约束正确行为。
尤其是共享夹具,修改前要查清消费者。把两份重复初始化抽成一个辅助函数,可能减少代码;如果顺便引入共享可变状态,也可能让更多测试互相影响。
我们已经把这些要求接入现有 Spec 编写、实现和评审验收指引,普通的小修复也适用,无需为了整理几条断言新建完整 Spec。后续是否持续减少返工,需要从真实改动中继续观察。
五、从重复读取和真实等待中拿回成本
这轮整理还发现,分层审计先读取全部测试源码建立内容映射,随后分类时又把同一批文件读了一遍。
这里不需要改变测试契约。直接复用第一次读取的内容,就能去掉重复读取,原来的分类与断言保持不变。
时间夹具也值得单独处理。内存恢复用例原先通过短暂等待,让租约进入过期状态。修改后使用受控时钟推进时间;数据库用例则直接设置测试自有租约的到期时间,再让真实数据库执行候选查询。
Vitest 的日期模拟文档展示了控制系统时间及恢复真实时钟的方法。不过,替换时间的方式仍要匹配实际判断位置:应用里的模拟时钟不会自动改变 PostgreSQL 的时钟,数据库用例需要控制数据库实际读取的事实。
这些修改消除了明确的重复操作和真实等待。单次运行时间有所下降,但还不足以估计长期节省了多少研发时间。后续需要在可比条件下观察运行耗时、环境失败和重复执行,不能把删掉多少文件当作收益本身。
测试运行选集也需要与文件处置同步。删除最后一个用例后,运行入口和活动计划不能继续指向它;改了共享夹具,则需要覆盖受影响的消费者。仅靠把慢测试移出默认集合,维护成本往往只是被推迟了。
下一次写 Spec 时,可以先打开已有测试,说明每组断言正在保护的承诺,再决定它应继续约束新设计、换一种表达,还是随着旧机制一起退出。这样,测试维护就能成为设计工作的组成部分,而不必总在实现结束后追着失败补债。
相关阅读:系统演化与旧能力退出 · 测试与评测的证据边界。欢迎在 GitHub 工程文集 点个 Star。