如何在py.test中将多个参数化的夹具合并成新的夹具?

19 投票
3 回答
9859 浏览
提问于 2025-04-18 10:36

如果我有两个带参数的测试准备工具(fixtures),我该如何创建一个单独的测试函数,让它先用一个准备工具的实例运行,然后再用另一个准备工具的实例运行呢?

我觉得可以创建一个新的准备工具,把这两个现有的准备工具结合起来。这样做对“普通”的准备工具很有效,但我似乎无法让它在带参数的准备工具上正常工作。

下面是我尝试过的一个简化示例:

import pytest

@pytest.fixture(params=[1, 2, 3])
def lower(request):
    return "i" * request.param

@pytest.fixture(params=[1, 2])
def upper(request):
    return "I" * request.param

@pytest.fixture(params=['lower', 'upper'])
def all(request):
    return request.getfuncargvalue(request.param)

def test_all(all):
    assert 0, all

当我运行这个时,我遇到了这个错误:

request = <SubRequest 'lower' for <Function 'test_all[lower]'>>

    @pytest.fixture(params=[1, 2, 3])
    def lower(request):
>       return "i" * request.param
E       AttributeError: 'SubRequest' object has no attribute 'param'

... 对于 upper() 也出现了同样的错误。

我哪里做错了?

我该如何修复这个问题?


更新:

有一个 PyTest 插件可以用来解决这个问题: https://github.com/TvoroG/pytest-lazy-fixture

在通过 pip 安装这个插件后,以上代码唯一需要的修改是:

@pytest.fixture(params=[pytest.lazy_fixture('lower'),
                        pytest.lazy_fixture('upper')])
def all(request):
    return request.param

不过,请注意,有一些复杂的情况是它无法处理的:

https://github.com/pytest-dev/pytest/issues/3244#issuecomment-369836702

相关的 PyTest 问题:

3 个回答

1

这段代码看起来不太美观,但也许今天你会找到更好的方法。

在'all'这个测试环境中,请求对象只知道自己的参数:'lower'和'upper'。有一种方法是从一个测试环境中使用另一个测试环境

import pytest

@pytest.fixture(params=[1, 2, 3])
def lower(request):
    return "i" * request.param

@pytest.fixture(params=[1, 2])
def upper(request):
    return "I" * request.param

@pytest.fixture(params=['lower', 'upper'])
def all(request, lower, upper):
    if request.param == 'lower':
        return lower
    else:
        return upper

def test_all(all):
    assert 0, all
3

我之前也遇到过同样的问题(并且得到了一个类似但不同的回答)。我找到的最佳解决办法是重新考虑我如何设置测试参数。与其使用多个输出兼容的固定测试数据,不如把这些固定数据当作普通函数来用,然后把你的元固定数据设置为接受函数名和参数:

import pytest

def lower(n):
    return 'i' * n

def upper(n):
    return 'I' * n

@pytest.fixture(params=[
    (lower, 1),
    (lower, 2),
    (upper, 1),
    (upper, 2),
    (upper, 3),
])
def all(request):
    func, *n = request.param
    return func(*n)

def test_all(all):
    ...

在你的具体情况下,把 n 拆成一个列表并用 * 传递有点多余,但这样做更通用。我的情况是有多个固定数据,它们接受不同的参数列表。

在 pytest 允许我们正确地链接固定数据之前,这就是我想到的在你的情况下运行 5 个测试而不是 12 个测试的唯一方法。你可以用类似下面的方式缩短列表:

@pytest.fixture(params=[
    *[(lower, i) for i in range(1, 3)],
    *[(upper, i) for i in range(1, 4)],
])

这样做实际上是有好处的。你可以选择性地对某些测试做特殊处理,比如标记为 XFAIL,而不会影响到其他一大堆测试,尤其是当你的测试流程中有额外的依赖时。

8

现在有一个解决方案可以在 pytest-cases 中找到,叫做 fixture_union。下面是如何创建你在例子中提到的这个 fixture union:

from pytest_cases import fixture_union, pytest_fixture_plus

@pytest_fixture_plus(params=[1, 2, 3])
def lower(request):
    return "i" * request.param

@pytest_fixture_plus(params=[1, 2])
def upper(request):
    return "I" * request.param

fixture_union('all', ['lower', 'upper'])

def test_all(all):
    print(all)

它的工作效果如你所期待的那样:

<...>::test_all[lower-1] 
<...>::test_all[lower-2] 
<...>::test_all[lower-3] 
<...>::test_all[upper-1] 
<...>::test_all[upper-2] 

注意,我在上面的例子中使用了 pytest_fixture_plus,因为如果你使用 pytest.fixture,你就需要自己处理那些实际上没有被使用的 fixture 的情况。比如说,对于 upper 这个 fixture,可以这样处理:

import pytest
from pytest_cases import NOT_USED

@pytest.fixture(params=[1, 2])
def upper(request):
    # this fixture does not use pytest_fixture_plus 
    # so we have to explicitly discard the 'NOT_USED' cases
    if request.param is not NOT_USED:
        return "I" * request.param

想了解更多细节,可以查看 文档。(顺便说一下,我是这个文档的作者 ;))

撰写回答