QuickCheck:让性质替你写测试
进入上游卷——从这里开始,每章都是一件「只有源头才有」的手艺,而且每件都能立刻带回你的语言。第一件是测试方式的换代:普通单元测试里,输入是你挑的,而你挑不出自己想不到的边界情况。2000 年,Claessen 和 Hughes 发表了 QuickCheck:你只声明「对所有输入都该成立的性质」,机器负责造几百组刁钻输入轰炸它,失败后还会自动把反例削到最小。这个思路如今叫 property-based testing,jqwik、Hypothesis、fast-check 全是它的移植——你今天学,下周就能在工作里用。
从用例到性质:换一个提问方式
先摆出对照组——你写过一万遍的测试长这样:
@Test fun testReverse() {
assertEquals(listOf(3,2,1), listOf(1,2,3).reversed())
assertEquals(emptyList<Int>(), emptyList<Int>().reversed())
// 还该测什么?单元素?重复元素?一千个元素?……凭感觉列
}
它验证的是「这几个我想到的输入没问题」。而 bug 的定义恰恰是「你没想到的情况」——用想得到的用例去抓想不到的 bug,逻辑上就先天不足。属性测试问的是另一个问题:「有没有一句话,对所有输入都成立?」对 reverse,至少有三句:反转两次回到原样;反转不改变长度;反转后首元素是原来的末元素。写成 Haskell:
prop_involutive :: [Int] -> Bool prop_involutive xs = reverse (reverse xs) == xs prop_keepsLength :: [Int] -> Bool prop_keepsLength xs = length (reverse xs) == length xs
λ> quickCheck prop_involutive +++ OK, passed 100 tests.
那 100 个输入哪来的?看签名:参数是 [Int],QuickCheck 按类型自动生成(第 6 章的类型类机制在幕后接单)——空表、单元素、负数、重复、越来越长的随机列表。你声明「对所有」,它负责「找例外」。角色转换的本质:用例测试里你是出题人,水平受限于想象力;属性测试里你是立法者,机器当质检员——它没有想象力,但它不知疲倦、毫无成见。
下水:抓一个真 bug,看它收缩
测试台里预置了四条性质,其中 prop_revApp 是 QuickCheck 论文里的著名反面教材(看起来很对!),prop_nubKeepsLen 是另一个埋伏。跑起来,重点观察失败后的「收缩过程」表格:
先看绿的:通过时注意「最大的用例」有多大——生成器会随测试轮数逐渐加码,从空表小数字试到长列表大数字,边界和常规一起覆盖。再看红的:失败时发生的事叫收缩(shrinking),是 QuickCheck 的另一半灵魂:随机撞上的反例通常又长又乱(比如 [-23,7,0,15] [4,-9]),拿它 debug 是折磨。QuickCheck 会反复尝试「删掉一些元素」「把数字往 0 靠」,只要削过的输入仍然让性质失败,就继续削——直到再削一刀就不失败为止。交到你手上的是最小反例,比如 [0] [1]:两条列表各一个元素,一眼看穿 bug 在哪(reverse 拼接后顺序应该先 ys 后 xs)。「随机找到 bug」是运气,「自动把 bug 化简到能一眼看懂」才是生产力。
往返(roundtrip):encode 后 decode 回到原样——序列化、加密、解析器的头号性质,一条顶五十个用例。不变量(invariant):操作前后有什么不变——排序不变多重集、余额转账总和不变、树旋转后仍然有序。参照(oracle):优化版结果 == 朴素版结果——重写引擎、加缓存、上并发时的保命符。代数定律:结合律、交换律、幂等(f (f x) == f x,normalize/去重类函数几乎都该有)。找不到性质的函数是少数;更多时候是你从没被要求用这个角度想过自己的代码——这个角度本身就是收获。
生成器:随机也要讲工程
按类型自动生成靠的是类型类 Arbitrary(第 6 章的知识:Int、[a]、元组都是会员)。自定义类型要么 deriving 要么手写生成器,手写时用 Gen 的组合子——注意它们又是 do 语法(第 10 章的知识,Gen 也是 Monad):
instance Arbitrary Order where
arbitrary = do
n <- choose (1, 5) -- 1 到 5 件商品
items <- vectorOf n arbitrary
Order items <$> arbitrary -- 第 9 章的句式,信手拈来
-- 生成器有分布陷阱:随机 Int 几乎永远非零,「除数为零」这类分支根本测不到。
-- 用 frequency 手动加权,用 classify 观测分布:
arbitraryDivisor = frequency [ (1, pure 0), (9, arbitrary) ] -- 10% 概率给 0
这里有一条实战军规:先怀疑生成器,再怀疑代码。「跑了一千次都绿」可能是代码真没 bug,也可能是生成器从来没生成过触发 bug 的形状(空列表?超长字符串?全等元素?)。QuickCheck 的 classify/collect 能打印输入分布报告——写完性质顺手看一眼分布,是老手和新手的分水岭:
λ> quickCheck (\xs -> collect (length xs `div` 10 * 10) (prop xs)) +++ OK, passed 100 tests: 38% 0 -- 四成输入长度不到 10——如果 bug 只在长列表出现,这个分布测不到它 27% 10 19% 20 ...
还记得第 12 章的纯随机数吗?现在兑现:QuickCheck 的随机全部来自显式种子,所以每次失败都可复现——CI 里报的失败,把种子抄下来本地重放,一帧不差。测试台的种子输入框就是这个机制,换个种子跑跑看,失败用例不同,但收缩终点一致——这也侧面验证了收缩的价值:无论从哪个反例出发,都收敛到同一个最小形状。
再上一层:有状态的系统怎么测
「我的代码不是纯函数,是个有状态的服务,性质测试还有用吗?」有,而且是高级形态:基于模型的测试(model-based testing)。思路:给你的系统写一个「傻瓜参考模型」(比如,你的 LRU 缓存的模型就是一个朴素的关联列表),然后让 QuickCheck 随机生成操作序列——put、get、evict 的任意排列组合——同时打在真实系统和模型上,断言两者行为一致:
-- 伪代码骨架: prop_cacheAgreesWithModel :: [CacheOp] -> Bool prop_cacheAgreesWithModel ops = run realCache ops == run naiveModel ops -- 生成器随机产出 [Put "k" 1, Get "k", Put "j" 2, Evict, Get "j", ...]
普通测试覆盖你想到的调用顺序;这个测法覆盖你想不到的调用顺序——而有状态系统的 bug 十有八九藏在「特定顺序」里(先 evict 再 put 同 key 再 get?边界容量上连续 put?)。收缩在这里更神:失败的 200 步操作序列会被削成最短的致病序列,常常就三四步,直接就是 bug 报告。第 15 章提到的 Riak 那个 17 步并发 bug,用的正是这个技术的并发版(quickcheck-state-machine / Quviq 商业版)。jqwik 和 Hypothesis 都有对应机制(jqwik 的 Action 链、Hypothesis 的 RuleBasedStateMachine),关键词记好,回家就能查。
Kotlin/Java — jqwik(JUnit 平台原生集成):
@Property
fun reverseTwiceIsIdentity(@ForAll list: List<Int>) {
assertThat(list.reversed().reversed()).isEqualTo(list)
}
Python — Hypothesis(pytest 生态,收缩器业界最强):@given(lists(integers())) 注解一行。JS/TS — fast-check:fc.assert(fc.property(fc.array(fc.integer()), xs => ...))。三者全支持收缩与种子复现。落地建议:别推翻现有测试,先给一个「纯函数密集」的模块(解析、金额计算、状态归约)加三条性质——通常当天就能挖出一两个陈年边界 bug,用战果说服团队,比布道有效。
其一,恒真性质:「排序后长度不变」如果你的 sort 实现是 return xs 也能过——性质要能杀死错误实现,写完先故意把实现改坏,看性质红不红(变异测试思维)。其二,在性质里复述实现:拿实现的逻辑当性质,等于用代码验证代码,永远绿。性质应该说「什么」,不该说「怎么」。其三,把属性测试当银弹:它擅长纯函数和代数结构,不擅长「点击按钮后弹窗文案正确」——示例测试、属性测试、集成测试是三种武器,不是替代关系。顺带:性质测试天然要求函数「可以被随便喂输入」——不纯的函数喂不了。第 11 章的纯核心架构,在这里兑换成了测试红利。
QuickCheck 的后续故事值得知道:Hughes 后来创办 Quviq,用商业版 QuickCheck 给 Volvo 测车载软件、给 Ericsson 测电信协议,最著名的战绩是用性质测试在 Riak 分布式数据库里揪出「需要 17 步精确交错才能触发」的并发 bug——人类写不出这个用例,随机+收缩找得到。这条「学术发明 → 上游验证 → 全语言移植」的路径,和你前面见过的 ADT、Maybe 一模一样;属性测试只是走得最近的一班,2010 年代才大规模抵达下游。
1. 给 fromMaybe 写两条性质(提示:分 Just 和 Nothing 两种输入想「什么永远成立」),再给 take 写一条涉及 length 的。
2. 在测试台把种子换成你的生日跑 prop_revApp,记录:原始反例多大?收缩了几步?最小反例是什么?
3. 往返性质实战:挑你项目里任意一对 toJson/fromJson(或 serialize/deserialize),用 jqwik/Hypothesis/fast-check 给它写 roundtrip 性质。跑之前预测会不会过。(经验:第一次跑 roundtrip,五成概率挖出 null/空串/Unicode 边界 bug。)
4. 设计题:「转账函数」的性质清单怎么写?至少给三条,分别属于源头碑四句式里的哪一类?
5. 模型测试预演:给你项目里的某个缓存/去重/限流组件想一个「傻瓜模型」(朴素到不可能写错的版本)。真实现和模型的行为差异会在哪类操作序列上暴露?这个思考本身常常就能翻出 bug。