bigywater.tech
Python · 99

Hypothesis:基于属性的测试如何发现边界 bug

手写测试只能覆盖你想到的输入。基于属性的测试(property-based testing)换了个思路:你描述「对任何输入都应成立的规律」,让 Hypothesis 去生成成百上千个输入,找到反例后还会自动把它缩到最小。这篇用一个看上去没问题的行程编码函数,亲眼看它怎么被找出毛病。

7 / 7 通过
手写的 7 个例子全部通过——bug 依然在
'0'
Hypothesis 缩小得到的最小反例,只有 1 个字符
2 次
失败被记入数据库后,重跑只调用了 2 次 property 就复现

做的事:一个「看起来没问题」的行程编码

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。

验证一:告诉 Hypothesis 一条规律

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 都被当成数字累积成重复次数,没有后续字符可重复,返回空串。行程编码没有为「输入本身含数字」留出转义,这是设计上的漏洞。

验证二:「修复」得不彻底,它会再次找到

只排除 Unicode 类别 Nd 的字符
实测 · 本机执行输出
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,可重复)
Hypothesis 找到并缩小反例
当前输入

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,hypothesis 6.168.3、pytest 9.1.1(临时虚拟环境);演示部分用 derandomize=True、database=None 保证可重复,验证四没有关闭随机和数据库
    • 没有验证:stateful 测试、st.builds/st.composite 的组合策略、@example 固定用例、profile 设置对 CI 的影响
    京ICP备2026067502号-1京公网安备11010502063487号