Add pytest.fixture(indirect=...) argument

closes #10101
This commit is contained in:
Michał 'Khorne' Lowas-Rzechonek 2022-06-20 12:41:41 +02:00
parent 4414c4adae
commit 720c4585b5
7 changed files with 163 additions and 11 deletions

View File

@ -238,6 +238,7 @@ Michael Goerz
Michael Krebs Michael Krebs
Michael Seifert Michael Seifert
Michal Wajszczuk Michal Wajszczuk
Michał Lowas-Rzechonek
Michał Zięba Michał Zięba
Mihai Capotă Mihai Capotă
Mike Hoyle (hoylemd) Mike Hoyle (hoylemd)

View File

@ -0,0 +1,2 @@
Add an optional ``indirect`` argument for ``pytest.fixture`` to make all
parametrizations of that fixture indirect by default.

View File

@ -372,6 +372,24 @@ test:
This can be used, for example, to do more expensive setup at test run time in This can be used, for example, to do more expensive setup at test run time in
the fixture, rather than having to run those setup steps at collection time. the fixture, rather than having to run those setup steps at collection time.
It's also possible to configure this on the fixture level, making all tests
using that fixture indirectly parametrized by default:
.. code-block:: python
import pytest
@pytest.fixture(indirect=True)
def fixt(request):
return request.param * 3
@pytest.mark.parametrize("fixt", ["a", "b"])
def test_indirect(fixt):
assert len(fixt) == 3
.. regendoc:wipe .. regendoc:wipe
Apply indirect on particular arguments Apply indirect on particular arguments

View File

@ -212,6 +212,7 @@ def add_funcarg_pseudo_fixture_def(
func=get_direct_param_fixture_func, func=get_direct_param_fixture_func,
scope=arg2scope[argname], scope=arg2scope[argname],
params=valuelist, params=valuelist,
indirect=False,
unittest=False, unittest=False,
ids=None, ids=None,
) )
@ -943,6 +944,7 @@ class FixtureDef(Generic[FixtureValue]):
func: "_FixtureFunc[FixtureValue]", func: "_FixtureFunc[FixtureValue]",
scope: Union[Scope, "_ScopeName", Callable[[str, Config], "_ScopeName"], None], scope: Union[Scope, "_ScopeName", Callable[[str, Config], "_ScopeName"], None],
params: Optional[Sequence[object]], params: Optional[Sequence[object]],
indirect: bool = False,
unittest: bool = False, unittest: bool = False,
ids: Optional[ ids: Optional[
Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]] Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]
@ -987,6 +989,8 @@ class FixtureDef(Generic[FixtureValue]):
# assign to the parameter values, or a callable to generate an ID given # assign to the parameter values, or a callable to generate an ID given
# a parameter value. # a parameter value.
self.ids = ids self.ids = ids
# Whether the fixture should be always indirectly parametrized
self.indirect = indirect
# The names requested by the fixtures. # The names requested by the fixtures.
self.argnames = getfuncargnames(func, name=argname, is_method=unittest) self.argnames = getfuncargnames(func, name=argname, is_method=unittest)
# Whether the fixture was collected from a unittest TestCase class. # Whether the fixture was collected from a unittest TestCase class.
@ -1174,6 +1178,7 @@ class FixtureFunctionMarker:
converter=_ensure_immutable_ids, converter=_ensure_immutable_ids,
) )
name: Optional[str] = None name: Optional[str] = None
indirect: bool = False
def __call__(self, function: FixtureFunction) -> FixtureFunction: def __call__(self, function: FixtureFunction) -> FixtureFunction:
if inspect.isclass(function): if inspect.isclass(function):
@ -1241,6 +1246,7 @@ def fixture(
Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]] Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]
] = None, ] = None,
name: Optional[str] = None, name: Optional[str] = None,
indirect: bool = False,
) -> Union[FixtureFunctionMarker, FixtureFunction]: ) -> Union[FixtureFunctionMarker, FixtureFunction]:
"""Decorator to mark a fixture factory function. """Decorator to mark a fixture factory function.
@ -1298,6 +1304,7 @@ def fixture(
autouse=autouse, autouse=autouse,
ids=ids, ids=ids,
name=name, name=name,
indirect=indirect,
) )
# Direct decoration. # Direct decoration.
@ -1417,7 +1424,11 @@ class FixtureManager:
p_argnames, _ = ParameterSet._parse_parametrize_args( p_argnames, _ = ParameterSet._parse_parametrize_args(
*marker.args, **marker.kwargs *marker.args, **marker.kwargs
) )
parametrize_argnames.extend(p_argnames) for argname in p_argnames:
fixturedefs = self.getfixturedefs(argname, node.nodeid) or []
if any(f.indirect for f in fixturedefs):
continue
parametrize_argnames.append(argname)
return parametrize_argnames return parametrize_argnames
@ -1614,10 +1625,14 @@ class FixtureManager:
func=obj, func=obj,
scope=marker.scope, scope=marker.scope,
params=marker.params, params=marker.params,
indirect=marker.indirect,
unittest=unittest, unittest=unittest,
ids=marker.ids, ids=marker.ids,
) )
if marker.indirect:
fixture_def.params = fixture_def.params or []
faclist = self._arg2fixturedefs.setdefault(name, []) faclist = self._arg2fixturedefs.setdefault(name, [])
if fixture_def.has_location: if fixture_def.has_location:
faclist.append(fixture_def) faclist.append(fixture_def)

View File

