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)不是手工列出更多样例,而是描述对一类输入都应成立的性质,再由测试框架生成大量输入验证这些性质。

例如,排序函数可以描述为:

  1. 输出有序;
  2. 输出与输入包含相同的元素;
  3. 函数不修改输入;
  4. 对已经排序的列表再次排序,结果不变。

形式化地,令:

  • xx 为输入序列;
  • f(x)f(x) 为排序函数的输出;
  • sorted(x)\operatorname{sorted}(x) 表示 Python 的参考排序结果。

最强、最直接的性质是:

f(x)=sorted(x)f(x) = \operatorname{sorted}(x)

如果不想直接使用同一个实现作为测试预言机,也可以拆成多个性质:

ordered(f(x))\operatorname{ordered}(f(x))

multiset(f(x))=multiset(x)\operatorname{multiset}(f(x)) = \operatorname{multiset}(x)

f(f(x))=f(x)f(f(x)) = f(x)

其中,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

测试函数的逻辑是:

  1. st.lists(st.integers()) 描述“任意整数列表”;
  2. @given(...) 从这个策略生成多个列表;
  3. 每个列表进入测试体;
  4. original 保存输入的初始状态;
  5. 第一个断言验证排序结果;
  6. 第二个断言验证函数没有修改输入。

这里有一个重要边界:属性测试验证的是策略所描述的输入空间。如果策略只生成整数列表,就没有验证字符串、浮点数或自定义对象。属性测试并不自动扩大测试范围,输入建模仍然是测试作者的责任。


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]

反转满足:

reverse(reverse(s))=s\operatorname{reverse}(\operatorname{reverse}(s)) = s

测试如下:

@given(st.text())
def test_reverse_is_involution(value):
    assert reverse_text(reverse_text(value)) == value

这里的“反转两次得到原值”称为不变关系幂等式性质的一种特殊形式。它不要求测试知道每个字符串反转后的具体结果,但要求实现遵守一个代数关系。

注意,“幂等”通常指:

f(f(x))=f(x)f(f(x)) = f(x)

而反转满足的是:

f(f(x))=xf(f(x)) = x

二者不要混用。

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

性质推导如下:

  1. 第一次调用将输入映射到规范形式;
  2. 规范形式已经满足规范化规则;
  3. 再次调用不会产生新的变化;
  4. 所以 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)

它保证输出是非负偶数。生成过程可理解为:

n{0,1,2,}n \in \{0, 1, 2, \ldots\}

x=2nx = 2n

因此:

x0xmod2=0x \geq 0 \land x \bmod 2 = 0

相比在测试体中写:

@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

生成过程是:

  1. 先生成 lower
  2. lower 作为下界生成 upper
  3. 返回 (lower, upper)
  4. 测试体无需再通过 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,
)

这里的基础值是:

Noneboolinttext\text{None} \mid \text{bool} \mid \text{int} \mid \text{text}

扩展规则允许把已有的子值组合成列表或字典。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 会尝试改变内部选择序列,例如:

  1. 删除列表元素;
  2. 缩小列表长度;
  3. 缩小整数绝对值;
  4. 简化字符串;
  5. 简化嵌套结构;
  6. 重新执行测试,确认失败仍然存在。

因此最终报告的是一个满足以下条件的反例:

P(x)=FalseP(x) = \text{False}

并且在 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[报告操作序列]

关键数据流是:

  1. Hypothesis 生成一条规则调用;
  2. 规则同时作用于真实对象和参考模型;
  3. 两者内部状态发生变化;
  4. @invariant 在规则后检查关系;
  5. 失败时 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 不是用来掩盖程序错误的。它描述的是状态机的合法操作域。例如空购物车不能执行“减少已有商品数量”,这是测试模型的前置条件。

如果某个规则在大多数状态下都不可用,状态机可能难以生成有效序列。此时应:

  1. 调整初始化规则;
  2. 使用更合适的 Bundle;
  3. 让规则输入依赖当前状态;
  4. 减少无效规则;
  5. 而不是无差别关闭健康检查。

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. 状态机的生命周期与隔离

一次状态机测试通常经历:

  1. 创建状态机实例;
  2. 执行所有 @initialize 初始化规则;
  3. 随机选择适用的 @rule
  4. 执行规则;
  5. 执行所有适用的 @invariant
  6. 重复若干步;
  7. 如果失败,缩减规则序列和参数;
  8. 重放最小失败程序确认失败稳定。

如果定义多个初始化规则,它们都会执行一次,但顺序可能变化。因此初始化规则之间不应依赖固定顺序。官方文档明确说明,初始化规则适合为 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()

测试重点是:

  • 操作顺序;
  • 操作之间的状态影响;
  • 非法状态转换;
  • 多次调用;
  • 资源生命周期;
  • 模型与真实系统的长期一致性。

可以用一个判断条件区分:

测试结果=f(单个输入)\text{测试结果} = f(\text{单个输入})

适合普通属性测试;

测试结果=f(s0,a1,a2,,an)\text{测试结果} = f(s_0, a_1, a_2, \ldots, a_n)

其中:

  • s0s_0 是初始状态;
  • aia_i 是第 ii 个操作;
  • nn 是操作步数。

这种结果依赖操作序列时,状态机测试更自然。


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”。属性测试擅长发现未预料的输入组合。状态机测试擅长发现“单次调用都正确,但组合调用错误”的缺陷。

测试数量也不等于测试强度。一个只断言“不抛异常”的属性,可能比十个精确的固定样例更弱;一个错误的参考模型,可能让状态机稳定地验证错误结论。真正需要审查的是:

  1. 输入域是否准确;
  2. 属性是否排除了错误实现;
  3. 参考模型是否独立;
  4. 状态是否隔离;
  5. 失败是否可以稳定重放;
  6. 缩减后的反例是否仍然具有业务意义。

当这些条件成立时,Hypothesis 的价值不在于“随机运行更多次”,而在于把输入空间、失败证据和状态变化组织成可推理、可复现、可维护的测试程序。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。