如何在装饰器中使用 pytest 固定装置而不将其作为装饰函数的参数

Ren*_*ann 5 python pytest

我试图在装饰器中使用一个夹具来装饰测试功能。目的是为测试提供注册的测试数据。有两种选择:

  1. 自动导入
  2. 手动导入

手动导入是微不足道的。我只需要全局注册测试数据,然后可以根据其名称在测试中访问它,自动导入比较棘手,因为它应该使用 pytest 夹具。

最后会怎样:

@RegisterTestData("some identifier")
def test_automatic_import(): # everything works automatic, so no import fixture is needed
    # Verify that the test data was correctly imported in the test system
    pass

@RegisterTestData("some identifier", direct_import=False)
def test_manual_import(my_import_fixture):
    my_import_fixture.import_all()
    # Verify that the test data was correctly imported in the test system
Run Code Online (Sandbox Code Playgroud)

我做了什么:

装饰器在类变量中全局注册测试数据。它还使用usefixtures标记用相应的夹具标记测试,以防它不使用它。这是必要的,否则 pytest 将不会my_import_fixture为测试创建对象:

class RegisterTestData:
    # global testdata registry
    testdata_identifier_map = {} # Dict[str, List[str]]

    def __init__(self, testdata_identifier, direct_import = True):
        self.testdata_identifier = testdata_identifier
        self.direct_import = direct_import
        self._always_pass_my_import_fixture = False

    def __call__(self, func):
        if func.__name__ in RegisterTestData.testdata_identifier_map:
            RegisterTestData.testdata_identifier_map[func.__name__].append(self.testdata_identifier)
        else:
            RegisterTestData.testdata_identifier_map[func.__name__] = [self.testdata_identifier]

        # We need to know if we decorate the original function, or if it was already
        # decorated with another RegisterTestData decorator. This is necessary to 
        # determine if the direct_import fixture needs to be passed down or not
        if getattr(func, "_decorated_with_register_testdata", False):
            self._always_pass_my_import_fixture = True
        setattr(func, "_decorated_with_register_testdata", True)

        @functools.wraps(func)
        @pytest.mark.usefixtures("my_import_fixture") # register the fixture to the test in case it doesn't have it as argument
        def wrapper(*args: Any, my_import_fixture, **kwargs: Any):
            # Because of the signature of the wrapper, my_import_fixture is not part
            # of the kwargs which is passed to the decorated function. In case the
            # decorated function has my_import_fixture in the signature we need to pack
            # it back into the **kwargs. This is always and especially true for the
            # wrapper itself even if the decorated function does not have
            # my_import_fixture in its signature
            if self._always_pass_my_import_fixture or any(
                "hana_import" in p.name for p in signature(func).parameters.values()
            ):
                kwargs["hana_import"] = hana_import
            if self.direct_import:
                my_import_fixture.import_all()
            return func(*args, **kwargs)
        return wrapper
Run Code Online (Sandbox Code Playgroud)

这会导致在第一个测试用例中出现错误,正如装饰器所期望的那样my_import_fixture,但不幸的是它不是由 pytest 传递的,因为 pytest 只是查看未装饰函数的签名。

此时它变得很棘手,因为我们必须告诉 pytestmy_import_fixture作为参数传递,即使原始测试函数的签名不包含它。我们通过添加夹具名称来覆盖pytest_collection_modifyitems钩子并操作argnames相关测试函数的 :

def pytest_collection_modifyitems(config: Config, items: List[Item]) -> None:
    for item in items:
        if item.name in RegisterTestData.testdata_identifier_map and "my_import_fixture" not in item._fixtureinfo.argnames:
            # Hack to trick pytest into thinking the my_import_fixture is part of the argument list of the original function
            # Only works because of @pytest.mark.usefixtures("my_import_fixture") in the decorator
            item._fixtureinfo.argnames = item._fixtureinfo.argnames + ("my_import_fixture",)
Run Code Online (Sandbox Code Playgroud)

为了完整性,导入夹具的位代码:

class MyImporter:
    def __init__(self, request):
        self._test_name = request.function.__name__
        self._testdata_identifiers = (
            RegisterTestData.testdata_identifier_map[self._test_name]
            if self._test_name in RegisterTestData.testdata_identifier_map
            else []
        )

    def import_all(self):
        for testdata_identifier in self._testdata_identifiers:
            self.import_data(testdata_identifier)

    def import_data(self, testdata_identifier):
        if testdata_identifier not in self._testdata_identifiers: #if someone wants to manually import single testdata
            raise Exception(f"{import_metadata.identifier} is not registered. Please register it with the @RegisterTestData decorator on {self._test_name}")
        # Do the actual import logic here


@pytest.fixture
def my_import_fixture(request /*some other fixtures*/):
    # Do some configuration with help of the other fixtures
    importer = MyImporter(request)
    try:
        yield importer
    finally:
        # Do some cleanup logic
Run Code Online (Sandbox Code Playgroud)

现在我的问题是是否有更好(更多 pytest 本机)的方法来做到这一点。之前有一个类似的问题 ,但从未得到回答,我会将我的问题链接到它,因为它本质上描述了如何解决它的hacky方法(至少在pytest 6.1.2和python 3.7.1行为下)。

有些人可能会争辩说,我可以移除夹具并MyImporter在装饰器中创建一个对象。然后我将面临与request夹具相同的问题,但可以通过将夹具func.__name__而不是request夹具传递给构造函数来简单地避免这种情况。

不幸的是,由于我在my_import_fixture. 当然我可以复制它,但它变得非常复杂,因为我使用其他灯具,它们也有一些配置和清理逻辑等等。最后,这将是需要保持同步的重复代码。

我也不想my_import_fixture成为,autouse因为这意味着对测试的一些要求。

Pet*_*man 2

我希望这个答案对一年多后的人有帮助。根本问题是当你这样做时

  @functools.wraps(func)
  def wrapper(*args: Any, my_import_fixture, **kwargs: Any):
    . . .
Run Code Online (Sandbox Code Playgroud)

的签名wrapper就是 的签名funcmy_import_fixture不是签名的一部分。一旦我明白这就是问题所在,我就在这里得到了一个关于如何快速修复它的非常有用的答案How can Iwrapp a python function in a way that works withspectr.signature?

要让 pytest 传递my_import_fixture给你的包装器,请执行以下操作:

  @functools.wraps(func)
  def wrapper(*args: Any, my_import_fixture, **kwargs: Any):
    . . .
Run Code Online (Sandbox Code Playgroud)

PEP-362解释了其工作原理。感谢@Andrej Kesely 回答了相关问题。

抱歉 - 我稍微简化了代码,因为我解决的问题与您的问题略有不同(我需要包装器来访问固定装置,request即使包装的测试用例没有使用它)。不过,相同的解决方案应该适合您。