@ -1194,8 +1194,8 @@ class Metafunc:
#: Underlying Python test function. #: Underlying Python test function.
self.function = definition.obj self.function = definition.obj
#: Set of fixture names required by the test function. #: Set of fixtures required by the test function
self.fixturenames = fixtureinfo.names_closure self.fixtureinfo = fixtureinfo
#: Class object where the test function is defined in or ``None``. #: Class object where the test function is defined in or ``None``.
self.cls = cls self.cls = cls
@ -1205,6 +1205,10 @@ class Metafunc:
# Result of parametrize(). # Result of parametrize().
self._calls: List[CallSpec2] = [] self._calls: List[CallSpec2] = []
@property
def fixturenames(self):
return self.fixtureinfo.names_closure
def parametrize( def parametrize(
self, self,
argnames: Union[str, List[str], Tuple[str, ...]], argnames: Union[str, List[str], Tuple[str, ...]],
@ -1300,8 +1304,23 @@ class Metafunc:
else: else:
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
if not isinstance(indirect, (bool, Sequence)):
fail(
"In {func}: expected Sequence or boolean for indirect, got {type}".format(
type=type(indirect).__name__, func=self.function.__name__
),
pytrace=False,
)
self._validate_if_using_arg_names(argnames, indirect) self._validate_if_using_arg_names(argnames, indirect)
if indirect is not True: # False, or a list
indirect = list(indirect or [])
for argname in argnames:
fixturedefs = self.fixtureinfo.name2fixturedefs.get(argname, [])
if any(f.indirect for f in fixturedefs):
indirect.append(argname)
arg_values_types = self._resolve_arg_value_types(argnames, indirect) arg_values_types = self._resolve_arg_value_types(argnames, indirect)
# Use any already (possibly) generated ids with parametrize Marks. # Use any already (possibly) generated ids with parametrize Marks.
@ -1435,13 +1454,7 @@ class Metafunc:
pytrace=False, pytrace=False,
) )
valtypes[arg] = "params" valtypes[arg] = "params"
else:
fail(
"In {func}: expected Sequence or boolean for indirect, got {type}".format(
type=type(indirect).__name__, func=self.function.__name__
),
pytrace=False,
)
return valtypes return valtypes
def _validate_if_using_arg_names( def _validate_if_using_arg_names(

View File

@ -4468,3 +4468,105 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None:
result.assert_outcomes(errors=1) result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines([expected]) result.stdout.fnmatch_lines([expected])
assert result.ret == ExitCode.TESTS_FAILED assert result.ret == ExitCode.TESTS_FAILED
def test_fixture_indirect(pytester: Pytester) -> None:
pytester.makeconftest(
"""
import pytest
@pytest.fixture(indirect=True)
def indirect_sum(request):
return sum(request.param)
@pytest.fixture
def indirect_reversed(request):
return list(reversed(request.param))
"""
)
pytester.makepyfile(
"""
import pytest
@pytest.mark.parametrize("indirect_sum, indirect_reversed, direct_list",
[
([1,2,3], [1,2,3], [1,2,3])
],
indirect=["indirect_reversed"]
)
def test_indirect_sum(indirect_sum, indirect_reversed, direct_list):
assert indirect_sum == 6
assert indirect_reversed == [3, 2, 1]
assert direct_list == [1, 2, 3]
"""
)
result = pytester.runpytest()
result.assert_outcomes(passed=1)
def test_fixture_indirect_always(pytester: Pytester) -> None:
pytester.makeconftest(
"""
import pytest
@pytest.fixture(indirect=True)
def indirect_sum(request):
return sum(request.param)
"""
)
pytester.makepyfile(
"""
import pytest
@pytest.mark.parametrize("indirect_sum",
[
([1,2,3])
],
indirect=[]
)
def test_indirect_sum(indirect_sum):
assert indirect_sum == 6
"""
)
result = pytester.runpytest()
result.assert_outcomes(passed=1)
def test_fixture_indirect_no_params(pytester: Pytester) -> None:
pytester.makeconftest(
"""
import pytest
@pytest.fixture(indirect=True)
def indirect_skip(request):
pass
"""
)
pytester.makepyfile(
"""
import pytest
def test_indirect_skip(indirect_skip):
pass
"""
)
result = pytester.runpytest()
result.assert_outcomes(skipped=1)
def test_fixture_indirect_default_params(pytester: Pytester) -> None:
pytester.makeconftest(
"""
import pytest
@pytest.fixture(indirect=True, params=[1])
def indirect_default(request):
return request.param
"""
)
pytester.makepyfile(
"""
import pytest
def test_indirect_sum(indirect_default):
assert indirect_default == 1
"""
)
result = pytester.runpytest()
result.assert_outcomes(passed=1)

View File

@ -22,6 +22,7 @@ from _pytest import python
from _pytest.compat import _format_args from _pytest.compat import _format_args
from _pytest.compat import getfuncargnames from _pytest.compat import getfuncargnames
from _pytest.compat import NOTSET from _pytest.compat import NOTSET
from _pytest.fixtures import FixtureDef
from _pytest.outcomes import fail from _pytest.outcomes import fail
from _pytest.pytester import Pytester from _pytest.pytester import Pytester
from _pytest.python import IdMaker from _pytest.python import IdMaker
@ -34,7 +35,7 @@ class TestMetafunc:
# on the funcarg level, so we don't need a full blown # on the funcarg level, so we don't need a full blown
# initialization. # initialization.
class FuncFixtureInfoMock: class FuncFixtureInfoMock:
name2fixturedefs = None name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]] = {}
def __init__(self, names): def __init__(self, names):
self.names_closure = names self.names_closure = names