结论先行:示例测试(example-based)验证的是"我想到过的输入",属性测试(property-based)验证的是"对所有输入都该成立的规则"。前者靠枚举,后者靠不变量。这两者补的不是同一块盲区——我上一篇讲变异测试补"断言有没有接住输出",这篇讲属性测试补"输入空间有没有枚举到"。下面是我团队一次真实落地记录,数字可复现。
一、现象与背景:用例全绿,但边界一碰就碎
先摆数字。我们一条 B 端结算模块的服务端是 Python 3.11 + pytest 7.4,核心是三个纯函数:split_amount(total, weights)(按权重分账)、round_cents(x)(金额取整到分)、merge_intervals(list)(合并时间区间)。示例用例共 46 个,全绿,行覆盖 90%。
但 2024 年 Q4 一个季度里,线上/预发接连炸了 3 个 bug,全是"值碰巧对了"型的:
split_amount(100, [1,1,1])返回[33,33,34],总和=100,对;但split_amount(101, [1,1,1])返回[33,33,33],总和=99,少 1 分——余数只加给了第一个权重,最后一个权重永远拿不到。我们的示例用例里从没写过"除不尽 + 余数 > 1"的组合。round_cents(-0.005)与round_cents(0.005)舍入方向不一致(Pythonround是银行家舍入),示例只测了正数。merge_intervals在"嵌套区间 + 零长度区间"组合下顺序敏感,示例用例全是"首尾相接"的理想序列。
共性:这三个 bug 都不是逻辑写错,而是输入空间没枚举到。46 个示例用例只覆盖了输入空间里我能想到的 46 个点,剩下的大片边界没人管。
二、原理:属性测试到底在测什么
示例测试是"输入 A → 期望输出 B",你为每个 B 手写断言。问题是 B 的集合无穷大,你写不完。
属性测试反过来:不写具体期望值,而是写一条对所有合法输入都该成立的不变量(invariant),然后让工具自动生成海量输入去验证这条规则:
split_amount的不变量:sum(result) == total且all(r >= 0 for r in result)且len(result) == len(weights)。round_cents的不变量:round_cents(x) == round_cents(-x) 时方向对称、abs(result - x) <= 0.005。merge_intervals的不变量:合并后区间两两不相交、且并集覆盖等于原并集(用集合大小比较,规避顺序问题)。
工具(Hypothesis)的策略是基于属性的搜索 + 反例归约:它先随机采样,一旦发现某个输入让不变量不成立,就尝试把"让规则失败的最小反例"压缩出来。你拿到的不是"第 1024 个随机输入挂了",而是"输入 101, [1,1,1] 挂了"——可复现的确定性失败。
一句话:示例测试问"这个输入对不对",属性测试问"这条规则对所有输入都成立吗"。
三、具体做法(可复现)
固定版本安装:
pip install hypothesis==6.108.4 pytest==7.4.4
把不变量写成 pytest 用例,用 @given 声明输入策略:
from hypothesis import given, settings, assume
from hypothesis import strategies as st
# 分账:total 为正整数分,weights 为 1~10 个正整数
total_st = st.integers(min_value=1, max_value=10**6)
weights_st = st.lists(st.integers(min_value=1, max_value=100), min_size=1, max_size=10)
@settings(max_examples=500, deadline=None)
@given(total=total_st, weights=weights_st)
def test_split_sum_invariant(total, weights):
result = split_amount(total, weights)
assert sum(result) == total # 不变量 1:总和守恒
assert all(r >= 0 for r in result) # 不变量 2:非负
assert len(result) == len(weights) # 不变量 3:长度一致
# 时间区间:合并后两两不相交
def _random_intervals():
return st.lists(
st.tuples(st.integers(0, 1000), st.integers(0, 1000)).map(
lambda t: (min(t), max(t))),
min_size=0, max_size=20)
@settings(max_examples=300, deadline=None)
@given(intervals=_random_intervals())
def test_merge_disjoint(intervals):
merged = merge_intervals(intervals)
for a, b in zip(merged, merged[1:]):
assert a[1] <= b[0] # 不变量:相邻区间不相交
两个关键参数:max_examples 控制每个用例生成的输入数(我们按函数复杂度定 300~500,不是越大越好,见第四段);deadline=None 关掉单例耗时熔断,避免边界大输入被误判超时。
跑与复现:
pytest -q test_props.py # 正常跑
pytest -q test_props.py --hypothesis-show-statistics # 看每个用例实际生成数
# 失败时 Hypothesis 会把最小反例写进 .hypothesis/,下次自动优先重放
CI 门禁(GitLab CI 片段):属性测试用例纳入必跑集,失败即红;我们额外加一条——任何"新增纯函数"必须在 MR 里附至少 2 条 @given 用例,否则 review 打回。
四、踩过的坑与反直觉之处
- 不是越大越好:
max_examples从 500 提到 5000,split_amount用例耗时从 1.8s 涨到 41s,但没有再抓到新 bug。边界 bug 集中在小输入域(余数 1~3、零长度区间),把预算摊薄反而稀释了搜索效率。我们最终按"能否稳定复现已知 bug"校准阈值,而不是无脑拉高。 - 不变量写太弱 = 没写:
assert result这种存在性断言在属性测试里同样是弱断言——它只能证明"返回了东西",证不出"返回得对"。必须写守恒、对称、幂等这类语义不变量(弱断言校验的是存在性而非语义正确性,谢景行-248 在快照测试一文里讲得很透)。 - 顺序敏感型不变量要换比较方式:
merge_intervals最初我写assert merged == expected_order,结果 Hypothesis 一生成乱序输入就误报,把 6 个假失败当真 bug 查了一下午。改成"集合覆盖等价 + 两两不相交"后,顺序噪音彻底消失——不变量要写"什么不变",而不是"输出长什么样"。 - 与变异测试互补而非替代:属性测试抓"输入没枚举到",变异测试抓"断言没接住输出"。我们两个都跑——覆盖率回答"跑了没",这两层分别回答"测没测到"的不同侧面,缺一不可。
- 反例归约是双刃剑:Hypothesis 给的最小反例很漂亮,但它只证明"这一个输入挂了",不等于"这一类都挂"。拿到反例后我们仍要人工归因到具体代码分支,再补一条针对该分支的示例用例固化,避免下次重构又漏。
五、结论与可复用清单
属性测试把"输入空间无穷大、示例写不完"这个根本问题,转成"写少数几条不变量、让工具去枚举"。复盘数字:46 个示例用例全绿 → 接入属性测试后,3 类边界 bug 全部转成可复现的确定性失败并修复;新增 9 条 @given 用例,覆盖 3 个纯函数的 6 条不变量,单轮 CI 新增耗时约 14s。
可复用清单:
- 纯函数优先上属性测试:分账、取整、合并、排序、编解码、往返转换(encode/decode)是最肥的猎物。
- 不变量写语义,不写外观:守恒(sum 不变)、对称(x 与 -x)、幂等(f(f(x))==f(x))、覆盖等价,而非
assert result。 max_examples按"能否稳定复现已知 bug"校准,不是越大越好;deadline=None防大输入误判超时。- 顺序敏感的规则用"集合等价 + 不相交"等无顺序不变量,规避假失败。
- 反例归约后人工归因,并补一条针对该分支的示例用例固化,防止重构再漏。
- 与变异测试、契约/快照分层断言配合:三层分别补"输入没枚举""断言没接住""结构没锁住",互为防线。
小结:示例测试证明"我想到的都对",属性测试证明"我想不到的也都对"——后者把测试从"枚举的艺术"变成"规则的工程"。