如何在Pytest中向fixture函数传递参数?

253
我正在使用py.test来测试一些封装在Python类MyTester中的DLL代码。 为了验证目的,在测试过程中我需要记录一些测试数据,并在之后进行更多的处理。由于我有很多test_...文件,我希望能够重用测试对象的创建(即MyTester的实例)来进行大部分的测试。
由于测试对象是唯一拥有对DLL变量和函数的引用的对象,因此我需要为每个测试文件将DLL变量的列表传递给测试对象(对于一个test_...文件,要记录的变量是相同的)。 列表的内容用于记录指定的数据。
我的想法是以某种方式来实现这个目标:
import pytest

class MyTester():
    def __init__(self, arg = ["var0", "var1"]):
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

# located in conftest.py (because other test will reuse it)

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester()
    return _tester

# located in test_...py

# @pytest.mark.usefixtures("tester") 
class TestIt():

    # def __init__(self):
    #     self.args_for_tester = ["var1", "var2"]
    #     # how to pass this list to the tester fixture?

    def test_tc1(self, tester):
       tester.dothis()
       assert 0 # for demo purpose

    def test_tc2(self, tester):
       tester.dothat()
       assert 0 # for demo purpose

这样做是否可行,或者是否有更加优雅的方式呢?
通常我可以为每个测试方法使用某种设置函数(类似于xUnit风格)。但我想要实现一些重用性。有人知道是否可以通过固定装置来实现吗?
我知道可以像这样做:(来自文档)
@pytest.fixture(scope="module", params=["merlinux.eu", "mail.python.org"])

但是我需要直接在测试模块中进行参数化。 是否可以从测试模块访问夹具的params属性?
12个回答

302

通过间接参数化,py.test实际上原生支持此功能。

在您的情况下,您将拥有:

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [['var1', 'var2']], indirect=True)
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

5
我尝试使用这个解决方案,但在传递多个参数或使用除request以外的变量名时遇到了问题。最终我采用了@Iguananaut的解决方案。 - Victor Uriarte
95
这应该是被接受的答案。indirect 关键字参数的官方文档确实很简略并且不友好,这可能解释了为什么这种基本技巧如此晦涩难懂。我已经在 py.test 网站上多次搜索这个特性,但一直找不到,只变得更加老旧和困惑。苦涩的地方便是持续集成。感谢 Stackoverflow。 - Cecil Curry
4
注意,此方法会更改测试名称以包含参数,这可能是需要的也可能不是需要的。例如,test_tc1 变成了 test_tc1[tester0] - jjj
2
所以 indirect=True 将参数传递给所有被调用的 fixture,对吗?因为文档明确命名了 indirect 参数化的 fixtures,例如一个名为 x 的 fixture:indirect=['x'] - winklerrr
2
好的,根据有关参数化的官方文档TrueFalse也适用于indirect关键字。 - winklerrr
显示剩余4条评论

193

更新:自从这个回答成为该问题的被接受答案且有时仍会得到赞同,我应该添加一个更新。虽然我的原始回答(如下所述)是在早期版本的pytest中执行此操作的唯一方法,但是,其他人已经指出,pytest现在支持间接参数化固定装置。例如,您可以像这样做(通过@imiric):

# test_parameterized_fixture.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [True, False], indirect=['tester'])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

$ pytest -v test_parameterized_fixture.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture.py::TestIt::test_tc1[True] PASSED                                                                                                                    [ 50%]
test_parameterized_fixture.py::TestIt::test_tc1[False] FAILED

然而,尽管这种间接参数化形式是显式的,正如 @Yukihiko Shinoda 指出的那样,它现在支持一种隐式的间接参数化形式(尽管我在官方文档中没有找到任何明显的参考):points out
# test_parameterized_fixture2.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(tester_arg):
    """Create tester object"""
    return MyTester(tester_arg)


class TestIt:
    @pytest.mark.parametrize('tester_arg', [True, False])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

$ pytest -v test_parameterized_fixture2.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture2.py::TestIt::test_tc1[True] PASSED                                                                                                                   [ 50%]
test_parameterized_fixture2.py::TestIt::test_tc1[False] FAILED

我不确定这个表单的语义是什么,但似乎 pytest.mark.parametrize 认识到 test_tc1 方法虽然未使用名为 tester_arg 的参数,但它所使用的 tester fixture 已经使用了该参数,从而通过 tester fixture 将参数化的参数传递给测试方法。
我遇到了类似的问题——我有一个名为 test_package 的 fixture,我希望在特定的测试中运行它时能够传递一个可选参数。例如:
@pytest.fixture()
def test_package(request, version='1.0'):
    ...
    request.addfinalizer(fin)
    ...
    return package

