手写测试只能覆盖你想到的输入。基于属性的测试(property-based testing)换了个思路:你描述「对任何输入都应成立的规律」,让 Hypothesis 去生成成百上千个输入,找到反例后还会自动把它缩到最小。这篇用一个看上去没问题的行程编码函数,亲眼看它怎么被找出毛病。
rle.py def rle_encode(s): # "aaabcc" → "3a1b2c" out = []; i = 0 while i < len(s): j = i while j < len(s) and s[j] == s[i]: j += 1 out.append(f"{j - i}{s[i]}"); i = j return "".join(out) def rle_decode(t): out = []; num = "" for ch in t: if ch.isdigit(): num += ch else: out.append(ch * int(num)); num = "" return "".join(out)
1 passed in 0.07s
7 个手写例子("aaabcc"、空串、"hello world"……)全部通过。这些都是「合理的输入」,恰好绕开了 bug。
property @given(st.text(max_size=30)) def prop(s): assert rle_decode(rle_encode(s)) == s
Hypothesis 报告失败。最终(最小)反例 = '0'
note: Failing test case: prop(
s='0',
)
note: Explanation:
note: These lines were always and only run by failing test cases:
note: prop.py:14
property 被调用了 18 次; 其中失败 6 次
被调用的前 12 个输入: ['', '\x89\U0001b622vîZ\U00089a4f!ü\U0007d364\U0008b1c5@\x1dà\U000b779e\x96{\U000ce570\x84\U000e7cc8', '\x1fç\U00063ce4\x9d', 'ü', 'J\x03', 'a\x14"±\U000644ffQ', 'Ö7', '\U0009684fu\U000b07e1\U00102305~\x8eJNâP\U00101fb4=ùX', 'læ9\x01Ñ\U000a39ee\x00)', 'ñ', 'È\x8e=', '¸\x9d#']
首次失败的输入: 'Ö7' (长度 2)
缩小(shrinking)过程中依次出现的失败输入(去重, 按出现顺序):
'Ö7' 长度 2
'læ9\x01Ñ\U000a39ee\x00)' 长度 8
'00' 长度 2
'0' 长度 1不到 20 次调用就失败了:第 7 个随机输入 'Ö7'(含数字字符)就让往返不成立。Hypothesis 报告的不是这个随机串,而是缩小之后的最小反例 '0'——「note」里给出的 s='0' 就是你可以直接拿去写回归测试的输入。整个过程 property 被调用了 18 次,其中 6 次失败(都是缩小阶段的试探)。仔细看被调用的前 12 个输入:空串、单字符、带控制字符和各种 Unicode 的字符串——它们不是随便乱来的,Hypothesis 会偏向边界值(空串排第一个)。
原因:encode("0") 得到 "10",解码时 1 和 0 都被当成数字累积成重复次数,没有后续字符可重复,返回空串。行程编码没有为「输入本身含数字」留出转义,这是设计上的漏洞。
Hypothesis 发现 2 种不同的失败:
ValueError: invalid literal for int() with base 10: '1²1' | Failing test case: prop( s='²:', )
AssertionError: | Failing test case: prop( s='²', )
调用次数 104
'²'.isdigit() = True | unicodedata.category('²') = No | '²'.isdecimal() = False我第一次的修复思路是:输入里不要出现十进制数字字符(类别 Nd)。Hypothesis 立刻又找到两种失败:'²'(上标 2)的 isdigit() 是 True——而它的类别是 No、isdecimal() 却是 False,所以既躲过了我的类别过滤,又骗过了 decode 里的 isdigit() 判断,最后 int('1²1') 抛出 ValueError。一个手写例子里很难想到「上标数字」。
过滤掉所有 isdigit() 为真的字符后, 300 个随机输入全部通过
直接用 str.isdigit 本身当条件过滤,300 个随机输入全部通过。要注意:这只是把「有 bug 的输入范围」排除了——真正的修法是给编码格式加上转义或改用长度前缀,让含数字的输入也能正确往返。
第 1 次运行: 失败: AssertionError 本次运行 property 被调用 16 次 第 2 次运行: 失败: AssertionError 本次运行 property 被调用 2 次 .hypothesis 目录里: ['.gitignore', 'constants', 'examples', 'unicode_data']
默认情况下 Hypothesis 会把找到的失败例子存进当前目录的 .hypothesis/examples。第一次运行调用了十几次才碰到反例;第二次运行,它先回放数据库里的旧反例,只调用 2 次就再次失败——所以修 bug 期间每次重跑都是立刻复现,不必再等随机碰运气。这个目录不要提交进版本库(它自带 .gitignore)。
st.integers() 100 个样本里: 0 出现 1 次; 绝对值 < 10 的占 1 个; 超过 2**32 的占 20 个; 最大 = 7033059631192095236290872385462075392 最小 = -5975142986778338865702764544 st.text() 100 个样本里: 空串 1 个; 含非 ASCII 的 91 个; 含数字字符的 20 个
100 个 st.integers() 样本里既有 0、也有远超 2**32 的大整数(20 个),最大的有 37 位;100 个 st.text() 里有空串、91 个含非 ASCII 字符、20 个含数字字符。这种「刻意覆盖极端值」的分布,是它比随机数更容易撞出 bug 的原因。
derandomize=True、database=None 保证可重复,验证四没有关闭随机和数据库st.builds/st.composite 的组合策略、@example 固定用例、profile 设置对 CI 的影响