diff --git a/AUTHORS b/AUTHORS index 2f8e26b2cb1..f3c8d016c28 100644 --- a/AUTHORS +++ b/AUTHORS @@ -495,6 +495,7 @@ Vlad Radziuk Vladyslav Rachek Volodymyr Kochetkov Volodymyr Piskun +Warren Markham Wei Lin Wil Cooley Will Riley diff --git a/changelog/11295.improvement.rst b/changelog/11295.improvement.rst new file mode 100644 index 00000000000..fc29956f04c --- /dev/null +++ b/changelog/11295.improvement.rst @@ -0,0 +1 @@ +Improved output of ``--fixtures-per-test`` by excluding internal-implementation fixtures generated by ``@pytest.mark.parametrize`` and similar. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f667c40ea78..22d2ecb57bf 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1977,6 +1977,36 @@ def _pretty_fixture_path(invocation_dir: Path, func) -> str: return bestrelpath(invocation_dir, loc) +def _get_fixtures_per_test(test: nodes.Item) -> Iterator[FixtureDef[object]]: + """Returns all fixtures used by the test item except for those created by + direct parametrization and those requested dynamically with + ``request.getfixturevalue``. + + The justification for excluding fixtures created by direct parametrization + is that for users, they are internal implementation detail. + + Dynamically requested fixtures are excluded because they are not known + statically. + """ + from _pytest.python import DirectParamFixtureDef + + # Custom Items may not have _fixtureinfo attribute. + fixture_info: FuncFixtureInfo | None = getattr(test, "_fixtureinfo", None) + if fixture_info is None: + return # pragma: no cover + + # dict key not used in loop but needed for sorting. + for argname, fixturedefs in sorted(fixture_info.name2fixturedefs.items()): + if not fixturedefs: + # Not supposed to be empty, but for safety. + continue # pragma: no cover + # Last item is expected to be the one directly used by the test item. + fixturedef = fixturedefs[-1] + if isinstance(fixturedef, DirectParamFixtureDef): + continue + yield fixturedef + + def _show_fixtures_per_test(config: Config, session: Session) -> None: import _pytest.config @@ -2009,22 +2039,18 @@ def write_fixture(fixture_def: FixtureDef[object]) -> None: tw.line(" no docstring available", red=True) def write_item(item: nodes.Item) -> None: - # Not all items have _fixtureinfo attribute. - info: FuncFixtureInfo | None = getattr(item, "_fixtureinfo", None) - if info is None or not info.name2fixturedefs: + fixturedefs = list(_get_fixtures_per_test(item)) + if not fixturedefs: # This test item does not use any fixtures. return + tw.line() tw.sep("-", f"fixtures used by {item.name}") # TODO: Fix this type ignore. tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] - # dict key not used in loop but needed for sorting. - for _, fixturedefs in sorted(info.name2fixturedefs.items()): - assert fixturedefs is not None - if not fixturedefs: - continue - # Last item is expected to be the one used by the test item. - write_fixture(fixturedefs[-1]) + + for fixturedef in fixturedefs: + write_fixture(fixturedef) for session_item in session.items: write_item(session_item) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 13932e548c5..806e4a22fdf 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -55,6 +55,7 @@ from _pytest.fixtures import _resolve_args_directness from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import FixtureValue from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import get_scope_node from _pytest.main import Session @@ -1095,8 +1096,7 @@ class CallSpec2: and stored in item.callspec. """ - # arg name -> arg value which will be passed to a fixture or pseudo-fixture - # of the same name. (indirect or direct parametrization respectively) + # arg name -> arg value which will be passed to a fixture of the same name. params: dict[str, object] = dataclasses.field(default_factory=dict) # arg name -> arg index. indices: dict[str, int] = dataclasses.field(default_factory=dict) @@ -1153,8 +1153,30 @@ def get_direct_param_fixture_func(request: FixtureRequest) -> Any: return request.param -# Used for storing pseudo fixturedefs for direct parametrization. -name2pseudofixturedef_key = StashKey[dict[str, FixtureDef[Any]]]() +class DirectParamFixtureDef(FixtureDef[FixtureValue]): + """A custom FixtureDef for direct parametrization fixtures. + + Each parameter in direct parametrization is desugared to a parametrized + fixture which returns the direct parameterization value as its param. + We use this custom type as a "marker" for this type of FixtureDef, but + usually behaves like any other FixtureDef. + """ + + def __init__(self, *, config: Config, argname: str, scope: Scope) -> None: + super().__init__( + config=config, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=scope, + params=None, + ids=None, + _ispytest=True, + ) + + +# Used for storing fixturedefs for direct parametrization. +name2directparamfixturedef_key = StashKey[dict[str, DirectParamFixtureDef[object]]]() @final @@ -1333,14 +1355,14 @@ def parametrize( self._params_directness.update(arg_directness) # Add direct parametrizations as fixturedefs to arg2fixturedefs by - # registering artificial "pseudo" FixtureDef's such that later at test + # registering artificial DirectParamFixtureDef's such that later at test # setup time we can rely on FixtureDefs to exist for all argnames. node = None - # For scopes higher than function, a "pseudo" FixtureDef might have + # For scopes higher than function, a DirectParamFixtureDef might have # already been created for the scope. We thus store and cache the - # FixtureDef on the node related to the scope. + # DirectParamFixtureDef on the node related to the scope. if scope_ is Scope.Function: - name2pseudofixturedef = None + name2directparamfixturedef = None else: collector = self.definition.parent assert collector is not None @@ -1357,28 +1379,26 @@ def parametrize( node = collector.session else: assert False, f"Unhandled missing scope: {scope}" - default: dict[str, FixtureDef[Any]] = {} - name2pseudofixturedef = node.stash.setdefault( - name2pseudofixturedef_key, default + default: dict[str, DirectParamFixtureDef[object]] = {} + name2directparamfixturedef = node.stash.setdefault( + name2directparamfixturedef_key, default ) for argname in argnames: if arg_directness[argname] == "indirect": continue - if name2pseudofixturedef is not None and argname in name2pseudofixturedef: - fixturedef = name2pseudofixturedef[argname] + if ( + name2directparamfixturedef is not None + and argname in name2directparamfixturedef + ): + fixturedef = name2directparamfixturedef[argname] else: - fixturedef = FixtureDef( + fixturedef = DirectParamFixtureDef( config=self.config, - baseid="", argname=argname, - func=get_direct_param_fixture_func, scope=scope_, - params=None, - ids=None, - _ispytest=True, ) - if name2pseudofixturedef is not None: - name2pseudofixturedef[argname] = fixturedef + if name2directparamfixturedef is not None: + name2directparamfixturedef[argname] = fixturedef self._arg2fixturedefs[argname] = [fixturedef] # Create the new calls: if we are parametrize() multiple times (by applying the decorator diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index b48de293ede..026589d65f5 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -801,7 +801,7 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x"]) assert metafunc._calls[0].params == dict(x="a", y="b") - # Since `y` is a direct parameter, its pseudo-fixture would + # Since `y` is a direct parameter, its DirectParamFixtureDef would # be registered. assert list(metafunc._arg2fixturedefs.keys()) == ["y"] diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index c860b61e21b..76fc3728267 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -3,7 +3,7 @@ from _pytest.pytester import Pytester -def test_no_items_should_not_show_output(pytester: Pytester) -> None: +def test_should_show_no_output_when_zero_items(pytester: Pytester) -> None: result = pytester.runpytest("--fixtures-per-test") result.stdout.no_fnmatch_line("*fixtures used by*") assert result.ret == 0 @@ -254,3 +254,75 @@ def test_arg1(arg1): " Docstring content that extends into a third paragraph.", ] ) + + +def test_should_not_show_direct_param_fixtures(pytester: Pytester) -> None: + """A direct-param fixture is a helper fixture created as an implementation + detail of direct parametrization. + + These fixtures should not be included in the output because they don't + satisfy user expectations for how fixtures are created and used (#11295). + """ + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [1]) + def test_pseudo_fixture(x): + pass + """ + ) + result = pytester.runpytest("--fixtures-per-test") + result.stdout.no_fnmatch_line("*fixtures used by*") + assert result.ret == 0 + + +def test_should_show_parametrized_fixtures_used_by_test(pytester: Pytester) -> None: + """A fixture with parameters should be included if it was created using + the @pytest.fixture decorator, including those that are indirectly + parametrized.""" + pytester.makepyfile( + ''' + import pytest + + @pytest.fixture(params=['a', 'b']) + def directly(request): + """parametrized fixture""" + return request.param + + @pytest.fixture + def indirectly(request): + """indirectly parametrized fixture""" + return request.param + + def test_directly_parametrized_fixture(directly): + pass + + @pytest.mark.parametrize("indirectly", ["a", "b"], indirect=True) + def test_indirectly_parametrized_fixture(indirectly): + pass + ''' + ) + result = pytester.runpytest("--fixtures-per-test") + assert result.ret == 0 + + result.stdout.fnmatch_lines( + [ + "*fixtures used by test_directly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:14)*", + "directly -- test_should_show_parametrized_fixtures_used_by_test.py:4", + " parametrized fixture", + "*fixtures used by test_directly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:14)*", + "directly -- test_should_show_parametrized_fixtures_used_by_test.py:4", + " parametrized fixture", + "*fixtures used by test_indirectly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:17)*", + "indirectly -- test_should_show_parametrized_fixtures_used_by_test.py:9", + " indirectly parametrized fixture", + "*fixtures used by test_indirectly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:17)*", + "indirectly -- test_should_show_parametrized_fixtures_used_by_test.py:9", + " indirectly parametrized fixture", + ] + )