你的单元测试只覆盖了你记得的用例

你写了一个 reverse 函数。你用 [1, 2, 3][4, 5, 6, 7] 测了它。测试通过。你发布了。

用户传入了一个单元素切片。你的函数把它漏掉了。他们提了 issue。你盯着测试文件,想不通这么明显的问题自己是怎么漏掉的。

你漏掉它,是因为 example-based testing 只能抓住你提前预料到的 bug。测试套件里的每一个 assert_eq! 都是你对失败可能藏在哪里的猜测。property-based testing 用随机数据和数学 invariants 取代了这些猜测。它能找到单元素 bug,也能找到空向量 bug、整数溢出的边界 case,以及你根本不知道存在的 Unicode 边界 case。

随机输入与 invariants,而非示例与预期

property-based testing 扭转了思路。你不再挑选输入并断言精确输出,而是定义一条对所有输入都必须成立的 property,然后测试框架生成数百个随机输入来尝试打破它。

经典的例子是反转列表。property 不是“[1, 2, 3] 的反转是 [3, 2, 1]”,而是“把列表反转两次会回到原始列表”。这条 invariant 对任何可能的列表都成立,所以框架不断抛出随机列表,直到有东西出错或者达到迭代上限。

Rust 有两个成熟的库可以做这件事。quickcheck 是最早流行的选择,从 Haskell 移植而来。proptest 是更新的方案,带有一个决定性特性:automatic shrinking。当 proptest 发现失败输入时,它不会直接打印一个百元素向量然后让你去调试。它会简化输入,直到找到最小可能的反例。这就是“读失败测试输出”和“真正理解 bug”之间的区别。

shrinking 如何帮你找到真正的 bug

shrinking 是秘诀所在。假设 proptest 生成了一个包含 47 个整数的随机向量,你的 property 失败了。bug 不是第 47 个元素的问题,bug 很可能是关于空向量、符号变化或重复值之类的。

proptest 明白这一点。它尝试移除元素,尝试把值归零,尝试让向量越来越小。如果测试在更小的输入上仍然失败,它就继续 shrink。当所有更简单的变体都通过时,它才停止。结果就是最小反例。

这不是锦上添花,而是 property-based testing 在实践中真正可用的原因。没有 shrinking,你又要去干草堆里找针。有了它,针直接递到你手上。

真实的 bug,真实的测试

这里有一个带微妙 bug 的 reverse 函数。看看你能不能比测试更早发现它。

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = Vec::with_capacity(xs.len());
    for i in 1..xs.len() {
        rev.push(xs[xs.len() - i].clone());
    }
    rev
}

范围从 1 而不是 0 开始。对于任何非空列表,第一个元素都会被静默丢弃。一个用 [1, 2, 3] 写的单元测试可能通过——如果作者只检查了输出看起来是反转的,而没有检查长度是否正确。但 property 能立刻抓住它。

proptest 加进你的 Cargo.toml

[dev-dependencies]
proptest = "1.6"

然后写出 property:

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
        let rev = reverse(&xs);
        let rev_rev = reverse(&rev);
        prop_assert_eq!(xs, rev_rev);
    }
}

运行 cargo test,输出说明了一切:

thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
  left: `[0]`,
 right: `[]`'

proptest 把一个失败的随机向量 shrink 到了单个元素 [0]。第一次调用返回 [],第二次调用也返回 []。property 要求 [] == [0],于是失败。一旦输入被最小化,bug 就显而易见了。

你也可以直接测试结构性 property:

proptest! {
    #[test]
    fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
        prop_assert_eq!(xs.len(), reverse(&xs).len());
    }
}

它同样以最小输入失败,但给出的错误更清晰:输出长度是 0,而不是 1

为什么你仍然需要常规单元测试

property-based testing 不是 example-based tests 的替代品,而是补充。

单元测试记录意图。当新开发者读到 reverse(&[1, 2, 3]) 应该等于 [3, 2, 1] 时,他们就明白了这个函数该做什么。property test 说“这条 invariant 成立”,但它不会告诉你正常情况下的输出长什么样。

properties 也比 examples 更难写。状态机、副作用和 I/O 并不能干净地映射到纯函数。proptest 有复杂类型的 strategies,但把它们搭起来比写几个 assert_eq! 要费脑筋。

还有时间成本。一个 property test 可能跑一千次迭代。在一个大型 codebase 里,这会累积起来。你应该在 CI 里跑 property tests,而不是在开发的每一次按键时都跑。

随机测试仍然可以是可复现的

随机性让人不安。如果测试只是偶尔失败,是代码的问题还是测试的问题?

proptest 通过持久化失败的 seeds 来解决这个问题。当 property 失败时,它会将 seed 和 shrink 后的输入保存到 proptest-regressions/ 目录下的文件中。下一次运行会重放那个确切的 case,然后再生成新的随机数据。一个 flaky 的 property test 几乎总是 property 本身有 bug,而不是框架 flaky。

如果你需要严格的确定性,可以在测试配置里固定 seed。大多数团队不这么做。regression 文件已经足够了。

property tests 在哪里最值得投入

当你的代码具有清晰的数学 invariants 和巨大的输入空间时,property-based testing 值得投入额外开销。

排序是教科书般的例子。properties 很简单:输出是有序的,它是输入的一个 permutation,且长度相同。一个有 bug 的排序可能通过手写示例,但在带有重复元素的随机数据上失败。

parser 和 serializer 是另一个 sweet spot。round-trip properties 如 parse(serialize(x)) == x 能抓住只在特定字节序列下才会出现的编码 bug。

任何涉及算术、集合或状态转换的代码都能受益。任何涉及外部 API、严格 timing 或人类判断的代码则不适合。

从一个 property 开始

你不需要重写整个测试套件。挑一个具有清晰 invariant 的纯函数。把 proptest 加到 dev-dependencies。写一条 property。运行它,看着它通过,然后故意引入一个 subtle bug,再看着它在几秒内把失败 shrink 成你能轻松调试的东西。

一旦你看到随机测试发现了一个你永远不会写出示例的 bug,你就会更频繁地伸手去拿 properties。不是每个测试都用,而是用在真正重要的那些上。