问题
I was trying to use a fixture in a decorator which is intended to decorate test functions. The intention is to provide registered test data to the test. There are two options:
- Automatic import
- Manual import
The manual import is trivial. I just need to register the test data globally and can then access it in the test based on its name, the automatic import is trickier, because it should use a pytest fixture.
How shall it look in the end:
@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
What did I do:
The decorator registers the testdata globally in a class variable. It also uses the usefixtures
marker to mark the test with the respective fixture, in case it doesn't use it. This is necessary because otherwise pytest will not create a my_import_fixture
object for the test:
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
This leads to an error in the first test case, as the decorator expects my_import_fixture
is passed, but unfortunately it isn't by pytest, because pytest just looks at the signature of the undecorated function.
At this point it becomes hacky since we have to tell pytest to pass my_import_fixture
as argument, even though the signature of the original test function does not contain it. We overwrite the pytest_collection_modifyitems
hook and manipulate the argnames
of the relevant test functions, by adding the fixture name:
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",)
For completeness a bit code for the 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
Now my question is if there is a better (more pytest native) way to do this. There was a similar question asked before, but it never got answered, I will link my question to it, because it essentially describes a hacky way how to solve it (at least with pytest 6.1.2 and python 3.7.1 behaviour).
Some might argue, that I could remove the fixture and create a MyImporter
object in the decorator. I would then face the same issue with the request
fixture, but could simply avoid this by passing func.__name__
instead of the request
fixture to the constructor.
Unfortunately, this falls apart because of the configuration and cleanup logic I have in my_import_fixture
. Of course I could replicate that, but it becomes super complex, because I use other fixtures which also have some configuration and cleanup logic and so on. Also in the end this would be duplicated code which needs to be kept in sync.
I also don't want my_import_fixture
to be autouse
because it implies some requirements for the test.
来源:https://stackoverflow.com/questions/65734222/how-to-use-pytest-fixtures-in-a-decorator-without-having-it-as-argument-on-the-d