(这些目的是什么,夹具做什么或返回的package对象类型不重要。)
最好能够以某种方式在测试函数中使用此夹具,以便我还可以指定要与该测试一起使用的version参数。尽管目前无法实现,但这可能是一个不错的功能。
与此同时,我可以很容易地使我的夹具只返回一个执行之前夹具完成的所有工作但允许我指定version参数的函数
@pytest.fixture()
def test_package(request):
    def make_test_package(version='1.0'):
        ...
        request.addfinalizer(fin)
        ...
        return test_package

    return make_test_package

现在我可以在我的测试函数中使用它,例如:

def test_install_package(test_package):
    package = test_package(version='1.1')
    ...
    assert ...

等等。

原帖作者的尝试解决方案是朝着正确的方向前进的,正如@hpk42的答案所建议的那样,MyTester.__init__可以只存储一个对请求的引用:

class MyTester(object):
    def __init__(self, request, arg=["var0", "var1"]):
        self.request = request
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

然后使用此方法来实现fixture:

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester(request)
    return _tester

如果需要的话,MyTester类可以进行一些重构,以便在创建后可以更新其.args属性,以调整单个测试的行为。


感谢您提供有关装置内部功能的提示。虽然花了一些时间才能再次处理此问题,但这非常有用! - maggie
3
这个主题的一个不错的简短文章:https://alysivji.github.io/pytest-fixures-with-function-arguments.html - maggie
1
你是否没有收到错误提示:"夹具不应该直接调用,而是在测试函数请求它们作为参数时自动创建的。"? - nz_21
不要忘记,如果您使用作用域来选择 @pytest.fixture(scope=function)。 - François B.
我不明白。如果你无论如何都要调用一个函数,为什么不直接简单地执行它,而非将其包装在一个固定的环境中?我原以为固定环境的整个意义就是它们会自动注入,无需询问。 - Abhijit Sarkar
这些只是一些琐碎的例子,但重点不仅仅是“调用一个函数”。所讨论的装置可能是更复杂和昂贵的设置。 - Iguananaut

45

4
感谢您指出这一点,这似乎是最清晰的解决方案。我认为在早期版本中这是不可能的,但现在很清楚可以实现。您是否知道这种形式是否在官方文档中提到?我没有找到类似的内容,但显然它可行。谢谢,我已经更新了我的答案,包括这个例子。 - Iguananaut
2
我认为在未来这将不可能,如果您查看https://github.com/pytest-dev/pytest/issues/5712和相关的(已合并)PR。 - Nadège
1
这个被还原了 https://github.com/pytest-dev/pytest/pull/6914 - Maspe36
10
@Maspe36指出,Nadège提供的PR已被撤销,因此这个未记录的功能(我认为它仍然未被记录?)仍然存在。 - blthayer
1
这里有文档记录:https://docs.pytest.org/en/7.2.x/how-to/fixtures.html#override-a-fixture-with-direct-test-parametrization - chilicheech
显示剩余3条评论

20

你还可以使用闭包,这将为您提供更全面的命名和对参数的控制。 它们比在隐式参数化中使用的request参数更加“明确”:

@pytest.fixture
def tester():
    # Create a closure on the Tester object
    def _tester(first_param, second_param):
        # use the above params to mock and instantiate things
        return MyTester(first_param, second_param)
    
    # Pass this closure to the test
    yield _tester 


@pytest.mark.parametrize(['param_one', 'param_two'], [(1,2), (1000,2000)])
def test_tc1(tester, param_one, param_two):
    # run the closure now with the desired params
    my_tester = tester(param_one, param_two)
    # assert code here

我用这个工具构建可配置的测试数据。


你如何对这个方法进行拆解(teardown)? - Jan33
1
Fixture模式是在传统的setup/teardown方法之上的一种抽象。您可以在“yield”准备好的对象之前“设置”所需内容。然后,在yield之后,如果需要,可以将其拆除(关闭资源,删除文件)。如果需要访问实例,则可以从外部范围保存它。 - dinigo

14

为了稍微改进imiric的回答:另一个优雅的解决这个问题的方法是创建“参数夹具(parameter fixtures)”。我个人更喜欢它,而不是pytestindirect功能。 这个功能可以从pytest_cases获得,最初的想法是由Sup3rGeo提出的。

import pytest
from pytest_cases import param_fixture

# create a single parameter fixture
var = param_fixture("var", [['var1', 'var2']], ids=str)

@pytest.fixture
def tester(var):
    """Create tester object"""
    return MyTester(var)

class TestIt:
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

请注意,pytest-cases 还提供了 @fixture,使您可以直接在您的 fixture 上使用参数化标记,而无需使用 @pytest.fixture(params=...)

from pytest_cases import fixture, parametrize

@fixture
@parametrize("var", [['var1', 'var2']], ids=str)
def tester(var):
    """Create tester object"""
    return MyTester(var)

