AI毛毛的blog

关于pytest框架的fixture参数化实践踩坑记录

需求

目前在写的测试框架里,有这么个需求,要在测试用例模块的同级data目录下,引用到同模块名同测试用例方法名的数据。

例如,测试模块名为test_ns.py,测试用例的方法名为test_create_ns,则它可以自动获取的测试数据位于./data/test_ns.toml文件中,测试用例独占的数据为该toml文件的[[test_create_ns]]块的数据。

文件目录结构如下:

1
2
3
4
5
tests/csk/api/
├── conftest.py
├── data
│   └── test_ns.toml
└── test_ns.py

其中test_ns.toml简化内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[sharedata]
cluster_id = "xxx"

# 同测试方法名,为表数组
[[test_create_ns]]
name = 'api-test-1'
label_name = "api_label_1"
label_val = "api_label_val_1"
[test_create_ns.assert]
success = true

[[test_create_ns]]
name = 'api-test_下划线'
label_name = "api_label_2"
label_val = "api_label_va_2"
# ns名称不支持下划线,所以断言false
[test_create_ns.assert]
success = false

初步尝试

直接写一个方法去加载这些数据很简单,例如定义名为load_data的方法,在测试用例里调用它,即可直接使用数据:

1
2
3
4
5
6
def test_create_ns(self):
data = load_data('xxx')

# 过程处理

assert xxxxx

但是这样有个缺陷,即无法很好的使用Pytest的参数化功能,因为测试用例集的定义是个列表,即反序列化data为可迭代对象。

我们可以去循环迭代此data,然而Pytest会认为这只是一个测试用例;另一方面,默认情况下,若某次循环的断言没有通过,则该用例的测试过程会结束掉,这显然不是我们想要的结果。

引入参数化机制

利用Pytest框架实现最简单的参数化过程是,对测试类或者测试方法,使用@pytest.mark.parametrize()装饰器。

稍微灵活一点的操作是,对fixture做参数化处理,形如@pytest.fixture(params=["xxx", "yyy"])

于是可以写这么个fixture,形式如下:

1
2
3
@pytest.fixture(params=["xxx", "yyy"])
def _case_data(request):
return request.param

params的内容是一个可迭代对象,此数据需要从我们的toml测试数据文件里取到,而解析数据这个动作的前提是,测试用例方法能够自动找到自己同名的段数据。

起初我把它也实现成了一个fixture,形式大概如下:

1
2
3
4
5
6
7
8
9
@pytest.fixture()
def _load_data(request):
# 获取模块名和测试用例方法名
module_name = request.module.__name__
method_name = request.node.name

# 处理数据

return ['xxx', 'yyy']

随后把这个名为_load_data的fixture传递给之前的fixture:

1
2
3
@pytest.fixture(params=_load_data)
def _case_data(request):
return request.param

看似逻辑正确,然而运行却报错了,输出提示表明,fixture不能作为参数传递给另一个fixture。

Collection time and testing time

Pytest的执行大致分为两个阶段,即collection time 和 testing time。

顾名思义,前一个阶段目的主要是参数化准备以及收集测试用例,后者是真正执行测试的阶段。

fixture生成是在collection time内,装饰器是在模块导入的时候执行。

collection time完成后,Pytest进入’test time’,此时,一系列的setup方法、fixture方法会被调用,前一阶段收集的测试用例方法被执行,同样,teardown方法也会在最后时刻被调用。

由此引出的关键问题是,fixture或者test case从来不会在collection time时期执行。

所以上面的错误是,我们想要在collection time内使用fixture把数据收集完成,逻辑合理,但是pytest并不支持这种实现。

关于这个问题,pytest-dev的Github issues上也有过讨论,how to get data from fixture to parametrize with @parametrize ?

如果试图直接将fixture作为方法调用,Pytest会终止运行并提示: Fixtures are not meant to be called directly。

使用pytest_generate_tests方法完成参数化处理

有了上述前提,解决问题的思路大致清晰了起来:

  1. 完整的收集数据不能放在collection time,可以在此之前,例如模块导入的时候,作为通用的方法被调用;也可以在此之后,例如test time;

  2. 数据被参数化调用解析可以在collection time,由于要能够发现模块名/方法名,所以实现这种机制,还需要依赖自省的能力。

第一条解决起来很快,将之前的fixture拆分出来,变成普通的函数,解析出全局的字典,用以存储完整数据。

第二条在查阅文档后,发现pytest_generate_tests方法提供了完备的支持。

pytest_generate_tests是Pytest框架内置的hook方法,提供了自省的能力,而且它可以发现所有的fixture名。它的使用比较直接,直接实现即可。

collection time时间段内,Pytest在每个模块中寻找名为pytest_generate_tests的方法并调用它。这个方法并非fixture,只是普通的Python函数。

它接受名为metafunc的参数,该参数也不是fixture,而是一个特殊的pytest对象,详见pytest 7.4 源码

最终实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@pytest.fixture(scope='function')
def _case_data(request, params=[]):
return request.param


def pytest_generate_tests(metafunc):
if '_case_data' in metafunc.fixturenames:
# 这里先获取所有数据
all_global_data = _all_global_data()
# 区分测试用例所在的测试模块名
module_name = metafunc.module.__name__

global_data = all_global_data[module_name]

# load_data是个加载数据的普通方法,关键点是metafunc的自省能力
case_data = load_data(global_data, metafunc.function.__name__)

# 对_case_data这个fixture做参数化
metafunc.parametrize('_case_data', case_data)

在测试用例里,加上名为_case_data的fixture:

1
2
3
4
5
6
7
def test_create_ns(self, _case_data):
key=_case_data['label_name'],
value=_case_data['label_val']

resp = self._ns.create_ns(xxx)

assert resp.json['success'] is _case_data['assert']['success']

后记

Fixtures 和 parametrization机制使得测试数据与测试方法分离开来,数据分离使得测试代码尽可能的简化,让测试的重用性与健壮性更强了。

但是使用起来还是要理解它们的运行流程和实现机制,否则很容易踩坑。