第 18 章 · 属性基测试与 Hypothesis
本章目标:理解 property-based testing 与 example-based 的本质差异,掌握 Hypothesis 的策略、设置与反例缩小机制,并学会与 parametrize 组合使用。
18.1 从"举例"到"性质"
前面所有章节的测试都是 example-based:人挑几组输入,写下期望输出。它的盲区很明显——你只能测到你想得到的输入。sorted 用 []、[1]、[2,1] 测着都好,但谁会想到传一个含 float('nan') 的列表?
property-based 换个问法:不问"这组输入输出是什么",而问"对任意合法输入,哪些性质必须永远成立"。比如排序函数的性质:
- 输出有序;
- 输出是输入的一个排列(长度相同、元素多重集相同);
- 幂等:再排一次结果不变。
Hypothesis 会按策略随机生成成百上千组输入去攻击这些性质,一旦找到反例就报告并固定下来。
pip install hypothesis18.2 @given 与 strategy 入门
@given 是唯一的核心入口,接收一个或多个 strategy:
# test_hypo_basic.py
from hypothesis import given, strategies as st
@given(st.integers())
def test_abs_non_negative(n):
assert abs(n) >= 0 # 性质:绝对值非负,对任意整数成立
@given(st.text())
def test_encode_decode_roundtrip(s):
# 性质:编码后再解码应得到原串(roundtrip 性质是最常用的模式)
assert s.encode("utf-8").decode("utf-8") == s
@given(st.lists(st.integers()))
def test_sorted_properties(xs):
result = sorted(xs)
assert len(result) == len(xs) # 长度不变
assert all(result[i] <= result[i + 1] for i in range(len(result) - 1))
assert sorted(result) == result # 幂等常用内置策略速查:
| 策略 | 生成内容 |
|---|---|
st.integers(min_value, max_value) | 整数 |
st.floats(allow_nan=False) | 浮点数 |
st.text(alphabet=..., max_size=...) | Unicode 文本 |
st.lists(elements, min_size, max_size, unique=True) | 列表/去重列表 |
st.dictionaries(keys, values) | 字典 |
st.one_of(a, b) | 多策略任选 |
st.sampled_from([...]) | 从给定集合抽取 |
复杂输入用 @st.composite 组装依赖关系,用 .filter() / assume() 过滤:
from hypothesis import assume, given, strategies as st
@st.composite
def ordered_pair(draw):
"""draw 函数由 Hypothesis 注入,可顺序构造有依赖的值。"""
lo = draw(st.integers())
hi = draw(st.integers(min_value=lo)) # 保证 hi >= lo
return (lo, hi)
@given(st.integers(), st.integers())
def test_div(n1, n2):
assume(n2 != 0) # 丢弃除零用例
q = n1 / n2
assert q * n2 == n1 or True # 示意:浮点误差需另行处理18.3 @settings 与确定性复现
import pytest
from hypothesis import given, settings, strategies as st
@settings(max_examples=50, deadline=None) # CI 里可调低数量提速;deadline 关闭单例耗时断言
@given(st.lists(st.integers(), min_size=1))
def test_min_is_member(xs):
assert min(xs) in xs
# 数据库/网络类慢测试建议显式放宽 deadline:
@settings(deadline=500) # 单个用例超过 500ms 才算失败
@given(st.text(max_size=100))
def test_indexing(s):
assert s.find("") == 0 # find 空串总返回 0关键机制——shrinking(反例缩小):发现失败后,Hypothesis 不是直接甩给你一坨随机大对象,而是自动尝试更小、更简单的输入,直到找到最小反例。失败输出形如:
Falsifying example: test_parse(
s='0',
)同时它会把这个反例写进 .hypothesis/examples 目录下的数据库,下次运行时优先重放历史反例——所以回归是确定性的:修好了才绿。
18.4 与 pytest.mark.parametrize 组合
Hypothesis 官方明确支持与参数化叠加:外层枚举"操作",内层生成数据:
import pytest
from hypothesis import given, strategies as st
@pytest.mark.parametrize("operation", [reversed, sorted])
@given(st.lists(st.integers()))
def test_operation_preserves_length(operation, lst):
assert len(list(operation(lst))) == len(lst)注意规则:@given 必须在最外层(紧贴函数),parametrize 的参数会原样传入。反过来(given 在上、parametrize 在下)是不支持的。
18.5 选型:什么时候值得上 Hypothesis
- ✅ 解析器/序列化器:roundtrip 性质(encode→decode、dump→load)几乎零成本高收益;
- ✅ 数学/金融计算:交换律、结合律、单调性等代数性质;
- ✅ 状态ful 系统:
RuleBasedStateMachine可以随机生成操作序列做模型测试(进阶话题); - ❌ 纯 IO 编排代码(调第三方 API 的胶水层):没有可验证的纯函数性质,mock 后意义不大。
成本提示
默认每个属性测试跑 100 个例子,大型套件会显著变慢。常见做法是本地全量、CI 用 --hypothesis-profile=ci 调低 max_examples,每晚定时任务再跑高探索档位。
本章小结
- property-based 测试验证"任意合法输入下不变量恒成立",专治 example-based 的想象盲区;
@given(strategy)+ 内置 strategies 覆盖绝大多数类型,composite/filter/assume 处理复杂约束;@settings(max_examples, deadline)控制强度与耗时;失败反例自动 shrinking 并持久化复放;@given必须最外层,可与 parametrize 叠加形成"操作 × 数据"矩阵;- 最适合解析、算法、数值类纯逻辑;IO 胶水层收益有限。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. property-based testing 相比 example-based 的核心优势是?
2. 在 Hypothesis 中,assume(cond) 的作用是?
3. 关于 shrinking 反例缩小机制,正确的说法是?
4. @given 与 @pytest.mark.parametrize 同时使用时,正确的装饰器顺序是?
🛠️ 动手实践
- 为 JSON 序列化器写 roundtrip 属性测试:
json.loads(json.dumps(x)) == x,用st.recursive生成嵌套结构。 - 给一个手写的
parse_duration("3h30m")函数找茬:定义"秒数往返一致""非法输入必抛异常"两条性质,让 Hypothesis 攻击它,记录它抓到的第一个 bug。 - 把第 15 章的参数化测试改造成 parametrize + given 组合,并用
@settings(max_examples=20)控制运行时间。
下一章把镜头拉远,聊聊整个 Web 服务的集成测试策略。