- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
属性测试(Property Based Testing)是当前测试领域最值得掌握的方法论之一,而 Python 生态中它的代表实现就是 Hypothesis。本文以 Hypothesis 作者对“什么是属性测试”的权威阐述为核心骨架,系统梳理属性测试的边界(哪些特性并非必要、fuzzing 与属性测试的分野)、其“构造测试 + 让测试被 fuzz”的本质定义,以及这一思想在 Hypothesis 仓库 中如何落地为真实的引擎实现——从结构化模糊测试核心 Conjecture,到given装饰器、strategies(策略)与 shrinker(最小化器)的完整调用链。读完本文,你将能用精确的语言界定属性测试,理解它为什么不是“随机测试”的简单包装,并能把 Hypothesis 的用法与其底层机制对应起来。
从“QuickCheck 是什么”到“属性测试是什么”
在很长一段时间里,属性测试的工作定义就是“QuickCheck 所做的事情”。QuickCheck 诞生于 Haskell 生态(quickcheck-in-every-language 一文 详细梳理了它在十余种语言中的移植情况),这个定义用起来很方便,但它有一个致命缺陷:它无法区分属性测试的本质特征(essential features)与只是“在我们熟悉的实现中偶然出现的特征”(accidental features)。
对 QuickCheck 的继承者们来说,这个区别尤其重要——它们往往与 QuickCheck 在实现上差异巨大。Hypothesis 的作者明确指出:属性测试并非由某个特定库定义,而是由一类行为模式定义;边界既可以划得很窄(只收录长得像 QuickCheck 的东西),也可以划得很宽(收录同一行为家族的所有东西)。本文采取宽边界,但先钉下一根“旗帜”,明确哪些东西不是属性测试的必要特征:
- 引用透明性(Referential Transparency):测试的代码不必是纯函数。
- 类型(Types):强类型语言并非前提。
- 随机化(Randomization):生成方式不一定要是随机的。
- 使用任何特定工具或库:手写测试协议同样可以构成属性测试。
这四点分别有对应的反证:几乎每个属性测试库(Hypothesis、Erlang 与 Haskell 版 QuickCheck)都允许有副作用的测试;Erlang QuickCheck、test.check、Hypothesis 等大量动态语言实现都相当成功;SmallCheck 按确定性穷举生成,却毫无疑问属于属性测试;至于“不用任何工具”——作者曾只用手写协议测试一个代码格式化器:把一份 Python 文件语料跑过格式化器,再检查输出是否满足 PEP8,这就是一个经典的“带 oracle(预言机)的属性测试”。
尤其值得展开的是第一点。针对“属性测试要求代码引用透明”的流行误解,referential-transparency 一文 给出了更彻底的澄清:这种观念来自早期 Haskell QuickCheck 的设计倾向(更像形式化方法、且其宿主语言本身以纯函数为常态),但就连最新版 Haskell QuickCheck 都完整支持在 IO 中测试属性。属性测试对副作用唯一的要求是:如果测试有全局副作用,它必须能在结束时回滚——而这恰恰与普通单元测试的要求完全相同。“属性测试只是普通测试多跑几次,外加一个数据源来填掉一些空位而已。”这句话是理解整套方法论的心理起点。
边界问题:语料测试与 fuzzing 算不算属性测试
只有正例无法得出好定义,所以作者考察了两个有争议的边界案例。
针对大语料回放:大概率算
如果取 SmallCheck 会生成的前 2 万个输出,每次测试只回放其中前 N 个,你做的其实是完全同类的测试;用 Hypothesis 抽出 2 万个输出后再随机抽样,亦然。因此针对大语料测试“大概算”属性测试。反例是小且固定的语料:如果你能把它直接写成源码里 10 条 example-based 测试,那它实质上就是示例测试。这条边界确实有点模糊。
针对 fuzzing:作者收回了此前的观点
作者曾主张“fuzzing 只是属性测试的一种形式——你在测试‘它不会崩溃’这一属性”,但本文正式反转了这一观点:他倡导的入门方式(见 getting-started-with-hypothesis)大概率不算属性测试。理由是两者的气质不同:属性测试要求你思考程序应当如何表现,而 fuzzing 几乎不需要理解程序行为就能套用到任意代码上;且 fuzzing 感觉上更底层、更基础。但两个方向都可以跨越:
- 你可以用 fuzzing 工具做属性测试(例如给上面的格式化器测试加上 python-afl);
- 你也可以用属性测试工具做 fuzzing——所以“并非所有用 Hypothesis、QuickCheck 写的测试都是属性测试”,作者对此坦然接受,认为这符合测试工具被跨界使用的长久传统。
由此给出作者希望采用的 fuzzing 定义:
Fuzzing 是向一段代码(函数、程序等)喂入来自大语料的数据——可能是动态生成的,也可能依赖于对先前数据的执行结果——以观察它是否会失败。
“数据”和“是否失败”的定义随 fuzzer 而异:有的只生成二进制数据,有的生成更结构化的数据;有的寻找进程崩溃,有的只寻找函数返回 false。作者还特别指出,常见的“fuzzing 专指畸形数据”的界定是有问题的——CSmith 显然是一种 fuzzer,但它刻意只生成结构良好的 C 程序。
属性测试的正式定义与两段式本质
在 fuzzing 定义的基础上,文章给出了核心定义:
属性测试是这样一类测试的构造:当这些测试被 fuzz 时,测试中的失败能够揭示被测系统(system under test)中那些直接 fuzzing 该系统所无法揭示的问题。
(如果你坚持认为 fuzzing 本身就该算属性测试,只需去掉“无法被直接 fuzzing 揭示”这一从句即可——作者本人也在这条界线上摇摆。)这些额外暴露出的失败模式,就是我们在测试的“属性”。
这个定义有一个作者特别珍视的推论:属性测试是“你”做的事,不是计算机做的事——计算机那部分“只是 fuzzing”。由此,一个属性测试库天然地由两部分组成:
- 一个 fuzzer(负责生成输入、寻找失败);
- 一组让“用这个 fuzzer 构造属性测试”变得容易的工具(策略库、装饰器、最小化、数据库等)。
Hypothesis 正是严格按这条思路设计的:它的核心是一个名为Conjecture的结构化 fuzzing 库。
回到仓库:Conjecture 如何实现“结构化 fuzzing”
“Hypothesis 的核心是名为 Conjecture 的结构化 fuzzing 库”这句话,在当今仓库里可以被精确地验证。Conjecture 的源码位于 hypothesis/src/hypothesis/internal/conjecture/(入口在init.py,引擎在 engine.py)。
引擎的核心类是ConjectureRunner(engine.py 的 class ConjectureRunner),它围绕test_function(data: ConjectureData)运行:每次测试把一段字节缓冲解释为一串“选择”(choices),由ConjectureData提供给被测代码。从源码结构可以清晰地看出它的 fuzzer 属性:engine.py顶部定义了BUFFER_SIZE = 8 * 1024(单个测试用例生成阶段可消耗的最大熵预算)、MIN_TEST_CALLS = 10、MAX_SHRINKS = 500、MAX_SHRINKING_SECONDS = 300等常量,勾勒出“生成—测试—收缩”的运行框架。
与普通 fuzzer 不同,Conjecture 的生成是结构化的:策略(strategies)不直接吐字节,而是通过draw_choice等原语按需从数据缓冲区中抽取选择(见 choice.py 中的ChoiceNode、choice_from_index、choice_to_index等机制)。这解释了为什么 Hypothesis 能生成嵌套字典、合法时间、递归结构,而不只是字节流——这正是“结构化 fuzzing”的含义。
收缩(shrinking):让失败样例“最小化”
属性测试工具区别于裸 fuzzer 的关键体验之一,是失败样例的最小化。Conjecture 的收缩器实现在 shrinker.py,其工作方式是:把一次失败的执行重放为一棵选择树(ChoiceTree),然后不断尝试“更简单”的选择序列——更短,或同样长度但对应索引更小(sort_key的定义见 shrinker.py 开头),只要新序列仍然导致同样的失败(interesting_origin相同)就保留。引擎层通过shrink_interesting_test_cases(engine.py 中的方法)把所有已发现的失败样例逐个替换为最小复现。
结合 encode/decode 一文 的实例,可以直观感受到这一点:测试decode(encode(s)) == s时,Hypothesis 给出的最短失败样例是s='110'(两个相同字符后跟一个不同字符)——它“不是”作者偏好顺序下的绝对最小样例(那会是'001'),但已经足够简单、可读,足以快速定位“没有重置计数”的缺陷。最小化不是可有可无的装饰,它直接决定你调试体验的好坏。
为什么失败可能“超出被直接 fuzz 能揭示的问题”
回到定义中的关键从句:属性测试的失败能揭示直接 fuzzing 无法揭示的问题。用仓库里的真实例子说明:
- 纯 fuzzing 发现的问题:RLE 的
encode处理空字符串时抛UnboundLocalError——这是“不崩溃”属性的失败,直接 fuzzing 也能发现(getting-started-with-hypothesis 中作者承认这种入门式测试“大概率不算属性测试”)。 - 属性本身发现的问题:
decode(encode(s)) == s在s='110'上失败——这个失败只有在“编码再解码等于原样”这一属性被显式断言时才可能暴露,直接给encode或decode喂随机数据是抓不到的。这正是“测试被 fuzz 时,失败揭示了直接 fuzzing 无法揭示的问题”的活例子:属性是额外的一层失败模式探测器。
实操层:用 Hypothesis 写出“属性测试”
理解了定义,再看 Hypothesis 面向用户的 API——它对应定义中的第二组成部分“让构造属性测试变容易的工具”。
given装饰器与策略(strategies)
属性测试的入口是@given,参数是策略。策略的公开入口在 hypothesis/src/hypothesis/strategies/init.py:它从_internal子模块导出integers、text、lists、sampled_from、one_of、from_type、recursive、composite等数十种构造器(整数与浮点定义在_internal/numbers.py,字符串在_internal/strings.py,组合器one_of在_internal/strategies.py)。一份最小可用示例:
from hypothesis import given, reject from hypothesis.strategies import integers, text @given(integers(), text()) def test_some_stuff(x, y): try: my_function(x, y) except SomeExpectedException: reject()- Hypothesis 会用这两个策略反复调用测试函数,直到发现一个产生意外异常的组合;
- 当遇到“已知可能”的异常(例如参数越界导致的
ValueError)时,调用reject()丢弃该样例——被丢弃的样例不会计入允许运行的示例预算; - 这种“只测不崩溃”的写法在定义上是 fuzzing 风格,作者明确说它是进入属性测试的良好起点,但还不是完整的属性测试。
从 fuzzing 走向属性测试的两条路
getting-started-with-hypothesis 给出了从“入门 fuzzing”升级到“真正属性测试”的两条路径,恰好呼应本文的定义:
- 断言函数结果的任何性质:返回类型是什么?能否为
None?与输入存在什么可检验的关系?——哪怕是非常琐碎的属性也有价值; - 让代码更防御性:把代码里错误的假设变成崩溃而非静默的状态损坏——例如为函数增加参数检查(Hypothesis 为此设有专门的
InvalidArgument异常),或在代码里大量添加断言(把“局部性质”编码为断言)。
第 2 条是作者认为产出最高的路径:它让代码即使没被测出 bug 也变得更健壮,同时让你一次只关注问题的一半,平滑进入属性测试。
属性测试的经典范式:Encode/Decode 不变式
一旦上手,最容易找到的属性之一就是编码/解码对:存在一个把值编码为另一表示的函数,和另一个应当逆转该过程的函数。这类不变式天然有完全明确的规格——“编码再解码应该等于什么都没做”——非常适合 Hypothesis,因为序列化到表单、API、数据库的场景无处不在。仓库里的完整示例(encode-decode-invariant):
from hypothesis import given from hypothesis.strategies import text @given(text()) def test_decode_inverts_encode(s): assert decode(encode(s)) == s这个看似“平凡”的不变式先通过纯 fuzzing 抓到了空字符串的UnboundLocalError(修复:if not input_string: return []);随后,当实现里被人为删除“字符变化时重置计数”的一行后,测试在s='110'上失败:assert '1100' == '110'。不变式测试的妙处正在于此:它把纯 fuzzing 也顺带包含进来了——即使平凡的不变式也常常能发现有趣的问题。
属性测试库的职责划分(对照表)
综合文档与源码,可以把“属性测试库 = fuzzer + 测试构造工具”这个两分法映射到 Hypothesis 的对应实现:
| 定义中的组成部分 | Hypothesis 中的对应物 | 仓库位置 |
|---|---|---|
| Fuzzer(生成输入、寻找失败) | Conjecture 引擎(ConjectureRunner) | hypothesis/src/hypothesis/internal/conjecture/engine.py |
| 结构化数据生成 | Choice / ConjectureData 抽取机制 | hypothesis/src/hypothesis/internal/conjecture/choice.py |
| 失败样例最小化 | Shrinker(ChoiceTree 遍历) | hypothesis/src/hypothesis/internal/conjecture/shrinker.py |
| 构造属性测试的工具 | given、strategies、composite、from_type等 | hypothesis/src/hypothesis/strategies/init.py |
| 属性测试的书写范式 | encode/decode、往返(roundtrip)、不崩溃等不变式 | website/content/2016-04-16-encode-decode-invariant.md |
结语:一种可以迁移到任何语言的方法论
回到开头的问题——“什么是属性测试”——本文给出的最终答案不是“QuickCheck 做的事”,而是一句可操作的判断标准:当你的测试被 fuzz 时,它所暴露的失败模式是否超出了直接 fuzzing 被测系统所能暴露的范围;若是,你就在做属性测试。而属性测试之所以是“你”做的事情,是因为计算机只是负责 fuzzing 的那一半;另一半——想清楚系统应该有什么行为、把这些行为写成可断言的属性——永远是写测试的人的工作。Hypothesis 的全部价值,就是让这后一半工作变得足够便宜:策略负责构造数据,Conjecture 负责寻找并收缩失败,而你只需要写出那条不变式。这套思想完全不绑定 Python:它同样适用于 quickcheck-in-every-language 中列出的任何语言与工具,Hypothesis 只是把这条思路贯彻得最彻底的那个实现之一。
- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
相关推荐
Hypothesis 对计算机科学研究者的价值:从 QuickCheck 到 Conjecture 引擎的探索
Hypothesis 对计算机科学研究者的价值:从 QuickCheck 到 Conjecture 引擎的探索 Hypothesis 是 Python 生态中广
测试开发工具10分钟给 WeMod 装上本地增强版:Wand-Enhancer 完整上手指南
10分钟给 WeMod 装上本地增强版:Wand Enhancer 完整上手指南 还在为 Wand(WeMod)客户端功能太少、没法拿手机远程操控而头疼?Wan
测试开发工具公式图片怎么快速转成 LaTeX 代码?LaTeX-OCR 4 种用法实战指南
公式图片怎么快速转成 LaTeX 代码?LaTeX OCR 4 种用法实战指南 写论文时,参考文献里 50 个公式要手敲进 LaTeX,敲到第 12 个手已经开
测试开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考