幕 III · 手艺CH 13进度 13/24

属性测试:不写样例,写规律

到目前为止,你写的每个测试都是「样例式」的:给一个具体输入,断言一个具体输出。average([80,90,100]) == 90。这很好,但有个天花板 —— 你只测了你想得到的那几个输入,而 bug 恰恰藏在你想不到的地方(第 9 章)。属性测试换了个玩法:你不写具体样例,而是写下一条对任何输入都该成立的规律,然后让一个引擎生成成百上千组随机输入(包括各种刁钻值)去撞它。撞到反例,它还会自动把那个乱糟糟的反例收缩成人能一眼看懂的最小形态。你负责说「什么该永远成立」,机器负责找「什么时候不成立」。

样例 vs 规律不变量生成 + 收缩kotest / fast-check自动找边界

从「样例」到「规律」

样例测试和属性测试的差别,一个例子就说清了。测一个排序函数:

// 样例式:测你想到的几个具体情况
@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 模块(checkAllArb 生成器、内建收缩)。
  • Java:jqwik(@Property@ForAll)。
  • JS/TS:fast-check(fc.assert(fc.property(...)),收缩很强)。

属性 vs 样例:互补,不替代

◆ 判词 · 两个都要,各管一段

样例测试擅长「具体的、有业务含义的例子」:满 100 打八折就是 80。它是活文档,一眼看懂需求。

属性测试擅长「一整类输入上的普遍规律」:排序永远非降、往返永远还原。它替你搜刮那些想不到的边界。

好的做法是:用几个样例钉住关键的具体行为,用一两条属性罩住整个输入空间。属性抓到反例后,把最小反例固化成一个样例测试。两者形成一张更密的网。

AI 时代:属性测试是「自动找茬」

这一章和全书主线咬合得最紧。第 9 章说,AI 天生只喂 happy path、想不到边界;而找边界又费脑子。属性测试正好把「找边界」这件事自动化了:

▲ 属性测试:你定规律,机器找 AI 漏掉的反例

这里有一个特别漂亮的分工:写属性,是纯粹的意图 —— 「这个函数无论如何都该满足什么」,这需要人类的领域判断,是你最该做、也最不该外包的事。找反例,是纯粹的搜索 —— 灌一千组随机输入去撞,这是机器最擅长的。

于是面对一段 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 响应。下一章讲怎么把「这次和上次长得一样」「前端和后端对得上」变成可断言的东西:快照测试与契约测试。