属性测试:不写样例,写规律
到目前为止,你写的每个测试都是「样例式」的:给一个具体输入,断言一个具体输出。average([80,90,100]) == 90。这很好,但有个天花板 —— 你只测了你想得到的那几个输入,而 bug 恰恰藏在你想不到的地方(第 9 章)。属性测试换了个玩法:你不写具体样例,而是写下一条对任何输入都该成立的规律,然后让一个引擎生成成百上千组随机输入(包括各种刁钻值)去撞它。撞到反例,它还会自动把那个乱糟糟的反例收缩成人能一眼看懂的最小形态。你负责说「什么该永远成立」,机器负责找「什么时候不成立」。
从「样例」到「规律」
样例测试和属性测试的差别,一个例子就说清了。测一个排序函数:
// 样例式:测你想到的几个具体情况
@Test fun `排序`() {
assertEquals(listOf(1, 2, 3), sort(listOf(3, 1, 2)))
assertEquals(listOf(1, 2, 3), sort(listOf(1, 2, 3)))
}
// 属性式:说清「排好的列表,永远该满足什么」
@Test fun `排序后永远非降序`() = checkAll<List<Int>> { xs ->
val sorted = sort(xs)
sorted.zipWithNext().all { (a, b) -> a <= b } // 对任何 xs 都成立
}
下面那个属性测试,checkAll 会自动生成几百个随机列表 —— 空的、单元素的、全相同的、有重复的、超长的、含极值的 —— 每一个都拿去验证「排完非降序」。那些你手写样例时永远想不到去测的输入,它随手就覆盖了。
一条好属性长什么样
写属性的难点,是找到那条「对所有输入都成立」的规律。有几个经典套路:
| 套路 | 规律 | 例子 |
|---|---|---|
| 往返(roundtrip) | 编码再解码 = 原样 | decode(encode(x)) == x(JSON、Base64、序列化) |
| 幂等 | 做两次 = 做一次 | sort(sort(x)) == sort(x)、trim(trim(s)) |
| 不变量 | 某个性质永远保持 | 排序后长度不变、元素集合不变、结果永远非降 |
| 对照预言(oracle) | 和一个更慢但显然对的实现一致 | 你的快排 == 内置的 sorted() |
| 交换律等代数律 | 顺序无关 | merge(a,b) == merge(b,a) |
回到第 1 章那个「均分账单」的 bug,它的属性一句话就写完了:「所有人拿到的钱加起来,永远等于总额。」这条属性,对 1000/3 这种除不尽的输入照样要成立 —— 而 AI 的实现正是在这里栽的。你甚至不用自己想到「1000/3」这个刁钻输入,生成器会替你撞上它。
杀手锏:生成 + 收缩
属性测试真正的魔法,是它撞到反例之后干的事。随机生成的反例往往很「脏」—— 一个有 40 个元素、数值乱七八糟的列表。直接甩给你,你也看不懂错在哪。所以引擎会做一步收缩(shrinking):在保持「仍然失败」的前提下,把反例一点点变小、变简单,直到不能再简化。
下面这个 Demo 是一台真的属性测试引擎。被测的是「AI 写的 clamp(x, lo, hi)」—— 它漏了下界那半句。属性是「结果永远在 [lo, hi] 之间」。点一下,看它随机生成、撞到反例、然后收缩到最小。
你会看到类似这样的输出:一个原始反例可能是 clamp(-73, -12, 44),乱糟糟的;收缩之后,变成 clamp(-1, 0, 0) 这种 —— 一眼就懂:x 比 lo 小 1,结果没被夹住。这正是 AI 漏掉的那半句 if (x < lo) return lo。
没有收缩,属性测试只能告诉你「在某个奇怪输入上挂了」,你还得自己去简化。有了收缩,它直接递给你一个最小、最清晰的反例 —— 几乎就是一份现成的 bug 报告,甚至是一个现成的样例测试(你可以把这个最小反例固化成一个 @Test,防止它复活)。
工具
各语言都有成熟的属性测试库,它们全是 1999 年 Haskell 的 QuickCheck 的后裔:
- Kotlin:kotest 的 property 模块(
checkAll、Arb生成器、内建收缩)。 - Java:jqwik(
@Property、@ForAll)。 - JS/TS:fast-check(
fc.assert(fc.property(...)),收缩很强)。
属性 vs 样例:互补,不替代
样例测试擅长「具体的、有业务含义的例子」:满 100 打八折就是 80。它是活文档,一眼看懂需求。
属性测试擅长「一整类输入上的普遍规律」:排序永远非降、往返永远还原。它替你搜刮那些想不到的边界。
好的做法是:用几个样例钉住关键的具体行为,用一两条属性罩住整个输入空间。属性抓到反例后,把最小反例固化成一个样例测试。两者形成一张更密的网。
AI 时代:属性测试是「自动找茬」
这一章和全书主线咬合得最紧。第 9 章说,AI 天生只喂 happy path、想不到边界;而找边界又费脑子。属性测试正好把「找边界」这件事自动化了:
这里有一个特别漂亮的分工:写属性,是纯粹的意图 —— 「这个函数无论如何都该满足什么」,这需要人类的领域判断,是你最该做、也最不该外包的事。找反例,是纯粹的搜索 —— 灌一千组随机输入去撞,这是机器最擅长的。
于是面对一段 AI 写的、你信不太过的代码,最高效的验证之一就是:你写一条属性,让引擎去证伪它。它会自动撞上那些 AI(和你)都没想到的输入 —— 空、零、负、除不尽、极值 —— 并把反例收缩到你一眼能懂。这几乎就是第 1 章那句「想办法让它当场露馅」的全自动版本。
生成器:告诉引擎「合法输入长什么样」
属性测试的另一半功夫,是生成器(generator) —— 你得告诉引擎去哪个范围里随机取值。默认的生成器(任意 Int、任意 String)已经很能撞,但很多时候你需要约束到「业务上合法」的输入:
// kotest:自定义生成器,只生成"合法的年龄 + 非空名字"
val users = Arb.bind(
Arb.string(1..20), // 名字:1 到 20 个字符
Arb.int(0..150) // 年龄:0 到 150
) { name, age -> User(name, age) }
checkAll(users) { u ->
// 对任何合法 user,序列化再反序列化应还原(往返属性)
fromJson(toJson(u)) shouldBe u
}
生成器太窄,会漏掉边界(你限死年龄 18–65,就永远撞不到「0 岁」的 bug);太宽,又会生成一堆业务上不可能、报出来也没意义的「假反例」。好的生成器恰好覆盖"合法但刁钻"的输入空间。而且好的属性库会刻意偏向边界值去生成 —— 空串、0、最大值、负数出现的概率,比纯均匀随机高得多,因为它们知道 bug 爱待在那儿。
把最小反例,钉成一个永久测试
属性测试和样例测试的最佳配合,是一条流水线:属性测试撞出反例 → 收缩成最小 → 你把这个最小反例固化成一个样例测试。
// 属性测试撞出并收缩出的最小反例:clamp(-1, 0, 0)
// 把它钉成一个具体的回归测试(第 8 章),防止它复活:
@Test fun `regression clamp 下界`() {
assertEquals(0, clamp(x = -1, lo = 0, hi = 0)) // 修复前:返回 -1
}
属性测试用随机性去发现你想不到的反例 —— 但正因为随机,它不保证每次都撞到同一个(甚至不保证每次都撞到)。所以一旦它找到一个真 bug,把那个最小反例固化成一个确定的样例测试,就有了一个每次都跑、永远守着这个具体缺陷的哨兵。两者形成闭环:属性拉网搜捕,样例定点看守。
1. 回到 Demo,多点几次「换一批种子」。留意每次撞到的原始反例都不同,但收缩后几乎总是「x 比 lo 小 1」——最小反例揭示的是同一个根因。
2. 给一个你写过的函数找一条属性:它有往返吗?幂等吗?有什么不变量?哪怕只想到一条,也试着写成 checkAll。
3. 拿一段 AI 写的工具函数(解析、格式化、计算),写一条属性去撞它。看引擎能不能替你找到一个它漏的边界。
手艺篇还剩最后一种情况:有些东西的「期望值」根本不好手写 —— 一整棵 Compose UI、一大坨 JSON 响应。下一章讲怎么把「这次和上次长得一样」「前端和后端对得上」变成可断言的东西:快照测试与契约测试。