有状态测试
注意
另请参阅原文链接中的“How not to Die Hard with Hypothesis”和“An Introduction to Rule-Based Stateful Testing”。
使用 @given 时,测试流程仍主要由你编写,Hypothesis 负责提供数据。而有状态测试不仅生成数据,还尝试生成整个测试过程:你定义一组可组合的基本操作,Hypothesis 寻找能够导致失败的操作序列。
你未必需要有状态测试
核心思路是让 Hypothesis 同时选择测试中的操作和值,状态机是表达这种行为的一种声明式方式。
对于简单情况,普通的 @given 测试可能已经足够,因为可以在分支或循环中使用 data();状态机探索器内部也是这样工作的。面对更复杂的工作负载时,高层 API 才更能发挥作用。
基于规则的状态机
状态机与普通 @given 测试一样,从策略中抽取值并传给用户定义的测试函数,函数可以通过断言检查系统行为。区别在于,@given 的各次测试必须独立,而状态机中的规则可以串联:一次测试运行可能调用多个相互影响的规则。
规则可以接收普通策略,但除 runner() 和 data() 外,普通策略无法感知状态机的当前状态。此时可以使用 bundle。
规则参数可以用 Bundle 替代普通策略。hypothesis.stateful.Bundle 是一个命名的已生成值集合,可由测试中的其他操作复用。规则的返回值可以填充 bundle,bundle 又可作为其他规则的输入,让数据在规则之间流动,使后续规则处理先前计算或操作的结果。
具体来说,规则设置 target=a_bundle 后,其返回值会加入该 bundle;将参数策略设置为 an_argument=a_bundle,就会从中抽取一个值。如果要抽取后同时移除该值,可写成 an_argument=consumes(a_bundle)。
bundle 与其他策略一样可以进行过滤和映射,并且可按任意顺序与 consumes() 组合:consumes(a_bundle.filter(fn)) 与 consumes(a_bundle).filter(fn) 都会抽取当前符合条件的值,并只从 bundle 中移除该值。
注意
bundle 与实例变量都能表示可由规则修改的状态,两者的能力有重叠。如果不需要按状态机当前状态抽取值,直接用实例变量即可;如果需要,bundle 通常很方便。更复杂的情况可以用 runner() 与 .flatmap() 访问实例,例如 runner().flatmap(lambda self: sampled_from(self.a_list)) 从实例变量 a_list 中抽取。再复杂的逻辑,可以在规则中使用 data() 从实例或其他来源抽取数据。
下面的状态机来自 Hypothesis 示例数据库测试的简化版本。示例数据库将键映射到值的集合;测试把实际实现与一个使用 Python 字典保存相同数据的内存模型进行对照,对两者执行相同操作,查找行为差异。
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,而在 save 和 delete 中直接生成键和值,但 bundle 会鼓励 Hypothesis 在多个操作中复用相同的键和值,为规则建立一个共同的数据集合。
获取状态机提供的 unittest.TestCase,即可将它集成进测试套件:
TestTrees = DatabaseComparison.TestCase
# Or just run with pytest's unittest support
if __name__ == "__main__":
unittest.main()
原文中的测试能够通过。如果注释掉 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 代码;如果自定义 repr 不返回合法 Python,可能例外,但大多数情况下可以复制进测试来复现。
可以通过 TestCase.settings 调整行为。它是普通 Hypothesis 设置对象,使用第一次引用 TestCase 类时的默认值。例如,要减少测试样例数、增加每个操作序列的长度,可以设置:
DatabaseComparison.TestCase.settings = settings(
max_examples=50, stateful_step_count=100
)
相对于原文所用默认设置,这会将每个序列的步骤数加倍,将运行的测试样例数减半。
规则
规则是 RuleBasedStateMachine 最常用的功能,通过在函数上添加 rule() 装饰器定义。状态机至少需要一个规则;同一函数不能用于定义多个规则,以免出现重复操作。由于有状态执行的方式,规则通常不能直接从 fixture 或 pytest.mark.parametrize 等来源接收参数,可改用 sampled_from() 等策略提供。
初始化规则
初始化规则是特殊规则,保证在任何普通规则之前恰好运行一次。如果有多个初始化规则,它们都会执行,但顺序不固定,各次运行可能不同。
初始化规则常用于填充 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}"
它也可以根据策略抽取的值来初始化被测系统。另一种实现方式是在状态机中保存“是否初始化”的实例变量,再使用下文的前置条件,确保依赖初始化的规则执行之前,恰好有一条初始化规则运行。
前置条件
虽然可以在状态机规则中调用 assume(),但容易出现很少甚至没有规则满足假设的情况。为此,Hypothesis 提供 precondition() 装饰器,应用于已经用 rule 装饰的函数,并接收一个根据状态机实例返回布尔值的函数。
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() 后,Hypothesis 可以在运行之前排除不适用的规则,比在执行中使用 assume() 更容易生成有意义的操作序列。
目前前置条件不能访问 bundle;需要检查的数据应保存在实例上。
不变量
你可能希望某些条件在每一步之后都成立。将它们写成普通规则并不能保证检查时机,因为其他规则之间可能执行零次或多次。invariant() 装饰器可以将函数标记为每一步之后都执行。
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(),此时只有前置条件为真才运行。
目前不变量不能访问 bundle;相关数据应保存在实例上。
更细粒度的控制
如果希望绕过 TestCase 机制,可以手动调用。stateful 模块提供 run_state_machine_as_test,它接收一个返回 RuleBasedStateMachine 的函数,以及可选的 settings 参数,行为与类提供的 runTest 相同。











暂无评论内容