100 次测试运行是个谎言:如何真正确定你的 Property-Based Tests 规模

如果你用默认的 100 个 example 来运行 property-based tests,那你两头不讨好。你的 CI 比实际需要更慢,而你仍然没有抓到真正重要的 bug。

这个数字并不神奇。包括 Hypothesis 在内的大多数库默认设为 100,只是因为它是一个看起来保险的整数。但「感觉保险」不是测试策略。

Property-based testing 到底承诺了什么

Property-based testing 颠覆了单元测试的剧本。你不再需要手写输入和预期输出,而是定义一个 property——一条应该永远成立的规则。框架会生成输入来试图打破它。

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
    assert lst == list(reversed(list(reversed(lst))))

框架会用随机的整数列表多次运行这个函数。如果它找到了一个反例(counterexample),就会 shrink 输入,把它缩减到仍能失败的最小版本。一个触发了 bug 的 47 元素列表对调试毫无用处;一个 3 元素列表才是金子。

这很强大,但同时也是概率性的。Property-based testing 无法证明正确性。它只能提高「不存在 bug」的信心,或者在确实存在 bug 时把它找出来。这种概率性正是运行次数如此重要的原因。

为什么 100 是任意的

让我们诚实地说说 100 是从哪来的。在 Hypothesis 中,这是 2015 年选定的默认值,因为它是个好看的整数,既能抓住大多数 bug,又不会让测试慢到无法忍受。这是一种社会妥协,不是统计妥协。

找到 bug 的概率取决于两件事:bug 在输入空间中出现的频率,以及你抽取了多少样本。如果一个 bug 只有在输入是长度大于 20 的回文时才会触发,而回文只占所有列表的 0.01%,那么 100 次运行大概只有 1% 的概率能抓到它。这不是测试,这是彩票。

大多数 bug 并没有那么罕见。很多 property 会在空列表、单元素或简单重复数据上失效。一个调优过的 generator 能快速抓住它们。但 100 的默认值假设你的 generator 完美无缺、bug 都很浅显。这两个假设都是错的。

从统计角度看,运行次数到底给你带来了什么

如果我们把发现 bug 建模为「从有放回抽样」——即在一个 bug 出现概率为 p 的输入空间中抽样——那么 n 次运行后漏掉这个 bug 的概率就是 (1 - p)^n。

当 p = 0.01 时,100 次运行仍有 37% 的概率漏掉 bug。当 p = 0.001 时,100 次运行有 90% 的概率漏掉它。要想以 99% 的信心抓住一个出现率 0.1% 的 bug,你需要大约 4600 次运行。

import math

def runs_for_confidence(p, confidence=0.99):
    """Returns the runs needed to catch a bug with probability `p`
    at the given confidence level."""
    return math.ceil(math.log(1 - confidence) / math.log(1 - p))

print(runs_for_confidence(0.01))    # 459
print(runs_for_confidence(0.001))   # 4603
print(runs_for_confidence(0.0001))  # 46050

这是让人不舒服的部分。如果你想对罕见 bug 有很高的信心,就需要数万次运行。没人愿意在 CI 里等那么久。

Shrinking 改变了成本等式

100 次运行的默认值是在 shrinking 还没有今天这么好的时候设定的。现代的 property-based testing 框架不只是找到 bug,它们找到的是最小的 bug。

这意味着你可以从「预算」而不是单纯的「次数」来思考。如果你运行 1000 个 example,在第 847 个时发现了 bug,shrinking 可能还要再花 200 到 300 次执行才能把反例缩到最小。抓一个 bug 的总成本就是 1100 多次运行。但如果你运行 10000 个 example 却一无所获,那你就花了 10000 次运行买个安心。

诀窍在于把「发现」和「验证」分开。在 CI 里跑一个小而快的套件以获得即时反馈;在晚上或 release branch 上跑一个更大、更慢的套件以获得更深的信心。

from hypothesis import given, settings, strategies as st
import json

# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
    assert json.loads(json.dumps(d)) == d

# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
    assert json.loads(json.dumps(d)) == d

这不仅仅是速度的问题,而是信息密度的问题。一个通过了 100 次运行的测试几乎什么都没告诉你;一个通过了 5000 次运行的测试告诉你稍微多一点。而一个失败了 100 次运行的测试则精确地告诉了你该往哪儿看。

