Python 基础体系 · 第 69/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 属性测试:Hypothesis、生成策略、缩减和状态机测试
1. 从“列出样例”到“描述性质”
传统测试通常先写输入,再写期望输出:
def test_sort_numbers():
assert sort_numbers([3, 1, 2]) == [1, 2, 3]
这个测试只验证了一个具体样例。它无法直接说明以下问题:
- 空列表是否正确;
- 重复元素是否保留;
- 负数是否正确排序;
- 极大的整数是否导致溢出;
- 输入列表是否被意外修改;
- 不同元素排列是否得到相同结果。
属性测试(property-based testing)不是手工列出更多样例,而是描述对一类输入都应成立的性质,再由测试框架生成大量输入验证这些性质。
例如,排序函数可以描述为:
- 输出有序;
- 输出与输入包含相同的元素;
- 函数不修改输入;
- 对已经排序的列表再次排序,结果不变。
形式化地,令:
- 为输入序列;
- 为排序函数的输出;
- 表示 Python 的参考排序结果。
最强、最直接的性质是:
如果不想直接使用同一个实现作为测试预言机,也可以拆成多个性质:
其中,ordered 表示输出满足非递减顺序,multiset 表示忽略顺序但保留重复次数的元素集合。
Hypothesis 的入口通常是 @given 加一个或多个生成策略。被 @given 装饰的函数仍然是普通 Python 测试函数,pytest 或 unittest 都可以运行它;Hypothesis 负责为参数生成输入并执行测试体。默认情况下,一个测试通常会尝试最多 100 个生成样例,具体行为可通过 settings(max_examples=...) 调整。(hypothesis.readthedocs.io)
2. 安装和最小可运行示例
在 Python 3.14 虚拟环境中安装:
python -m pip install hypothesis pytest
Hypothesis 官方文档使用 pip install hypothesis 安装核心包。pytest 集成由 Hypothesis 自带的插件提供;安装 Hypothesis 后,pytest 通常会自动加载该插件。(hypothesis.readthedocs.io)
先实现一个待测函数:
# app.py
def sort_numbers(values: list[int]) -> list[int]:
return sorted(values)
再编写属性测试:
# test_app.py
from hypothesis import given, strategies as st
from app import sort_numbers
@given(st.lists(st.integers()))
def test_sort_numbers_matches_reference(values: list[int]) -> None:
original = values.copy()
result = sort_numbers(values)
assert result == sorted(original)
assert values == original
运行:
pytest -q
测试函数的逻辑是:
st.lists(st.integers())描述“任意整数列表”;@given(...)从这个策略生成多个列表;- 每个列表进入测试体;
original保存输入的初始状态;- 第一个断言验证排序结果;
- 第二个断言验证函数没有修改输入。
这里有一个重要边界:属性测试验证的是策略所描述的输入空间。如果策略只生成整数列表,就没有验证字符串、浮点数或自定义对象。属性测试并不自动扩大测试范围,输入建模仍然是测试作者的责任。
3. 属性、预言机与测试强度
属性测试最容易被误用的地方,是写出一个“看起来抽象、实际上没有约束力”的断言。
例如:
@given(st.lists(st.integers()))
def test_sort_does_not_crash(values):
sort_numbers(values)
这个测试只能检查函数没有抛出异常。它没有验证返回值是否正确,因此即使实现改成:
def sort_numbers(values: list[int]) -> list[int]:
return []
测试仍然可能通过。
3.1 完备预言机
如果存在可信的参考实现,可以直接比较:
@given(st.lists(st.integers()))
def test_sort_matches_python(values):
assert sort_numbers(values) == sorted(values)
但参考实现必须独立于被测实现。下面的写法没有意义:
def sort_numbers(values):
return sorted(values)
@given(st.lists(st.integers()))
def test_sort_matches_same_implementation(values):
assert sort_numbers(values) == sorted(values)
这里的测试与实现使用完全相同的算法路径。若产品代码中的调用方式、边界条件或数据转换存在错误,测试可能无法暴露问题。
3.2 关系属性
当没有完整的参考答案时,可以检查输入、输出之间的关系。
以字符串反转为例:
def reverse_text(value: str) -> str:
return value[::-1]
反转满足:
测试如下:
@given(st.text())
def test_reverse_is_involution(value):
assert reverse_text(reverse_text(value)) == value
这里的“反转两次得到原值”称为不变关系或幂等式性质的一种特殊形式。它不要求测试知道每个字符串反转后的具体结果,但要求实现遵守一个代数关系。
注意,“幂等”通常指:
而反转满足的是:
二者不要混用。
3.3 变换属性
有些操作适合用输入变换描述。假设 normalize() 会去除首尾空白并将连续空白合并为一个空格:
def normalize(value: str) -> str:
return " ".join(value.split())
可以测试:
@given(st.text())
def test_normalize_is_idempotent(value):
normalized = normalize(value)
assert normalize(normalized) == normalized
性质推导如下:
- 第一次调用将输入映射到规范形式;
- 规范形式已经满足规范化规则;
- 再次调用不会产生新的变化;
- 所以
normalize(normalize(x)) == normalize(x)。
这个性质比手工测试几个字符串更能覆盖空字符串、Unicode 空白、多个连续空白等情况,但它仍然不能证明规范形式本身符合业务定义。例如错误实现 return "" 也满足幂等性。因此实际测试往往需要同时检查:
@given(st.text())
def test_normalize_properties(value):
result = normalize(value)
assert normalize(result) == result
assert "\t" not in result
assert "\n" not in result
属性之间应当互相补充,不能只依赖一个容易被错误实现满足的弱性质。
4. 生成策略:把输入空间写成程序
Hypothesis 中的策略(strategy)是一个描述输入空间的对象。策略不只是“随机数生成器”,还携带结构信息,使 Hypothesis 能够生成有效样例并在失败时进行缩减。
常用策略包括:
import hypothesis.strategies as st
st.integers()
st.integers(min_value=0, max_value=100)
st.floats(allow_nan=False, allow_infinity=False)
st.booleans()
st.text()
st.binary()
st.none()
st.lists(st.integers(), min_size=0, max_size=20)
st.tuples(st.integers(), st.text())
st.dictionaries(st.text(min_size=1), st.integers())
st.one_of(st.none(), st.integers(), st.text())
st.sampled_from(["created", "paid", "cancelled"])
这些组合器表达的是结构约束。例如:
user_strategy = st.fixed_dictionaries(
{
"name": st.text(min_size=1, max_size=50),
"age": st.integers(min_value=0, max_value=150),
}
)
该策略生成的值具有固定字段:
{
"name": "...",
"age": 42,
}
lists() 生成列表,tuples() 生成固定长度元组,one_of() 在多个策略之间选择,builds() 根据参数策略构造对象,sampled_from() 从给定集合中选择值。策略组合的关键不是“随机性越强越好”,而是准确表达生产代码能够接受的输入域。(hypothesis.readthedocs.io)
4.1 用 builds() 构造领域对象
from dataclasses import dataclass
from hypothesis import given, strategies as st
@dataclass(frozen=True)
class User:
name: str
age: int
users = st.builds(
User,
name=st.text(min_size=1, max_size=20),
age=st.integers(min_value=0, max_value=150),
)
@given(users)
def test_user_age_is_valid(user: User):
assert 0 <= user.age <= 150
builds(User, ...) 等价于“先生成构造函数参数,再调用 User(...)”。这比先生成一个任意字典、再在测试体里手工转换更清晰,因为策略本身就说明了测试输入的领域结构。
4.2 map():生成后变换
如果原始策略容易描述,可以使用 map():
positive_even_numbers = st.integers(min_value=0).map(lambda n: n * 2)
它保证输出是非负偶数。生成过程可理解为:
因此:
相比在测试体中写:
@given(st.integers())
def test(value):
assume(value >= 0 and value % 2 == 0)
直接构造策略通常更高效,也更容易缩减。
4.3 @composite:有依赖关系的生成
当后一个值依赖前一个值时,使用 @st.composite:
from hypothesis import given, strategies as st
@st.composite
def ordered_pairs(draw):
lower = draw(st.integers())
upper = draw(st.integers(min_value=lower))
return lower, upper
@given(ordered_pairs())
def test_ordered_pair(pair):
lower, upper = pair
assert lower <= upper
生成过程是:
- 先生成
lower; - 以
lower作为下界生成upper; - 返回
(lower, upper); - 测试体无需再通过
assume()丢弃非法组合。
@composite 装饰的函数不是直接返回测试数据,而是返回一个策略;函数中的 draw() 用于从其他策略中取值。官方文档也建议,在可能的情况下优先使用组合策略或 @composite,而不是把大量生成逻辑分散在测试体中。(hypothesis.readthedocs.io)
4.4 递归数据
树、JSON 和嵌套表达式可以用递归策略:
json_values = st.recursive(
st.none() | st.booleans() | st.integers() | st.text(),
lambda children: (
st.lists(children, max_size=5)
| st.dictionaries(st.text(min_size=1), children, max_size=5)
),
max_leaves=20,
)
这里的基础值是:
扩展规则允许把已有的子值组合成列表或字典。max_leaves 限制规模,避免生成无限增长的嵌套结构。
递归分支的顺序会影响缩减效果。把更简单的分支放在前面,通常更容易缩减到空值、短列表或浅层结构;官方策略参考明确指出,one_of() 中应优先放置更简单的策略,递归策略尤其如此。(hypothesis.readthedocs.io)
5. filter()、assume() 与无效输入
5.1 策略过滤
non_empty_text = st.text().filter(bool)
它表达“生成字符串,但只接受非空字符串”。
不过,如果条件很常见,优先直接构造约束:
non_empty_text = st.text(min_size=1)
前者是生成后拒绝,后者从源头限制生成域。过滤比例过高时,Hypothesis 可能触发 HealthCheck,因为测试花费大量时间生成随后被丢弃的输入。.filter() 对单个策略生效,assume() 则会放弃整个测试用例;官方文档建议能用 .filter() 时优先使用它,而复杂的跨参数关系再使用 assume()。(hypothesis.readthedocs.io)
5.2 assume() 的正确边界
from hypothesis import assume, given, strategies as st
@given(st.integers(), st.integers())
def test_remainder_magnitude(a, b):
assume(b != 0)
assert abs(a % b) < abs(b)
这里 b != 0 是输入域约束,不是被测函数的业务断言。如果 b == 0,测试不是发现了产品缺陷,而是构造了不属于该性质定义域的输入。
错误写法是把真正应该测试的失败情况也过滤掉:
@given(st.text())
def test_parser(value):
assume(value != "invalid")
assert parse(value) is not None
如果业务要求无效字符串抛出异常,这种 assume() 会把最重要的输入排除在外。
判断原则是:
- 输入不满足前置条件:可以使用策略约束或
assume(); - 输入满足前置条件但程序行为错误:必须让断言失败;
- 如果
assume()过滤掉了大部分输入,应重新设计策略,而不是直接关闭健康检查。
6. 缩减:从复杂失败到最小反例
缩减(shrinking)是 Hypothesis 区别于普通随机测试的核心机制之一。
假设被测函数存在错误:
def first_and_last(values: list[int]) -> tuple[int, int]:
return values[0], values[-1]
测试要求空列表也能处理:
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_first_and_last_never_crashes(values):
first_and_last(values)
Hypothesis 可能先生成一个很复杂的失败输入:
[918273, -4, 0, 71, 999]
但这个输入实际上不会失败。真正的失败输入可能是:
[]
如果错误发生在长度为 1 的列表,也可能缩减为:
[0]
缩减不是简单地调用 min()。Hypothesis 会尝试改变内部选择序列,例如:
- 删除列表元素;
- 缩小列表长度;
- 缩小整数绝对值;
- 简化字符串;
- 简化嵌套结构;
- 重新执行测试,确认失败仍然存在。
因此最终报告的是一个满足以下条件的反例:
并且在 Hypothesis 定义的搜索顺序下,难以进一步简化。这里的“最小”不是数学上对所有可能输入的全局最小,而是由策略结构和缩减算法决定的可复现最小失败样例。Hypothesis 文档将其称为 minimal failing test case,并说明用户最终看到的是缩减后的失败用例。(hypothesis.readthedocs.io)
6.1 缩减为什么重要
如果没有缩减,随机测试可能报告:
输入包含 500 个元素,其中第 237 个元素触发异常
工程师还需要判断哪些元素真正重要。
经过缩减后,报告可能变为:
输入为 [0]
这个反例直接揭示了实现遗漏了单元素列表的情况。缩减把“测试发现的复杂事件”转换成“开发者可以理解的最小证据”。
6.2 缩减可能受到实现影响
下面两个策略生成的值域相同,但缩减路径不同:
bad_order = st.one_of(
st.text(),
st.none(),
)
good_order = st.one_of(
st.none(),
st.text(),
)
当失败与分支类型有关时,后者通常更容易先缩减到 None,因为更简单的分支放在前面。递归策略中分支顺序尤其重要。(hypothesis.readthedocs.io)
6.3 @example 与缩减的区别
已知回归样例可以用 @example 固定加入测试:
from hypothesis import example, given, strategies as st
@example(" ")
@given(st.text())
def test_normalize(value):
assert normalize(normalize(value)) == normalize(value)
@example 是显式回归用例,每次都会执行,但它本身不会被 Hypothesis 缩减。它适合保留业务上有意义的历史缺陷,例如某个真实用户输入、协议样例或安全漏洞触发串。(hypothesis.readthedocs.io)
7. 失败复现、数据库与测试配置
Hypothesis 会保存失败用例,使后续测试运行可以优先重放历史失败。测试数据库的作用是缩短“发现缺陷到再次确认”的路径,而不是替代版本库中的回归测试。
对于长期项目,应把关键失败转化为显式回归样例:
@example("历史上导致错误的输入")
@given(st.text())
def test_parser(value):
...
然后保留该 @example,因为数据库可能被清理、替换或在不同环境中配置不同。
7.1 单个测试设置
from hypothesis import given, settings, strategies as st
@settings(max_examples=300, deadline=None)
@given(st.lists(st.integers(), max_size=100))
def test_sort(values):
assert sort_numbers(values) == sorted(values)
max_examples 控制生成样例数量。deadline 控制单个测试样例的软时间限制;对依赖外部服务、磁盘或调试器的测试,统一时限可能产生误报,因此可以局部设置为 None,但这会失去超时检测。Hypothesis 的 settings 还可以控制数据库、阶段、状态机步数、详细程度和健康检查。(hypothesis.readthedocs.io)
7.2 测试阶段
Hypothesis 将运行过程分为多个阶段:
explicit:执行@example;reuse:重放数据库中的失败;generate:生成新输入;target:进行目标导向测试;shrink:缩减失败输入;explain:尝试解释失败原因。
例如,临时只进行生成和缩减:
from hypothesis import Phase, given, settings, strategies as st
@settings(phases=[Phase.generate, Phase.shrink])
@given(st.integers())
def test_value(value):
assert value != 0
这会跳过显式样例和历史数据库。它适合诊断 Hypothesis 行为,不适合一般 CI 配置,因为跳过数据库会失去回归失败重放能力。阶段定义及其依赖关系属于 Hypothesis 的公开设置接口。(hypothesis.readthedocs.io)
7.3 配置 profile
可以在 tests/conftest.py 中根据环境加载配置:
# tests/conftest.py
import os
from hypothesis import settings
settings.register_profile("dev", max_examples=20)
settings.register_profile("ci", max_examples=300)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
运行 CI 配置:
HYPOTHESIS_PROFILE=ci pytest
也可以使用 pytest 插件提供的命令行选项:
pytest --hypothesis-profile=ci
pytest --hypothesis-show-statistics
pytest --hypothesis-seed=12345
Hypothesis 的 pytest 插件提供 profile、统计、seed 和 verbosity 等命令行入口。--hypothesis-show-statistics 可以帮助判断生成是否被大量过滤、测试时间主要消耗在哪里。(hypothesis.readthedocs.io)
7.4 不要盲目关闭健康检查
下面的配置会隐藏所有健康检查:
from hypothesis import HealthCheck, settings
settings.register_profile(
"unsafe",
suppress_health_check=list(HealthCheck),
)
这通常不是解决方案。健康检查可能指出:
- 过滤掉了过多输入;
- 单个样例生成很慢;
- pytest fixture 的生命周期与 Hypothesis 不匹配;
- 不同执行器导致行为不一致。
特别是 function_scoped_fixture 不只是性能提示:pytest 的函数级 fixture 通常按测试函数执行,而不是按 @given 生成的每个样例执行。若 fixture 内部有可变状态,多个样例可能共享它,导致测试互相污染。Hypothesis 官方建议认真处理这类健康检查,而不是全局禁用。(hypothesis.readthedocs.io)
8. 与 pytest fixture、参数化的组合
pytest 参数化适合有限且有名字的场景:
import pytest
@pytest.mark.parametrize(
("input_value", "expected"),
[
("", ""),
(" a ", "a"),
("a\tb", "a b"),
],
)
def test_normalize_examples(input_value, expected):
assert normalize(input_value) == expected
Hypothesis 适合描述更大的输入空间:
from hypothesis import given, strategies as st
@given(st.text())
def test_normalize_is_idempotent(value):
assert normalize(normalize(value)) == normalize(value)
二者并不互斥:
pytest.mark.parametrize表达固定的业务案例;@example表达 Hypothesis 测试中的固定回归案例;@given表达具有结构的输入空间。
pytest 参数化会把参数值原样传入测试,不会自动复制可变对象,因此参数化列表或字典时也要注意测试之间的变异污染。(docs.pytest.org)
更稳妥的结构是把 fixture 负责的资源创建放在测试体内部或使用不会跨样例共享的工厂:
from hypothesis import given, strategies as st
class Cache:
def __init__(self):
self._data = {}
def put(self, key: str, value: int) -> None:
self._data[key] = value
def get(self, key: str) -> int | None:
return self._data.get(key)
@given(
key=st.text(min_size=1),
value=st.integers(),
)
def test_cache_round_trip(key, value):
cache = Cache()
cache.put(key, value)
assert cache.get(key) == value
每个生成样例都创建一个新 Cache,状态边界明确。如果使用外部数据库、临时目录或网络模拟,应确保每个样例的状态可以重置,否则失败可能取决于前一个样例执行过什么。
pytest 的 monkeypatch 会在测试结束后恢复修改,但它解决的是补丁生命周期,不会自动为 Hypothesis 的每个生成样例重建业务对象。补丁时还应修改被测模块实际引用的名称,而不是盲目修改标准库原对象。(docs.pytest.org)
9. 状态机测试:生成操作序列
普通 @given 测试通常要求每个输入样例相互独立:
@given(st.integers())
def test_add(value):
calculator = Calculator()
calculator.add(value)
...
但很多缺陷只有在一系列操作之后才出现:
创建账户
充值
扣款
再次扣款
退款
查询余额
这时需要状态机测试。它不只生成数据,还生成操作及其顺序。Hypothesis 的规则状态机通过 RuleBasedStateMachine 描述状态、规则、初始化动作和不变量。一个状态机实例携带被测系统以及辅助模型,规则可以读取或写入 Bundle,Hypothesis 会选择当前适用的规则执行。(hypothesis.readthedocs.io)
9.1 被测对象与参考模型
实现一个有缺陷的购物车:
# cart.py
class Cart:
def __init__(self):
self._items: dict[str, int] = {}
def add(self, sku: str, quantity: int) -> None:
self._items[sku] = self._items.get(sku, 0) + quantity
def remove(self, sku: str, quantity: int) -> None:
current = self._items.get(sku, 0)
if quantity >= current:
self._items.pop(sku, None)
else:
self._items[sku] = current - quantity
def quantity(self, sku: str) -> int:
return self._items.get(sku, 0)
假设业务规则规定:删除数量必须严格小于当前数量,否则操作应拒绝;但实现却在 quantity >= current 时直接删除。状态机可以将真实对象与简单模型同时操作:
# test_cart_stateful.py
from hypothesis import strategies as st
from hypothesis.stateful import (
RuleBasedStateMachine,
invariant,
precondition,
rule,
)
class CartMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cart = Cart()
self.model: dict[str, int] = {}
@rule(
sku=st.text(min_size=1, max_size=5),
quantity=st.integers(min_value=1, max_value=5),
)
def add(self, sku: str, quantity: int) -> None:
self.cart.add(sku, quantity)
self.model[sku] = self.model.get(sku, 0) + quantity
@precondition(lambda self: bool(self.model))
@rule(
sku=st.sampled_from(["a", "b", "c"]),
quantity=st.integers(min_value=1, max_value=5),
)
def remove(self, sku: str, quantity: int) -> None:
current = self.model.get(sku, 0)
self.cart.remove(sku, quantity)
if current and quantity < current:
self.model[sku] = current - quantity
# quantity >= current 时,模型规定操作应拒绝,
# 但此处暂不检查异常,只通过不变量暴露差异。
@invariant()
def quantities_match(self) -> None:
for sku in ("a", "b", "c"):
assert self.cart.quantity(sku) == self.model.get(sku, 0)
最后将状态机转换成 pytest 可以发现的测试:
TestCart = CartMachine.TestCase
需要补充导入:
from cart import Cart
状态变化可以表示为:
flowchart LR
A[初始状态: 空购物车] -->|add sku, quantity| B[模型与真实对象同时增加]
B -->|remove sku, quantity| C[执行删除]
C --> D{invariant}
D -->|相同| B
D -->|不同| E[报告操作序列]
关键数据流是:
- Hypothesis 生成一条规则调用;
- 规则同时作用于真实对象和参考模型;
- 两者内部状态发生变化;
@invariant在规则后检查关系;- 失败时 Hypothesis 缩减操作数量、操作参数和操作顺序。
不过,上面的 remove 规则有一个建模问题:sku 可能不在模型中,current 为零时,真实对象也不变,通常不会造成差异;真正需要暴露缺陷的是先添加数量为 1 的商品,再删除 1 个商品。更明确的模型可以直接把错误规则写成异常或状态差异:
class CartMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cart = Cart()
self.model: dict[str, int] = {}
@rule(
sku=st.sampled_from(["a", "b", "c"]),
quantity=st.integers(min_value=1, max_value=5),
)
def add(self, sku: str, quantity: int) -> None:
self.cart.add(sku, quantity)
self.model[sku] = self.model.get(sku, 0) + quantity
@precondition(lambda self: any(value > 0 for value in self.model.values()))
@rule(sku=st.sampled_from(["a", "b", "c"]))
def remove_one(self, sku: str) -> None:
current = self.model.get(sku, 0)
if current == 0:
return
self.cart.remove(sku, 1)
if current > 1:
self.model[sku] = current - 1
else:
# 业务规则:数量为 1 时删除 1 个应拒绝,模型保持不变
pass
@invariant()
def quantities_match(self) -> None:
for sku in ("a", "b", "c"):
assert self.cart.quantity(sku) == self.model.get(sku, 0)
TestCart = CartMachine.TestCase
Hypothesis 最终可能缩减出类似这样的最短操作:
add("a", 1)
remove_one("a")
这比普通随机测试报告一个长达几十步的操作序列更容易定位错误。
9.2 @precondition、@rule、@invariant
状态机中的三类核心声明分别是:
@rule:可以被 Hypothesis 选择执行的操作;@precondition:该规则当前是否可执行;@invariant:每条规则执行后都必须成立的性质。
@precondition 不是用来掩盖程序错误的。它描述的是状态机的合法操作域。例如空购物车不能执行“减少已有商品数量”,这是测试模型的前置条件。
如果某个规则在大多数状态下都不可用,状态机可能难以生成有效序列。此时应:
- 调整初始化规则;
- 使用更合适的 Bundle;
- 让规则输入依赖当前状态;
- 减少无效规则;
- 而不是无差别关闭健康检查。
9.3 Bundle:在操作之间传递数据
当一个规则生成的值需要被后续规则复用,可以使用 Bundle:
from hypothesis import strategies as st
from hypothesis.stateful import Bundle, RuleBasedStateMachine, rule
class KeyValueMachine(RuleBasedStateMachine):
keys = Bundle("keys")
def __init__(self):
super().__init__()
self.store = {}
@rule(target=keys, key=st.text(min_size=1, max_size=10))
def create_key(self, key: str) -> str:
self.store.setdefault(key, 0)
return key
@rule(key=keys, value=st.integers())
def write_value(self, key: str, value: int) -> None:
self.store[key] = value
TestKeyValue = KeyValueMachine.TestCase
这里 create_key 的返回值进入 keys,后续 write_value 可以从 keys 中取出之前生成的键。Bundle 表达的是状态机内部的数据流,而不是普通 Python 容器。
规则通常不能直接依赖 pytest fixture 或 pytest.mark.parametrize 来提供参数;需要依赖当前状态的值时,应使用 Bundle、runner()、flatmap() 或 data() 等机制。(hypothesis.readthedocs.io)
10. 状态机的生命周期与隔离
一次状态机测试通常经历:
- 创建状态机实例;
- 执行所有
@initialize初始化规则; - 随机选择适用的
@rule; - 执行规则;
- 执行所有适用的
@invariant; - 重复若干步;
- 如果失败,缩减规则序列和参数;
- 重放最小失败程序确认失败稳定。
如果定义多个初始化规则,它们都会执行一次,但顺序可能变化。因此初始化规则之间不应依赖固定顺序。官方文档明确说明,初始化规则适合为 Bundle 或状态机准备初始数据。(hypothesis.readthedocs.io)
状态机测试的隔离边界必须包括:
- 被测对象;
- 参考模型;
- 数据库事务;
- 临时目录;
- 网络模拟器;
- 全局缓存;
- 时间和随机数来源。
如果状态泄漏到状态机实例之外,Hypothesis 缩减出来的操作序列可能无法单独复现。典型错误是:
GLOBAL_CACHE = {}
class Machine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.cache = GLOBAL_CACHE
多个状态机运行之间共享 GLOBAL_CACHE,会使失败依赖运行顺序。除非全局对象就是被测系统的一部分,否则应在每个状态机实例中创建,或在生命周期结束时显式清理。
11. 普通属性测试还是状态机测试
两者的区别不是“简单测试”和“高级测试”,而是测试对象的结构不同。
适合 @given 的情况
@given(st.text())
def test_encode_decode(value):
assert decode(encode(value)) == value
这里每个输入都可以独立执行,系统状态不需要跨操作保留。
适合状态机的情况
open()
write()
flush()
read()
close()
测试重点是:
- 操作顺序;
- 操作之间的状态影响;
- 非法状态转换;
- 多次调用;
- 资源生命周期;
- 模型与真实系统的长期一致性。
可以用一个判断条件区分:
适合普通属性测试;
其中:
- 是初始状态;
- 是第 个操作;
- 是操作步数。
这种结果依赖操作序列时,状态机测试更自然。
12. 与 unittest 的关系
Hypothesis 不要求必须使用 pytest。测试函数仍然可以由 unittest 运行:
import unittest
from hypothesis import given, strategies as st
class TestProperties(unittest.TestCase):
@given(st.integers())
def test_integer_is_integer(self, value):
self.assertIsInstance(value, int)
但工程上常见的组合是:
- pytest 负责发现、fixture、参数化和报告;
- Hypothesis 负责数据生成、缩减和状态机;
unittest.TestCase适用于已有 unittest 体系或必须继承测试基类的项目。
如果使用 pytest fixture,要特别关注 fixture 是按测试函数执行,还是按每个 Hypothesis 生成样例重建。一个需要在每个输入之间清空的可变资源,不应默认假定 pytest 会自动为每个生成样例调用 fixture。Hypothesis 会通过健康检查提示部分此类交互。(hypothesis.readthedocs.io)
13. 诊断失败:先判断是产品错误还是测试错误
属性测试失败后,不应立即修改策略或提高 max_examples。先按以下因果链检查:
13.1 失败输入是否满足策略定义
如果测试报告:
Falsifying example: value=...
先确认该输入确实是业务允许的输入。如果不是,说明策略过宽,应修正策略或增加明确的前置条件。
13.2 失败是否稳定
Hypothesis 会在缩减后重新执行最小失败样例,以检查失败是否具有波动性。涉及时间、随机数、线程、网络或共享状态的测试,可能出现:
- 第一次失败,第二次通过;
- 本地失败,CI 通过;
- 单独运行失败,完整套件通过。
这通常不是“Hypothesis 随机不可靠”,而是被测系统或测试环境存在未控制的外部状态。Hypothesis 文档说明,最小失败样例会被额外重放以检查 flaky 行为。(hypothesis.readthedocs.io)
13.3 失败来自断言、异常还是健康检查
三者含义不同:
AssertionError:性质不成立;- 业务异常:可能是预期行为,也可能是未处理边界;
HealthCheck:测试生成或执行方式可能低效、不完整或有生命周期风险。
只有第一类通常直接说明性质被违反。第二类要根据契约决定是否使用 pytest.raises。第三类应先诊断生成策略和隔离方式。
14. 生产取舍与测试分层
属性测试不是单元测试的替代物,而是补充输入覆盖和行为关系的工具。
一个较完整的测试分层可以是:
固定样例
├─ 精确业务案例
├─ 历史回归案例
└─ 错误消息与异常类型
属性测试
├─ 输入输出关系
├─ 不变量
├─ 参考实现对照
└─ 边界与复杂结构
状态机测试
├─ 操作顺序
├─ 生命周期
├─ 模型一致性
└─ 非法状态转换
固定样例擅长表达业务语义,例如“订单状态从 paid 不能回到 created”。属性测试擅长发现未预料的输入组合。状态机测试擅长发现“单次调用都正确,但组合调用错误”的缺陷。
测试数量也不等于测试强度。一个只断言“不抛异常”的属性,可能比十个精确的固定样例更弱;一个错误的参考模型,可能让状态机稳定地验证错误结论。真正需要审查的是:
- 输入域是否准确;
- 属性是否排除了错误实现;
- 参考模型是否独立;
- 状态是否隔离;
- 失败是否可以稳定重放;
- 缩减后的反例是否仍然具有业务意义。
当这些条件成立时,Hypothesis 的价值不在于“随机运行更多次”,而在于把输入空间、失败证据和状态变化组织成可推理、可复现、可维护的测试程序。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python unittest 与 Mock:替身边界、Patch、异步和脆弱测试
- 下一篇:Python 静态类型工程:mypy、Pyright、Stub、渐进迁移和 CI
- 延伸:Python pytest 完整基础:Fixture、参数化、标记、插件和隔离
- 延伸:Python 基准测试:预热、噪声、统计、pyperf 和回归判断
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论