- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
导读
本篇指南围绕 Hypothesis 的状态化测试(stateful testing)展开,这是 Hypothesis 在@given之外提供的一种更高级的测试范式:不止生成输入数据,而是让 Hypothesis 自动生成一整段操作序列,去搜索能够触发系统故障的“程序”。你将掌握规则状态机(Rule-Based State Machine)的完整写法——包括rule、initialize、precondition、invariant、Bundle与consumes等核心构件,并学会如何把状态机接入 pytest / unittest、如何定制settings、如何阅读与复现失败输出。文中示例与原理均以当前仓库 hypothesis/src/hypothesis/stateful.py 及 hypothesis/docs/stateful.rst 为准。
一、什么是状态化测试:让 Hypothesis 生成整个测试
使用@given时,测试的主体仍由你编写,Hypothesis 只是提供数据;而使用状态化测试时,Hypothesis 尝试生成的不只是数据,而是整个测试。你只需声明一组可组合的“原子动作”(规则),Hypothesis 会努力寻找这些动作的某种组合序列,使系统进入失败状态。一个典型的应用场景是:你的被测系统是一个随时间变化的状态机(数据库、缓存、状态服务等),单次独立输入无法暴露问题,而一连串互相作用的操作却能触发缺陷。
你可能并不需要状态化测试
状态化测试的核心思想,是让 Hypothesis 同时替你的测试选择动作与取值,而状态机正是实现这一点的声明式方式。但对于较简单的场景,一个标准的@given测试可能已经足够——你完全可以在分支或循环里使用st.data动态取值。事实上,状态机探测器(state machine explorer)在内部也正是这样工作的(在 stateful.py 中,状态机测试被包装在@given(st.data())之上,见下文源码解析)。只有当工作负载足够复杂、更高层的 API 才能发挥威力时,才需要继续使用状态机。
二、规则状态机(Rule-Based State Machines)的核心概念
规则状态机与普通的@given测试非常相似:它从策略中取值,并传给用户自定义的测试函数,函数内部可用断言校验系统行为。关键区别在于:@given测试的各次调用必须相互独立,而规则可以被链式组合——单次测试运行可能包含多次规则调用,这些调用会以各种方式相互作用。
规则可以接受普通的策略参数,但普通策略(除runner与st.data外)无法感知状态机的当前状态。这正是 Bundle 存在的意义。
Bundle:让数据在规则之间流动
:class:hypothesis.stateful.Bundle是一组命名的、可被后续操作复用的生成值集合。它由规则的结果填充,也可以作为规则的参数被抽取,从而让数据从一条规则流向另一条规则,让规则作用于先前计算或动作的结果之上。具体规则为:
- 规则指定
target=a_bundle:该规则的返回值会被加入该 Bundle; - 规则以
an_argument=a_bundle作为策略:会从该 Bundle 中抽取一个值; - 规则以
an_argument=consumes(a_bundle):会从该 Bundle 中抽取一个值并将其移除(consumes由 stateful.py 实现)。
Bundle 与普通策略一样支持.filter()与.map(),并且这种变换与consumes可以以任意顺序组合:consumes(a_bundle.filter(fn))与consumes(a_bundle).filter(fn)等价——两者都是抽取一个当前满足条件的值,且只移除这个实际被抽中的值。在源码层面,Bundle通过拦截.filter/.map(见 stateful.py)将变换记录在_transformations元组中,使过滤与映射在同一次抽取内完成,从而保证被消费的值始终是实际被抽中的那个。
Bundle 与实例变量的取舍:两者都能表示规则可以操作的状态。若你不需要抽取“依赖机器状态的值”,直接用实例变量即可;若需要抽取依赖状态的值,Bundle 是最直白的方案;若需要更复杂的状态相关抽取,可以放弃 Bundle,改用
runner()与.flatmap访问实例——策略runner().flatmap(lambda self: sampled_from(self.a_list))会从实例变量a_list中抽取;若还需要更复杂的行为,可以用st.data基于实例(或其他任意位置)按规则内逻辑抽取数据。
完整实战示例:对比两种数据库实现
下面这个示例(来自 stateful.rst)是 Hypothesis 自带的示例数据库(example database)测试的简化版本。示例数据库把键映射到值集合,测试将真实实现与一个用 Pythondict模拟的“内存模型”做对拍:对两者执行相同的操作序列,寻找行为差异。
import shutil import tempfile from collections import defaultdict import hypothesis.strategies as st from hypothesis.database import DirectoryBasedExampleDatabase from hypothesis.stateful import Bundle, RuleBasedStateMachine, rule class DatabaseComparison(RuleBasedStateMachine): def __init__(self): super().__init__() self.tempd = tempfile.mkdtemp() self.database = DirectoryBasedExampleDatabase(self.tempd) self.model = defaultdict(set) keys = Bundle("keys") values = Bundle("values") @rule(target=keys, k=st.binary()) def add_key(self, k): return k @rule(target=values, v=st.binary()) def add_value(self, v): return v @rule(k=keys, v=values) def save(self, k, v): self.model[k].add(v) self.database.save(k, v) @rule(k=keys, v=values) def delete(self, k, v): self.model[k].discard(v) self.database.delete(k, v) @rule(k=keys) def values_agree(self, k): assert set(self.database.fetch(k)) == self.model[k] def teardown(self): shutil.rmtree(self.tempd) TestDBComparison = DatabaseComparison.TestCase这里声明了两个 Bundle——一个装键、一个装值。两条琐碎的规则只负责填充数据;三条非琐碎规则中,save在键下保存值、delete从键下删除值,两者都同步更新“应该是什么”的内存模型;values_agree则针对某个键校验数据库内容与模型一致。
为什么这里要用 Bundle 而非直接生成?虽然不用 Bundle、直接在
save和delete里生成键值也能简化代码,但使用 Bundle 会鼓励 Hypothesis 在多次操作中复用相同的键和值。Bundle 操作建立了一个供各规则使用的键值“宇宙”,更容易构造出具有连锁效应的失败序列。
集成到测试套件:TestCase
可以通过unittest.TestCase把状态机接入测试套件:
TestTrees = DatabaseComparison.TestCase # 或者直接依靠 pytest 对 unittest 的内建支持运行 if __name__ == "__main__": unittest.main()TestCase是RuleBasedStateMachine上的一个类属性(见 stateful.py),其底层是_to_test_case()动态生成的一个unittest.TestCase子类,runTest内部调用run_state_machine_as_test(cls, settings=self.settings),并带有默认的settings(deadline=None, suppress_health_check=list(HealthCheck))。
失败时输出一个“可复现小程序”
该测试当前可以通过,但假如把self.model[k].discard(v)这行注释掉,在 pytest 下运行会看到如下输出:
AssertionError: assert set() == {b''} ------------ Hypothesis ------------ state = DatabaseComparison() var1 = state.add_key(k=b'') var2 = state.add_value(v=var1) state.save(k=var1, v=var2) state.delete(k=var1, v=var2) state.values_agree(k=var1) state.teardown()注意它打印出的是一段非常短的“程序”,足以复现问题。规则状态机的输出通常已经非常接近合法 Python 代码——如果你为对象自定义了不产生合法 Python 的repr,输出可能无法直接执行,但大多数情况下你可以把这段输出原样复制粘贴进测试即可复现。从源码看,输出由_repr_step负责生成(stateful.py),它会为带target的规则生成var1 = state.add_key(...)形式的变量赋值,并用VarReference引用先前步骤的结果。
用settings精细控制行为
你可以通过TestCase上的settings对象控制行为细节(这是一个普通的 Hypothesis settings 对象,使用TestCase类首次被引用时生效的默认值)。例如希望运行更少的用例、每个用例跑更长的操作序列:
DatabaseComparison.TestCase.settings = settings( max_examples=50, stateful_step_count=100 )这使每个程序的步数翻倍(stateful_step_count100 步),同时将运行的测试用例数量减半(max_examples50 个)。
三、Rules:规则的完整约束
规则是RuleBasedStateMachine中最常用的构件,通过给函数应用rule装饰器定义(stateful.py)。使用时有几条硬性约束:
- 状态机必须至少定义一个规则,否则实例化时抛出
InvalidDefinition(提示"State machine X defines no rules",见 stateful.py); - 一个函数不能用于定义多个规则,这是为了避免多个规则做同一件事——对同一函数重复应用
@rule会抛出InvalidDefinition; - 由于状态机的执行机制,规则一般不能从其他来源(如 fixture 或
pytest.mark.parametrize)取参数——考虑改用sampled_from之类的策略来提供候选值; @rule与@invariant/@initialize互斥,同一函数不能同时被多种装饰器修饰;- 若规则没有指定
target,函数必须返回None,否则触发HealthCheck.return_value健康检查(stateful.py)。
规则可以把返回值同时送入多个 Bundle:传targets=(a, b)而非target=a。若希望把结果恰好送入多个 Bundle 之一,则为每种情况分别定义规则。源码中的_convert_targets(stateful.py)会校验 target 必须为Bundle,并会拦截one_of(a, b)/a | b这类误用。需要向 target 返回多个结果时,使用multiple(result1, result2, ...)(stateful.py),multiple()空调用则用于无结果地结束规则。
四、Initializes:初始化规则
initialize是规则的一种特殊情形,保证在任何普通规则之前恰好运行一次。若定义了多个 initialize 规则,它们都会被调用,但顺序任意,且每次运行顺序都可能变化(stateful.py 中源码明确:@initialize方法在每次运行中恰好被调用一次,除非某个初始化抛出了异常——之后只执行teardown)。initialize规则不允许带有precondition,源码会直接抛出InvalidDefinition。
初始化规则最常见的用途是填充 Bundle:
import hypothesis.strategies as st from hypothesis.stateful import Bundle, RuleBasedStateMachine, initialize, rule name_strategy = st.text(min_size=1).filter(lambda x: "/" not in x) class NumberModifier(RuleBasedStateMachine): folders = Bundle("folders") files = Bundle("files") @initialize(target=folders) def init_folders(self): return "/" @rule(target=folders, parent=folders, name=name_strategy) def create_folder(self, parent, name): return f"{parent}/{name}" @rule(target=files, parent=folders, name=name_strategy) def create_file(self, parent, name): return f"{parent}/{name}"初始化规则还可以用于“依赖策略取值地初始化被测系统”:例如在状态机中放一个“系统是否已初始化”的实例变量,再借助前置条件(见下节)保证:在任何依赖初始化的规则运行前,恰好有一个负责初始化的规则被执行过。
五、Preconditions:前置条件
在规则里使用hypothesis.assume是可行的,但如果只有少数几条规则使用了假设,很容易出现“几乎没有规则能通过假设”的窘境。为此 Hypothesis 提供了precondition装饰器(stateful.py),它作用于rule装饰过的函数,接收一个基于RuleBasedStateMachine实例返回 True/False 的函数:
from hypothesis.stateful import RuleBasedStateMachine, precondition, rule class NumberModifier(RuleBasedStateMachine): num = 0 @rule() def add_one(self): self.num += 1 @precondition(lambda self: self.num != 0) @rule() def divide_with_one(self): self.num = 1 / self.num使用precondition而非assume,Hypothesis 可以在运行规则之前就过滤掉不适用的规则,从而大大提高生成有用步骤序列的概率。规则选择阶段会先做可用性判定(RuleStrategy.is_valid,见 stateful.py):前置条件全部为 True 且依赖的 Bundle 非空,规则才算可选。若某一步没有任何规则满足前置条件,会抛出InvalidDefinition,提示"No progress can be made from state ..., because no available rule had a True precondition"。
需要注意两点:
- 前置条件目前不能访问 Bundle;需要用到前置条件时,请把相关数据存放在实例上;
- 多条
precondition可以叠加到同一条规则上,只有当全部返回 True 时规则才被视为有效;precondition同样可以作用于invariant。单独使用@precondition(未配合@rule或@invariant)是非法的,源码会直接报错(stateful.py)。
六、Invariants:不变量
很多时候我们想保证某个不变量在过程的每一步之后都成立。把它写成规则虽然可行,但它会在其他规则之间被运行零次或多次。Hypothesis 提供的invariant装饰器(stateful.py)把函数标记为每一步之后都要运行:
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule class NumberModifier(RuleBasedStateMachine): num = 0 @rule() def add_two(self): self.num += 2 if self.num > 50: self.num += 1 @invariant() def is_even(self): assert self.num % 2 == 0 NumberTest = NumberModifier.TestCase细节补充:
- 不变量也可以应用
precondition,此时仅当前置条件返回 True 时才运行; - 不变量目前不能访问 Bundle,需要时同样应把相关数据存到实例上;
- 默认情况下不变量只会在所有
initialize规则执行完毕之后才开始逐步骤检查;若希望初始化期间也检查,可传入@invariant(check_during_init=True); - 不变量函数必须返回
None,返回其他值会触发HealthCheck.return_value健康检查(stateful.py); - 不变量不能与
@rule/@initialize混用在同一个函数上。
七、更细粒度的控制:run_state_machine_as_test
如果不想走TestCase这套基础设施,可以手动调用状态机测试。stateful模块暴露了run_state_machine_as_test(state_machine_factory, *, settings=None, _min_steps=0)(stateful.py):
state_machine_factory:任意“无参调用即返回一个RuleBasedStateMachine实例”的对象——可以是类,也可以是函数;settings:控制测试执行的 settings 对象,可选。
它等价于基于类的runTest所做的事情:静默运行,或在找到最小失败程序时打印出来并抛出异常。此外,若在选择规则阶段抛出了FlakyStrategyDefinition,Hypothesis 会在异常上附加说明——“通常是飘忽不定的前置条件(flaky precondition)或意外为空的 Bundle 导致的”。
八、源码级解析:状态机在内部是如何运行的
要深入理解以上行为的原理,关键看 hypothesis/src/hypothesis/stateful.py 的三处实现。
1. 状态机测试本质上是@given(st.data())驱动的
get_state_machine_test(stateful.py)把状态机测试包装成:
@settings @given(st.data()) def run_state_machine(data): machine = state_machine_factory() ... max_steps = settings.stateful_step_count while True: # 选择规则 -> 生成参数 -> 执行规则 -> 检查不变量 ...这正是文档开头“状态机探测器内部就是靠st.data实现的”这句话的代码形态。循环内部,先优先运行尚未执行过的 initialize 规则(machine._initialize_rules_to_run),再通过RuleStrategy随机选择普通规则;每轮执行后调用machine.check_invariants(...)检查不变量,最终统一输出state.teardown()并调用machine.teardown()(stateful.py)。teardown默认什么都不做,可在子类中覆写以清理资源。
步数控制也在这里:max_steps = settings.stateful_step_count;为了便于收缩,正常运行时每一步都有2 ** -16的概率提前终止,达到上限后强制停止。
2. 规则选择:RuleStrategy与特性标志
RuleStrategy(stateful.py)负责在每一步选出要执行的规则。它先用FeatureStrategy随机“启用”一批规则,再在启用且可用的规则中用sampled_from抽取。规则的可用性判定is_valid同时检查两件事:规则用到的每个 Bundle 非空(Bundle.do_draw从空 Bundle 抽取会调用data.mark_invalid,提示"Cannot draw from empty bundle",见 stateful.py)以及全部前置条件为 True。注意过滤顺序是有意设计的:先判断可用性、再判断是否启用,以避免生成过大的 choice sequence。
3.stateful_step_count与max_examples的乘法关系
在 hypothesis/src/hypothesis/_settings.py 中,stateful_step_count被定义为“在放弃寻找缺陷之前,最多调用多少次额外的@rule方法”,默认值为50;文档明确说明它与max_examples是乘法关系——每个测试用例最多运行stateful_step_count步,因此总执行量约为二者之积(max_examples默认值为100)。这就是“把步数翻倍、用例数减半”的配置为何把执行总量基本维持在同一量级。
4. 仓库中的验证测试
仓库的测试套件覆盖了状态机的各种边界情况,可以作为继续深入阅读的入口:
- hypothesis/tests/cover/test_database_backend.py:文档示例的现实版本,直接以
run_state_machine_as_test(TestDatabaseListener)手动运行状态机(L696); - hypothesis/tests/cover/test_health_checks.py:验证无 target 的规则、
@initialize、@invariant返回非None值时触发的健康检查; - hypothesis/tests/cover/test_error_in_draw.py:验证参数生成阶段抛错的处理;
- hypothesis/tests/cover/test_flakiness.py:覆盖与状态相关的飘忽不定(flaky)场景。
九、小结
状态化测试把 Hypothesis 的能力从“生成输入”提升到“生成程序”:通过RuleBasedStateMachine声明一组规则与 Bundle,Hypothesis 会自动组合出具有连锁效应的操作序列、搜索最小失败序列并输出可直接复现的 Python 代码。实践中记住几条关键规则:Bundle 负责让数据在规则间流动、initialize负责一次性前置填充、precondition负责过滤不适用的步骤、invariant负责每一步之后的不变量检查;用TestCase.settings(而非类属性赋值)控制stateful_step_count与max_examples的步数/用例数配比;需要脱离TestCase时直接用run_state_machine_as_test。更深的运行机制与边界行为,可继续阅读 hypothesis/src/hypothesis/stateful.py、hypothesis/src/hypothesis/_settings.py 以及上述测试文件。
- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
相关推荐
Terraformer自动连接资源教程:terraform_remote_state引用原理与--connect详解
Terraformer自动连接资源教程:terraform_remote_state引用原理与 connect详解 Terraformer 是一款反向 Terr
开发工具CLIIaCDevOpsopencode-anthropic-auth的User-Agent伪装深度剖析:为何必须impersonate claude-cli
opencode anthropic auth的User Agent伪装深度剖析:为何必须impersonate claude cli opencode ant
Hypothesis高级特性:状态机和规则测试
Hypothesis高级特性:状态机和规则测试 Hypothesis的RuleBasedStateMachine是状态机测试的核心组件,提供了一种结构化方式来定
测试开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考