基于属性的测试
property-based testing,基于属性的测试。这个东西的有趣之处在于,和传统的测试相比,它不需要穷尽一切——或者这么说,在规格层面上,它覆盖的面积是「属性之内全称」。逻辑上来说,如果定义了一个正确的属性,那么它就囊括了该范围下全部的可能集合。当然,它本身不保证百分百命中,优势主要来自于对比单点测试。这个不保证从它的操作就能看出来了,在属性定义了何为正确之后,需要构建一个生成器,去不断地打随机看结果是否落在指定的正确范围内——它的核心思维是采样,这就决定了必然有覆盖不到的地方。
这里先谈一下 Daniel Jackson 的 SCH (small scope hypothesis)。这个概念讲的是:对于某个错误断言来说,几乎一定存在最小的反例;对于那些因实现结构而导致的错误,我们只需要在小范围内穷举就能够找到它。典型的例子是权限,不必模拟几百上千个用户,权限交互可能导致的问题在个位数多个权限来回交互的情况下就会暴露。大部分bug不见得会随着规模而出现(当然,这是个经验判断,也存在那种真的会在特定规模之后才发生的bug),往往在小范围的情况下就会暴露。
好,既然有了这个,我们为什么还要用PBT?问题就是出在穷举上。一旦 scope 写的稍微大一点,穷举的空间就会爆炸,而且还得用 Alloy 这种形式化语言去写。它的优势是跑完了真的能「证明」该范围内不存在反例,但代价偏高。而PBT在这里用随机采样替代了有界穷举,使我们可以不必完全穷举所有内容就取到SCH的收益,代价当然是牺牲了全称意义上的穷尽证明,可这种交换也是值得的。
PBT测试套件存在一个很好用的操作,那就是 shrink。前面说到SCH指明了错误断言通常存在小反例,但对于采样来说,发现错误后怎么能说它是当前采样下的小反例呢?shrink 操作就是为了找到这个小反例。在发现了错误之后,套件就会去尝试不断的缩小错误的输入值,举例来说,比如(733921845, -2147483648) 这种对人类来说很难理解(太大、太宽泛)的错误,它会不断去尝试往零缩,缩完后,会停在一个最小的报错值那里——(0,0)正常(-1,0)却挂了,我们一看就知道错误存在于符号而非单纯的数值上。要注意这里的缩小是根据类型来的,数组会删元素,字符串会减长度。我们可以这样理解,这就是一个不断排除最终接近最小的工程手段,而且往往越简单,对人来说也越好理解。所以,如果PBT发现了什么错误,传统的用例手段仍然会在这里作为补充,shrink 之后用一个最小的回归测试将其固定下来。
坦白来说,就像 Property-based testing is about to rule the (software) world 说的一样,PBT确实是相当小众的,但在AI开发的时代会变得日益重要。核心点就在于生成的速度日益超过了验证的速度,我们的代码正逐渐成为黑盒,我如何保证一个黑盒的质量?现有的测试体系当然可以做到这点,但在效率上始终存在着瓶颈。就拿基于案例的测试来说吧,我(当然,现在测试也是AI写的)必须去绞尽脑汁的思考一些可能出错的案例,但局限性就体现在这里,如果我想不到怎么办?
根本原因在于,传统的测试要求你去思考「你能够想到」的东西,但问题恰恰出在「我想不到」的地方上。这几乎相当于一句废话(或者悖论?),因为我能想到的东西肯定不再构成问题,既然我想到了,我为什么不加进去呢?好吧,大部分情况下我做不到穷尽一切,我的经验和精力限制了我能想到的范围,甚至这一点就算是AI也做不到:尽管他们有着无穷的记忆,但上下文和注意力也是有限的,他们的思考到的可能集会显著的受到其自身所收到的输入范围之限制。
PBT就是为了解决这个问题的。
写它会有点反直觉,因为我们正常写测试用例的时候想到的是「具体」的实现,但写属性的时候要求的是想「形状」——或者用逻辑的说法,我们需要想的是全称式的定义。集合论的理解会更直观一些:{1,4,9,16} 和 {n² | n ∈ ℕ} ,前者只涵盖了四个元素,但是后者涵盖了所有 n ∈ ℕ 的情况,这就是划定范围。
就拿 1+1=2 来说:
// 传统:公理的三个实例
expect(add(1,1)).toBe(2);
expect(add(2,3)).toBe(5);
expect(add(0,7)).toBe(7);
// PBT:公理本身
fc.assert(fc.property(fc.integer(), fc.integer(),
(a,b) => expect(add(a,b)).toBe(add(b,a)))); // 交换律
fc.assert(fc.property(fc.integer(),
(a) => expect(add(a,0)).toBe(a))); // 单位元
fc.assert(fc.property(fc.integer(), fc.integer(), fc.integer(),
(a,b,c) => expect(add(add(a,b),c)).toBe(add(a,add(b,c))))); // 结合律
这里直接从规则上直接把加法的正确边界定义了,一旦我写的加法工具违反了这些规则,它就必然是有问题的。
然后,上面的例子其实是有坑的。这个我一开始也没有发现,反而是事后进行对抗性审查时才察觉。上述的例子虽然完美的满足了教科书的加法公理——但这是一个过大的形状。比如,给入
a|b这样的位运算,就无法捕获计算错误的场景。而且它相当隐蔽,位运算在当前的测试范围内完全可以通过,甚至在不进位的情况下输出的结果和一般的加法没区别。可一旦涉及到进位,结果就会出错。4|3转成二进制就是100和011,结果是111= 7;而5|3是101和011,结果还是7,但合法的结果应该是8。 因此,应当再补上一个消去律,即a+c ≠ b+c ,其中a≠b,这样位运算中的 1|1 = 1,0|1 = 1 就会直接让测试飘红。当然,即便这样,也还是有未覆盖的,异或运算仍然满足当前的性质,并在全绿的情况下给出非预期结果(1^1 = 0,但预期应该是2)。可这里就是取舍了,关键是怎样的形状最贴近业务——我甚至认为常态状况下位运算根本可以不加,因为常规业务压根就不会出现位运算。但这个例子的重要性体现在,即便自以为足够完善的「公理性质」,也会有想不到的盲区。例如此处就是画的太大,不仅一般加法、位运算、异或运算都包含在其中,有太多业务不该包含的内容。案例测试只是将这个体现在单点上,而PBT这里则是我们对关系形状的定义。 by 事后回顾补充
而定义好了后,就要去大量的进行动作,生成随机的a,b,c喂入被测的实现中,检查结果是否符合规则。这里自然引出了一个点——适用范围。因为PBT要靠大量的覆盖来逼近全称,因此,它很不适合放在需要真实时间才能产出结果的东西上面,就比如说DOM渲染,如果每次都要渲染一次,大量打下来时间会膨胀到难以承受。
其次也正是因为这种随机性,本质来说PBT每次的结果不是确定性的——因为每次打出来的随机都不一样,这一次探索了这部分空间,不代表下一次一模一样。我在探索新的潜在错误区域,摸清楚了这一部分,不代表摸清楚了所有部分。但因为每次的随机存在一个seed,单次复现已有错误并不算太难。
需要注意的是,PBT并不是在任何情况下都有效。例如:正确依赖于外部状态(比如特定的魔法数字,或者语义约束),靠约定来保证正确,那么这种并非因关系结构定义,而是查表定义的正确性下,施行PBT的收益就不大(或者说弄出来跟基于案例的几乎没差别了),毕竟正确从一开始就是写死的,那还不如直接上案例测试;随机数生成器大概率无法命中的单点结果,这其实和第一个类似,只有精确的某个值才能正确时,覆盖式的PBT反而是多余的,因为生成器打出的内容除了该点之外全都是浪费的,为什么不直接单例测试?
虽然说PBT可以大范围覆盖,但我们也能看出来它对bug的搜寻强度从一开始就被我们自己构建的属性和生成器决定了。这一点仍然需要人来想,但如果bug刚好出现在覆盖范围外,那么PBT仍然是测不到的。但说实话这种值本来也不该用PBT。PBT的优势体现在存在大量组合的情况下,因为案例测试始终是单点,而PBT则是范围式的。因此,从概率上来说,测出想不到的bug的可能性远比单点高。
实际上从这里能看出来,PBT这种测试方法实际上会反过来约束实现。对于PBT可测的代码,要求需要尽可能的减少外部状态依赖(就像上述所说的,依赖外部约定的状态会让PBT几乎等同于案例),同时保证职责单一(因为结构需要稳定才能画它的范围,过于混杂就会根本画不出来)。为什么说它在AI时代非常合适,恰恰是能够从反过来就约束AI的发挥。
但是,为了克制AI的为了满足测试而改代码的倾向,仍然要注意以下的内容:
- PBT的性质必须画在最外层,也即核心函数上,而不能单纯的用辅助函数凑数。
- 必须经得住变异测试和对抗性审查,否则给你射箭画靶编一个能通过的形状这事儿在PBT这里仍然适用。而且PBT在变异上存在代价,那就是它每次都要大打随机,反而会导致变异全跑的速度变慢。
- 顺序。我当前主张的先写测试后写代码的纪律依旧要维持,形状必须预先固定,然后再去写代码。因为本质上来说,测试是为了固定业务逻辑,只有在充分讨论之后,给AI明确了什么才是「正确的形状」,出来的代码才能是可用的。
- 需要明确什么时候可以不用PBT。就如我上述所说的,有些东西用案例测试就足够了,上PBT完全就是没必要。比如约定好的数值、SQL,这些形状预先固定的内容若是再由AI强写成PBT,那就完全是多余的负收益。
还是回到之前,综合来讲PBT不是个万金油,而且还有不少坑:比如随着业务领域的复杂性增加,生成器的复杂度也会增加。并且还有一个随机打产生的时间问题,运行一次测试的时间会随着生成复杂性而增加。它的核心适用场景是关系搭配下可能存在组合爆炸的情况,例如多个不同的组合搭配导致案例测试覆盖起来极其繁琐——UI操作逻辑测试中的用户操作序列也属于此。滥用PBT在收益上可能是负的,大量的随机生成和操作最终只会拖慢测试时间,甚至搞出来一堆假绿,只会给人心理上的安慰罢了。