十条测试、十分钟、同一个不存在的元素
我们的 UI 套件在做正事之前会先断言前提:账号登录了吗、列表长到能切页吗、夹具数据在吗。前提用一个宽松的超时来检查,因为它依赖模拟器慢,而不是依赖产品出错。
这个设计在前提成立时没问题。而当前提为假时,它是整个套件里最糟糕的东西,因为每条用例都独立地为同一个判决付满整个超时。
这是一次真实测量。一个十条用例的套件断言一个列表可以切页。它不能。每条用例各自等满自己那六十秒:
- 套件单轮:12.7 分钟
- 构建系统的整包 flaky 重试:×2
- 覆盖率重跑:×2
一个七十分钟的作业,其中 50 分钟花在等一个永远不会出现的元素上。套件慢不是因为它在测的任何东西。它慢是因为它发现了一个事实,然后把它重新推导了十次。
本能是调低超时。那是用一个错答案换另一个——套件现在为正确的理由更快地失败,而最终会在一台慢机器上因为错误的理由通过。
只记住负判定
修法是一份共享的测试基础设施,它记住这个进程里已经判定为不满足的前提:
public static func evaluate(_ name: String, verify: () -> Bool) -> Bool {
if isKnownUnmet(name) {
print("⏭ 前提「\(name)」已在本 suite 判负,跳过重复验证")
return false
}
let met = verify()
if !met { markUnmet(name) }
return met
}
第一条用例照常等出真实答案,它必须这样。之后同一进程里的每一条用例直接跳到它已有的判决。十条用例,一次超时,同样的结果。
而正判定被刻意地不缓存。当前提成立时,每条用例仍然为自己那次冷启真等元素出现,等满超时也在所不惜。这个不对称就是整个设计,它归结为一个问题:每一种判决分别值多少。
- 失败的前提通常是世界的一个属性——数据不在、权限没给、模拟器启动不对。这些在同一个进程里的两条用例之间不会变。缓存它不会损失任何东西。
- 通过的前提是一次真实测量。它是「元素在超时内出现了」的证据,也正是那个能让你察觉套件正在变慢、开始变 flaky 的东西。缓存它,你就删掉了那个信号。
这与关于缓存的通行建议正好相反,而它成立是因为在 CI 里贵的那个状态是失败,不是成功。失败既慢又冗余:N 条用例为一个判决付 N 次。成功也慢,但不冗余——每条用例确实都需要自己那一份答案。
缓存必须自己出声
一个被缓存的判决改变了套件的行为,所以它必须说出来。跳过时会打印前提的名字和原因。没有那一行,十条瞬间失败与十条瞬间成功产生完全相同的测试输出,下一个读日志的人无从分辨一个被正确短路的套件和一个悄悄什么都不再测的套件。
那不是假设出来的客气话。测试套件里的每一个缓存机制,最终都会被误认成它所取代的那个东西,而唯一的防御就是让取代者在输出里可见。
还有一点:这份记忆是按进程划分的,并且可以被清空。若干互不相干的套件可以跑在同一个进程里,而机制自身的测试也需要一张干净的牌面——所以 reset 是 API 的一部分,而不是每个调用方靠重新初始化去够的东西。
采纳它花了多少
有意思的数字不是运行时,而是这次改动让测试代码变小了:
| 迁移的产品 | 16 |
| 触及的文件 | 111 |
| 新增行 | 684 |
| 删除行 | 948 |
净减 264 行。这不是因为那个 helper 很短,而是因为每个套件都长出了自己那一份同一个模式,通常还写得略有差别,每一份都带着自己的超时常量和自己对「什么时候放弃」的理解。把这个写法从每个套件里拿掉,删掉的比共享 helper 加上的还多。
这些拷贝值得被拿掉还有第二个理由。一旦每个套件都拥有自己的重试与超时逻辑,总会有人在赶时间的时候调其中一个——调短,因为它慢了——于是你会得到十五个对「慢」有十五种微妙不同定义的套件。一份带单一超时的共享前提检查是一个更小的表面,而它是一个数字只被写下一次的表面。
这个形状
这里的模式其实与 UI 测试无关,值得说得更一般一点:
在任何重复验证里,问一句这个判决是关于世界的,还是关于这次尝试的。
世界在第三条用例和第四条用例之间没有变。尝试变了。当一个判决是关于世界的——夹具缺失、权限被拒、构建发了错的包——那么在一次运行的生存期内记住它是安全的。当它是关于尝试的——这次渲染及时吗、这个请求返回了吗——记住它就毁掉了那次测量。
多数关于缓存的建议假定相反的默认值,因为在普通软件里贵的是成功:你想避免重算一个已经有的答案。而在测试套件里贵的是失败,因为那正是超时长、而且工作注定白费的状态。
这个推广很小,我们认为可以迁移到任何重复验证前提的地方:反复检查某个依赖是否就绪的编排系统、每次尝试前重建连接的重试循环、按定时器跑的健康检查。在这三者里都问一句——你即将重新验证的那个东西,自上次以来可能变过吗——然后缓存那些不可能变过的判决。
小结
套件慢的时候,先量它在等什么,再去缩短任何东西。我们那条七十分钟的作业有五十分钟花在一个它早已确立事实上,而任何超时调参都救不回来——那份工作只是被重复做了。
在验证代码里,不对称缓存通常是对的,而这个不对称不是随意的。失败的判决是环境的一个事实,在一次运行内不会变;通过的判决是一次测量,缓存它就删掉了你用来看出套件变慢的那个信号。缓存负判定,永远重做正判定。
让缓存在输出里可见。十条瞬间失败与十条瞬间成功无法区分,除非有东西说明是哪一种;一个沉默的缓存和一个不再做测试的套件无法区分。
并且预期这次迁移会删掉代码。把每个套件各自的重试写法换成一份共享检查,在十六个产品上删掉的比加上的多,因为每一份拷贝都有自己的超时常量。一个只写一次的共享数字,比十五个各自调过的数字值钱得多——而找到它们的唯一办法,是删掉这个模式,而不是加一条禁止它的规则。