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

  • 本文关键字:函数 参数 fixture pytest python pytest
  • 更新时间 :
  • 英文 :


我试图在装饰器中使用一个fixture,该装饰器旨在装饰测试函数。目的是为测试提供注册的测试数据。有两个选项:

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

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

最后会是什么样子呢:

@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

我做了什么:

装饰器将testdata全局注册到一个类变量中。它还使用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

这会在第一个测试用例中导致错误,因为decorator期望通过my_import_fixture,但不幸的是它不是pytest,因为pytest只查看未修饰函数的签名。

在这一点上,它变得很粗糙,因为我们必须告诉pytest传递my_import_fixture作为参数,即使原始测试函数的签名不包含它。我们覆盖pytest_collection_modifyitems钩子,并通过添加fixture名称来操作相关测试函数的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",)

为完整起见,输入设备的位码:

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

现在我的问题是,是否有更好(更pytest native)的方法来做到这一点。以前有一个类似的问题,但是从来没有得到回答,我将把我的问题链接到它,因为它本质上描述了一种如何解决它的hack方法(至少使用pytest 6.1.2和python 3.7.1的行为)。

有些人可能会争辩说,我可以删除fixture并在decorator中创建MyImporter对象。然后,对于request fixture,我将面临同样的问题,但可以简单地通过将func.__name__而不是request fixture传递给构造函数来避免这个问题。

不幸的是,由于我在my_import_fixture中的配置和清理逻辑,这失败了。当然,我可以复制它,但它会变得超级复杂,因为我使用了其他fixture,这些fixture也有一些配置和清理逻辑等等。最后,这将是重复的代码,需要保持同步。

我也不希望my_import_fixtureautouse,因为它暗示了测试的一些要求。

我希望这个答案对一年以后的人有帮助。潜在的问题是,当您执行

  @functools.wraps(func)
  def wrapper(*args: Any, my_import_fixture, **kwargs: Any):
    . . .

wrapper的签名就是func的签名。my_import_fixture不是签名的一部分。一旦我明白了这是问题所在,我就得到了一个非常有用的答案,关于如何在这里快速修复它。我如何以与inspect.signature一起工作的方式包装python函数?

要让pytest将my_import_fixture传递给包装器,执行如下操作:

  @functools.wraps(func)
  def wrapper(*args: Any, **kwargs: Any):
    # Pytest will pass `my_import_fixture` in kwargs.
    # If the wrapped func needs `my_import_fixture` then we need to keep it in kwargs.
    # If func doesn't expect a `my_import_fixture` argument then we need to remove it from kwargs.
    if 'my_import_fixture' in inspect.signature(func).parameters.keys():
      my_import_fixture = kwargs['my_import_fixture']
    else:
      my_import_fixture = kwargs.pop('my_import_fixture')
    # Do whatever it is you need to do with `my_import_fixture` here.
    # I'm omitting that specific logic from this answer
    # . . .
    # Now call the wrapped func with the correct arguments
    return func(*args, **kwargs)
  # If the wrapped func already uses the `my_import_fixture` fixture
  # then we don't need to do anything.  `my_import_fixture` will already be
  # part of the wrapper's signature.
  # If wrapped doesn't use `my_import_fixture` we need to add it to the
  # signature of the wrapper in a way that pytest will notice.
  if 'my_import_fixture' not in inspect.signature(func).parameters.keys():
    original_signature = inspect.signature(func)
    wrapper.__signature__ = original_signature.replace(
      parameters=(
        list(original_signature.parameters.values()) +
        [inspect.Parameter('my_import_fixture', inspect.Parameter.POSITIONAL_OR_KEYWORD)]
      )
    )
  return wrapper

PEP-362解释了这是如何工作的。感谢@Andrej Kesely,他回答了相关问题。

抱歉-我已经简化了代码一点,因为我解决的问题与您的问题略有不同(我需要包装器访问request fixture,即使包装的测试用例没有使用它)。同样的解决方案应该适用于您。

最新更新