我们如何把 property-based tests 分成不同层级

根据我们的经验,最好的方法是不要把所有 property 一视同仁。我们把它们分成三个层级。

Fast properties 在每个 pull request 时运行。这些是机械性的:round-trip serialization、去重的幂等性(idempotency)、数据结构的基本不变式(invariants)。我们运行 100 到 200 个 example,它们在一秒内完成。

Deep properties 在每次合并到 main 时运行。它们瞄准复杂的状态机、事件处理管道,以及任何有组合爆炸风险的东西。我们运行 2000 到 10000 个 example,耗时以分钟计,而不是小时。

Exploratory properties 在发布前手动运行。我们会把 max_examples 调到 50000 甚至更高,让机器在后台磨,同时我们 review changelog。我们用这种方式发现过 race conditions 和整数溢出边界条件,这些是再多的单元测试也抓不到的。

与其猜测,不如这样做

不要再把 max_examples 当成一个调一次就忘的旋钮。把它当作属于 property 本身的配置,而不是框架的。

为你写的每一个 property 问三个问题。

这个测试运行起来有多贵?如果每个 example 花 50ms,10000 次运行就是 8 分钟。如果花 5ms,不到一分钟。

如果我们漏掉了这个 bug,后果有多严重?日志消息里的格式错误和支付管道里的数据损坏可不是一回事。

触发条件有多罕见?如果 bug 只在闰年出现,或者只在两个 UUID 碰撞时出现,或者只在恰好 INT_MAX 时出现,那你就需要更多的运行次数,或者一个更聪明的 generator。

更聪明的 generator 几乎总是胜过更多的运行次数。如果你在测试 JSON parser,不要生成随机字符串然后指望它们能解析。先生成合法的对象,然后对它们做变异(mutate)。

from hypothesis import given, settings, strategies as st
import json

# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
    try:
        json.loads(s)
    except json.JSONDecodeError:
        pass  # Most inputs hit this immediately

# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
    assert json.loads(json.dumps(d)) == d

一个好的 generator 跑 500 次,胜过烂的 generator 跑 10000 次。每一次都是。

关于确定 property-based tests 规模的常见问题

更多的运行次数不总是意味着更好的 coverage 吗?

并不完全是。Property-based testing 在传统意义上并没有 coverage 指标。更多的运行次数确实能提高找到 bug 的概率,但边际收益递减得很快。从 100 翻倍到 200 是有意义的;从 10000 翻倍到 20000 则很少有意义。

那 fuzzing 呢?那不就是把 property-based testing 跑上百万次的版本吗?

Fuzzing 与之相邻但不同。Fuzzer 通常运行数百万个输入,却对领域没有任何语义理解。Property-based testing 使用结构化的 generator 和 shrinking。你可以把 PBT 看作智能 fuzzing,或者把 fuzzing 看作暴力 PBT。运行次数的考量不同,因为每次运行的成本和每次运行获得的信息并不相同。

我应该在 CI 里把 max_examples 设得更高还是更低?

CI 里设更高,本地开发设更低。你的笔记本用来求快,你的 CI 用来求稳。用 settings profile 或环境变量在两者之间切换。

import os
from hypothesis import settings

CI = os.environ.get("CI", "false").lower() == "true"

settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)

settings.load_profile("ci" if CI else "dev")

我怎么知道我的 generator 够不够好?

把 max_examples 设得很高——比如 50000——然后跑测试,同时盯着 coverage report。如果有分支没有被覆盖到,说明你的 generator 没有锻炼到它们。在降低运行次数之前,先修好 generator。

别再寻找完美的运行次数,开始测量吧

Property-based testing 不存在放之四海而皆准的正确运行次数。只有对你的 property、你的 generator、你的 CI 预算,以及你试图预防的 bug 的代价而言正确的数字。

如果必须,那就从 100 开始。但要为守护关键路径的 property 加大规模,只为 sanity check 的 property 缩小规模。测量你的测试耗时,给你的 generator 做 profile。记住:一个通过了 100 次的 property-based test 不是证明,它只是证据。

如果你想深入研究,Hypothesis 关于 test statistics 和 targeted property-based testing 的文档值得一读。hypothesis CLI 可以准确显示你的测试把时间花在了哪些 example 上。当你决定要把旋钮往大调还是往小调时,这是第一个该看的地方。