还有@parametrize_with_cases这个工具,它可以让你从“case函数”中获取参数,这些函数可以被分组在一个类或者单独的模块中。详情请参见文档。顺便说一下,我是作者 ;)


1
现在在普通的pytest中似乎可以工作了(我使用的是v5.3.1)。也就是说,我能够在没有param_fixture的情况下让它工作。请参见此答案。虽然我在文档中找不到类似的示例,但您是否知道有关此事的任何信息? - Iguananaut
谢谢提供信息和链接!我不知道这是可行的。让我们等待官方文档,看看他们有什么想法。 - smarie

12

你可以从fixture函数中访问请求的模块/类/函数(因此也可以从测试器类中访问),参见与fixture函数交互。 因此,您可以在类或模块上声明一些参数,测试器fixture可以获取它。


3
我知道我可以像这样做:(来自文档)@pytest.fixture(scope="module", params=["merlinux.eu", "mail.python.org"])但我需要在测试模块中这样做。如何动态地向fixture添加参数? - maggie
5
重点不在于需要与来自fixture函数的“请求测试上下文”进行交互,而在于要有一种明确定义的方式将参数传递给fixture函数。Fixture函数不应该知道请求测试上下文的类型,只需能够接收约定名称的参数即可。例如,人们希望能够编写 @fixture def my_fixture(request),然后 @pass_args(arg1=..., arg2=...) def test(my_fixture) 并在 my_fixture() 中像这样获取这些参数:arg1 = request.arg1, arg2 = request.arg2。 现在py.test是否支持类似这样的操作? - Piotr Dobrogost

10
我做了一个有趣的装饰器,可以像这样编写fixture:
@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"

这里,在 / 左侧是其他的固定内容,右侧则是使用以下方式提供的参数:

@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"

这与函数参数的工作方式相同。如果你没有提供age参数,则会使用默认值69。如果你没有提供name,或省略了dog.arguments装饰器,就会得到常规的TypeError: dog() missing 1 required positional argument: 'name'。如果你有另一个需要参数name的fixture,它不会与此fixture冲突。
还支持异步fixture。
此外,这也为你提供了一个很好的设置计划:
$ pytest test_dogs_and_owners.py --setup-plan

SETUP    F dog['Buddy', age=7]
...
SETUP    F dog['Champion']
SETUP    F owner (fixtures used: dog)['John Travolta']

一个完整的示例:

from plugin import fixture_taking_arguments

@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"


@fixture_taking_arguments
def owner(request, dog, /, name="John Doe"):
    yield f"{name}, owner of {dog}"


@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"


@dog.arguments("Champion")
class TestChampion:
    def test_with_dog(self, dog):
        assert dog == "Champion the dog aged 69"

    def test_with_default_owner(self, owner, dog):
        assert owner == "John Doe, owner of Champion the dog aged 69"
        assert dog == "Champion the dog aged 69"

    @owner.arguments("John Travolta")
    def test_with_named_owner(self, owner):
        assert owner == "John Travolta, owner of Champion the dog aged 69"

装饰器的代码:

import pytest
from dataclasses import dataclass
from functools import wraps
from inspect import signature, Parameter, isgeneratorfunction, iscoroutinefunction, isasyncgenfunction
from itertools import zip_longest, chain


_NOTHING = object()


def _omittable_parentheses_decorator(decorator):
    @wraps(decorator)
    def wrapper(*args, **kwargs):
        if not kwargs and len(args) == 1 and callable(args[0]):
            return decorator()(args[0])
        else:
            return decorator(*args, **kwargs)
    return wrapper


@dataclass
class _ArgsKwargs:
    args: ...
    kwargs: ...

    def __repr__(self):
        return ", ".join(chain(
               (repr(v) for v in self.args), 
               (f"{k}={v!r}" for k, v in self.kwargs.items())))


def _flatten_arguments(sig, args, kwargs):
    assert len(sig.parameters) == len(args) + len(kwargs)
    for name, arg in zip_longest(sig.parameters, args, fillvalue=_NOTHING):
        yield arg if arg is not _NOTHING else kwargs[name]


def _get_actual_args_kwargs(sig, args, kwargs):
    request = kwargs["request"]
    try:
        request_args, request_kwargs = request.param.args, request.param.kwargs
    except AttributeError:
        request_args, request_kwargs = (), {}
    return tuple(_flatten_arguments(sig, args, kwargs)) + request_args, request_kwargs


