- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
导读
当一个问题被少数几条简单属性完全规定时,它的实现可能依然极其繁琐,但它的测试却会变得出乎意料地容易。本文以"左偏二分查找"(left-biased binary search)为完整实例,讲解如何用 Hypothesis 的@given与策略组合,把函数规格逐条翻译成可自动执行的属性测试;同时揭示属性测试与数学证明之间的边界——低概率 bug 的存在及其发现时机,并给出"二次搜索"这类主动制造冲突场景的增强测试技巧。读完本文,你将掌握"以属性充当完整规格"的测试方法论,并理解 Hypothesis 测试数据库(ExampleDatabase)如何让已发现的失败持续复现,直至 bug 被修复。
一、什么是"完全由属性规定的程序"
在现实工程中,多数函数的正确性定义是模糊的:我们知道它"应该做什么",但难以用简洁的条件把它框死。但还有另一类问题——例如二分查找——其结果被几条简单属性完全规定:
- 返回值必须是一个合法的插入位置;
- 把值插入该位置后,列表仍然有序;
- 插入到任何更小的位置,列表都不再有序。
满足这三条的实现,无论内部逻辑多绕,都必然是"正确"的。原文档强调:"这并不代表它们容易实现(很多此类问题实际上极其难写对),但意味着它们容易测试。"
这正是属性测试(property-based testing)的理想土壤:属性测试不关心"你打算怎么做",只关心"结果必须满足什么"。Hypothesis 的核心主张正是围绕这一点展开——它把"生成输入、验证属性"变成了一条流水线。
二、用 Hypothesis 把规格写成测试
原文档给出了一组直接对应上述三条属性的测试。下面的代码做了少量现代化处理(以st.lists(st.integers())的写法给出,功能与原版一致):
from hypothesis import given, strategies as st @given(st.lists(st.integers()).map(sorted), st.integers()) def test_binary_search_gives_valid_index(ls, v): i = binary_search(ls, v) assert 0 <= i <= len(ls) @given(st.lists(st.integers()).map(sorted), st.integers()) def test_inserting_at_binary_search_remains_sorted(ls, v): i = binary_search(ls, v) ls.insert(i, v) assert sorted(ls) == ls @given(st.lists(st.integers()).map(sorted), st.integers()) def test_inserting_at_smaller_index_gives_unsorted(ls, v): for i in range(binary_search(ls, v)): ls2 = list(ls) ls2.insert(i, v) assert sorted(ls2) != ls三个测试逐条对应规格的三条属性:返回合法索引、在该索引插入后仍有序、在更小索引插入则失序。若三者全部通过,binary_search的规格就被完整覆盖了。
2.1 从源码看@given与策略组合
这一组测试的核心设施都有明确的源码依据:
@given是 Hypothesis 测试的主入口。在 core.py 中,given的文档字符串明确写道:"The@givendecorator turns a function into a Hypothesis test. This is the main entry point to Hypothesis." 它既支持位置参数也支持关键字参数,并且"从右往左"填充参数——这意味着放在最左边的位置参数可以留给self(便于在unittest.TestCase子类或实例方法中使用),这一行为在该段源码的注释中有直接说明。st.integers()生成整数。见 numbers.py:integers(min_value=None, max_value=None),当上下界为None时不设边界;其 docstring 指出"例子会向 0 收缩,负数还会向正数收缩"。这正是 Hypothesis 失败用例会自动变小、便于阅读的根本原因。st.lists()生成列表。见 core.py:lists(elements, *, min_size=0, max_size=None, unique_by=None, unique=False),长度落在[min_size, max_size];且"通过尝试移除元素来收缩"。结合.map(sorted),收缩得到的仍然是有序列表——这与 Hypothesis 将收缩集成进生成的设计一脉相承(可对照 integrated-shrinking 一文 中"收缩必须满足与生成相同的约束"的论述)。
因此st.lists(st.integers()).map(sorted)的含义是:先随机生成任意长度、任意整数的列表,再映射为有序列表作为测试输入;一旦断言失败,Hypothesis 会尝试把这个有序列表收缩到最小反例。
三、测试与数学证明的边界:低概率 bug 的存在
"如果这些测试通过,我们的实现一定完全正确,对吧?"原文档给出的回答是:大多数情况下是,但存在一个反复出现的隐患——属性测试无法保证属性在所有输入上都成立。
证明给出的是全称保证:"这些属性总是成立";而测试只能保证"在被检查的有限样本上成立"。Hypothesis 的检查范围远超手写测试,但终究是有限集合。差异带来的直接后果就是低概率 bug:某个错误行为只有在相当特殊的输入上才会被触发,随机生成往往需要多次运行才能撞上。
原文档随后给出了一个恰好踩中该陷阱的实现:
def binary_search(list, value): if not list: return 0 if value > list[-1]: return len(list) if value <= list[0]: return 0 lo = 0 hi = len(list) - 1 while lo + 1 < hi: mid = (lo + hi) // 2 pivot = list[mid] if value < pivot: hi = mid elif value == pivot: return mid else: lo = mid return hi这个实现犯了一个经典的错误:当mid处的元素恰好等于目标值时直接返回mid。这违反了"永远返回最小插入位置"的第三条属性——[0, 1, 1, 1, 1]中插入1时,正确结果应为1,而该实现可能在mid == 2或更晚时提前返回。
3.1 为什么这个 bug 难以被随机撞上
原文档给出的失败用例是:
Failing test case: test_inserting_at_smaller_index_gives_unsorted( ls=[0, 1, 1, 1, 1], v=1 )(有时也会得到ls=[-1, 0, 0, 0, 0], v=0。)触发条件相当苛刻:value必须在ls中至少出现两次,并且二分过程中某个非首个出现位置恰好被选为mid。Hypothesis 的生成器会刻意提高这种用例出现的概率,但提升幅度有限——作者实测通常需要运行 2 到 5 次才会失败一次。
这一案例很好地说明了属性测试方法论的一个核心权衡:规格完备 ≠ 反例易得。三条属性虽然共同构成了完整规格,但第三条属性对"重复元素"这类特定输入特别敏感,而重复元素在完全随机的列表中并不常见。
四、失败之后:Hypothesis 测试数据库的自动接力
原文档接着指出:一旦测试开始失败,Hypothesis 的测试数据库就会接管,让该测试持续失败直到 bug 被修复。
这在源码中有完整的对应实现。见 database.py:ExampleDatabase的类文档写道——
Hypothesis 会自动把失败保存到
settings.database指向的数据库中;下次运行同一测试时,会在reuse阶段重放这些失败(对应Phase.reuse)。数据库最好被理解为"永远不需要失效的缓存"。
具体机制包括:
save(key, value):把失败的例子保存到数据库(database.py);fetch(key):按测试标识读取已保存的例子(database.py);- 运行阶段由
Phase枚举驱动:explicit(显式@example)、reuse(重放数据库中的失败)、generate(生成新例子)等,见 _settings.py。
于是实际开发体验是:低概率 bug 一旦被首次捕获,后续每次运行都会先重放该反例,持续红灯,直到修复。但正如原文档强调的,这并不能消除低概率失败的全部成本——它把"发现问题的时间点"从"引入 bug 之时"推迟到了"某次偶然撞上之时",而推迟越久,定位成本越高。这在状态化测试中尤为突出:由于搜索空间巨大,存在大量低概率 bug,见 rule-based-stateful-testing 一文(对应源码中的RuleBasedStateMachine,见 stateful.py)。
五、修复策略:主动制造冲突场景,而非被动等待
幸运的是,这类问题有一个简洁的修复路径:编写"对示例不敏感"的增强测试——不依赖 Hypothesis 恰好生成重复元素,而是由测试自身主动制造出容易出问题的结构。
原文档给出的方案是"搜索—插入—再搜索":
@given(st.lists(st.integers()).map(sorted), st.integers()) def test_inserting_at_result_point_and_searching_again(ls, v): i = binary_search(ls, v) ls.insert(i, v) assert binary_search(ls, v) == i其正确性论证非常优雅:先搜索、在结果位置插入、再搜索一次,插入点不可能移动——因为在该位置再插一次结果依然有序,而在任何更早位置插入依然失序,所以第二次搜索必然返回同一个位置。
这个测试几乎稳定失败于那个错误实现,因为它不再依赖随机数据里"碰巧存在重复",而是主动创造了重复:把v插回ls,恰好制造出一个紧邻的重复对,而这样的重复对极可能落在二分查找的探查路径上。这正是原文档提出的核心技巧——用输出引导输入的构造,让测试主动把数据推向"最可能暴露错误"的形态。
5.1 与"测试优化器"方法论的呼应
原文档特别提醒读者,这个思路与作者此前在 testing-optimizers-with-hypothesis 一文 中使用的技巧同源:那里不是直接断言"最优解"(因为无对照),而是测试对扰动的正确响应——移除一个已选物品不应改善得分、添加一个已选物品的副本不应降低得分。文章中还展示了用st.data()在测试运行时交互式取数(data.draw(st.sampled_from(original_solution))),让后续输入依赖函数输出,从而构造出最能检验性质的数据。
两条方法论的共同点是:当"完全规格"难以直接验证时,就验证规格在结构化变更下的不变量。对于二分查找,"插入后再次搜索位置不变"就是这样一个对变更的响应断言;对于背包问题,"增删物品后得分不反向变化"也是。这种思路可以推广到更广阔的领域——例如修改用户权限或系统设置后,断言其可用选项集合单调不减。
六、结论:规格测试是起点,而非终点
原文档以三条结论收尾,这里结合全文展开:
- 完全规定的程序是属性测试的天然富矿。规格即测试:每条属性都可以用 Hypothesis 的
@given与策略组合直接翻译成自动化断言,几乎无需手工构造用例。 - 这是测试的起点,而不是终点。属性测试与数学证明之间存在本质差异——前者只能保证有限样本上的正确性。低概率 bug 会真实存在,需要开发者继续思考"还有哪些有趣的方式"去测试软件,例如主动制造重复、利用输出构造扰动输入、做状态化测试等。
- 善用 Hypothesis 的失败接力机制。一旦反例出现,
ExampleDatabase会在reuse阶段自动重放,让 bug 持续暴露直至修复;而为了尽早发现问题,则应主动编写"对示例不敏感"的增强测试,把发现失败的时机从"偶然"拉回"必然"。
延伸阅读
- rule-based-stateful-testing:状态化测试中的低概率 bug 问题;
- testing-optimizers-with-hypothesis:以"对扰动的正确响应"测试优化器的完整实例;
- integrated-shrinking:Hypothesis 将收缩集成进生成的设计理念;
- 核心源码:@given 定义、integers 策略、lists 策略、ExampleDatabase、Phase 枚举。
- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
相关推荐
用 Hypothesis 守护 Encode/Decode 不变量:以 Run Length Encoding 为例的属性测试实战
用 Hypothesis 守护 Encode/Decode 不变量:以 Run Length Encoding 为例的属性测试实战 导读:不变量(invaria
测试开发工具Hypothesis 属性测试入门:从「写例子」到「描述性质」的测试革命
Hypothesis 属性测试入门:从「写例子」到「描述性质」的测试革命 Hypothesis 是 Python 生态中最具代表性的属性测试(property
测试开发工具用 Hypothesis 属性化测试覆盖配置参数:以 Argon2 密码哈希库为例
用 Hypothesis 属性化测试覆盖配置参数:以 Argon2 密码哈希库为例 配置参数测试是软件测试中极易被忽视、却又极易出错的一环。本文基于 Hypot
测试开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考