@_omittable_parentheses_decorator
def fixture_taking_arguments(*pytest_fixture_args, **pytest_fixture_kwargs):
    def decorator(func):
        original_signature = signature(func)

        def new_parameters():
            for param in original_signature.parameters.values():
                if param.kind == Parameter.POSITIONAL_ONLY:
                    yield param.replace(kind=Parameter.POSITIONAL_OR_KEYWORD)

        new_signature = original_signature.replace(parameters=list(new_parameters()))

        if "request" not in new_signature.parameters:
            raise AttributeError("Target function must have positional-only argument `request`")

        is_async_generator = isasyncgenfunction(func)
        is_async = is_async_generator or iscoroutinefunction(func)
        is_generator = isgeneratorfunction(func)

        if is_async:
            @wraps(func)
            async def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_async_generator:
                    async for result in func(*args, **kwargs):
                        yield result
                else:
                    yield await func(*args, **kwargs)
        else:
            @wraps(func)
            def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_generator:
                    yield from func(*args, **kwargs)
                else:
                    yield func(*args, **kwargs)

        wrapper.__signature__ = new_signature
        fixture = pytest.fixture(*pytest_fixture_args, **pytest_fixture_kwargs)(wrapper)
        fixture_name = pytest_fixture_kwargs.get("name", fixture.__name__)

        def parametrizer(*args, **kwargs):
            return pytest.mark.parametrize(fixture_name, [_ArgsKwargs(args, kwargs)], indirect=True)

        fixture.arguments = parametrizer

        return fixture
    return decorator

这看起来真的很像 pytest!你计划将其提交到上游(即 pytest)吗? - George Shuklin
1
@GeorgeShuklin 好的,我已经为此打开了一个问题,并提出了更多疯狂的想法 https://github.com/pytest-dev/pytest/issues/8109 - squirrel

5
另一种方法是使用请求对象来访问在测试函数所定义的模块或类中定义的变量,这样如果您想要为类/模块的所有测试函数传递相同的变量,则不必在每个测试函数上重复使用@pytest.mark.parametrize()装饰器。
以下是具有类变量的示例:
@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.cls.tester_args)


class TestIt:
    tester_args = ['var1', 'var2']

    def test_tc1(self, tester):
       tester.dothis()

    def test_tc2(self, tester):
       tester.dothat()

这样,无论是 test_tc1 还是 test_tc2 中的 tester 对象都将使用 tester_args 参数进行初始化。

您还可以使用:

  • request.function 来访问 test_tc1 函数,
  • request.instance 来访问 TestIt 类实例,
  • request.module 来访问 TestIt 所定义的模块
  • 等等(请参考 request 文档)

4

另一种方法是使用自定义标记。这比在代码中使用参数化更好,不会反映在测试名称中,也是可选的(如果不存在这样的标记,则可以通过引发失败将其定义为非可选)

例如:

@pytest.fixture
def loaded_dll(request):
    dll_file = None
    for mark in request.node.iter_markers("dll_file"):
        if mark.args:
            if dll_file is not None:
                pytest.fail("Only one dll_file can be mentioned in marks")
            dll_file = mark.args[0]
    if dll_file is None:
        pytest.fail("dll_file is a required mark")
    return some_dll_load(dll_file)

@pytest.mark.dll_file("this.dll")
def test_this_dll(loaded_dll):
    ...

当我需要一个模拟ssh客户端的fixture以测试不同可能的输出时,我会使用它来进行测试,并使用标记(mark)为每个测试决定输出。

请注意,如果仅供个人使用,则不需要失败保护机制来使测试失效。


0
例如,使用@pytest.fixture(params=(...)),你可以将AppleOrangeBanana传递给fruits()夹具本身,然后传递给test(),如下所示:
import pytest

@pytest.fixture(params=("Apple", "Orange", "Banana"))
def fruits(request):
    print("fruits", request.param) # 1st
    return request.param

def test(fruits):
    print("test", fruits) # 2nd
    assert True

输出:

$ pytest -q -rP
...                              [100%]
=============== PASSES ================ 
_____________ test[Apple] _____________ 
-------- Captured stdout setup -------- 
fruits Apple
-------- Captured stdout call --------- 
test Apple
____________ test[Orange] _____________ 
-------- Captured stdout setup -------- 
fruits Orange
-------- Captured stdout call --------- 
test Orange
____________ test[Banana] _____________ 
-------- Captured stdout setup -------- 
fruits Banana
-------- Captured stdout call --------- 
test Banana
3 passed in 0.11s

例如,你可以向addition()夹具传递2个参数,该夹具具有如下所示的嵌套函数core()
import pytest

@pytest.fixture
def addition():
    def core(num1, num2):
        return num1 + num2
    return core

def test(request, addition):
    print(addition(2, 3))
    print(request.getfixturevalue("addition")(6, 8))
    assert True

输出:

$ pytest -q -rP
.                                [100%]
=============== PASSES ================ 
________________ test _________________ 
-------- Captured stdout call --------- 
5
14
1 passed in 0.10s

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接