diff --git a/AUTHORS b/AUTHORS index 53f7a8c2a16..8e136384c58 100644 --- a/AUTHORS +++ b/AUTHORS @@ -423,6 +423,7 @@ Vlad Radziuk Vladyslav Rachek Volodymyr Kochetkov Volodymyr Piskun +Warren Markham Wei Lin Wil Cooley William Lee diff --git a/changelog/11295.improvement.rst b/changelog/11295.improvement.rst new file mode 100644 index 00000000000..299cff9fab8 --- /dev/null +++ b/changelog/11295.improvement.rst @@ -0,0 +1 @@ +Improved output of --fixtures-per-test by excluding internal, pseudo fixtures. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3242d517ea0..b089e650405 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -58,6 +58,7 @@ from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest +from _pytest.fixtures import _get_direct_parametrize_args from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FuncFixtureInfo @@ -1482,7 +1483,7 @@ def _validate_if_using_arg_names( def _find_parametrized_scope( argnames: Sequence[str], - arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], + arg2fixturedefs: Mapping[str, Sequence[FixtureDef[object]]], indirect: Union[bool, Sequence[str]], ) -> Scope: """Find the most appropriate scope for a parametrized call based on its arguments. @@ -1534,6 +1535,48 @@ def _pretty_fixture_path(invocation_dir: Path, func) -> str: return bestrelpath(invocation_dir, loc) +def _get_fixtures_per_test(test: nodes.Item) -> Optional[List[FixtureDef[object]]]: + """Returns all fixtures used by the test item except for a) those + created by direct parametrization with ``@pytest.mark.parametrize`` and + b) those accessed dynamically with ``request.getfixturevalue``. + + The justification for excluding fixtures created by direct + parametrization is that their appearance in a report would surprise + users currently learning about fixtures, as they do not conform to the + documented characteristics of fixtures (reusable, providing + setup/teardown features, and created via the ``@pytest.fixture`` + decorator). + + In other words, an internal detail that leverages the fixture system + to batch execute tests should not be exposed in a report intended to + summarise the user's fixture choices. + """ + # Contains information on the fixtures the test item requests + # statically, if any. + fixture_info: Optional[FuncFixtureInfo] = getattr(test, "_fixtureinfo", None) + if fixture_info is None: + # The given test item does not statically request any fixtures. + return [] + + # In the transitive closure of fixture names required by the item + # through autouse, function parameter or @userfixture mechanisms, + # multiple overrides may have occured; for this reason, the fixture + # names are matched to a sequence of FixtureDefs. + name2fixturedefs = fixture_info.name2fixturedefs + fixturedefs = [ + # The final override, which is the one the test item will utilise, + # is stored in the final position of the sequence; therefore, we + # take the FixtureDef of the final override and add it to the list. + # + # If there wasn't an override, the final item will simply be the + # first item, as required. + fixturedefs[-1] + for argname, fixturedefs in sorted(name2fixturedefs.items()) + if argname not in _get_direct_parametrize_args(test) + ] + return fixturedefs + + def show_fixtures_per_test(config): from _pytest.main import wrap_session @@ -1552,7 +1595,21 @@ def get_best_relpath(func) -> str: loc = getlocation(func, invocation_dir) return bestrelpath(invocation_dir, Path(loc)) - def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: + def write_item(item: nodes.Item) -> None: + fixturedefs = _get_fixtures_per_test(item) + if not fixturedefs: + # This test item does not use any fixtures. + # Do not write anything. + return + + tw.line() + tw.sep("-", f"fixtures used by {item.name}") + tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] + + for fixturedef in fixturedefs: + write_fixture(fixturedef) + + def write_fixture(fixture_def: FixtureDef[object]) -> None: argname = fixture_def.argname if verbose <= 0 and argname.startswith("_"): return @@ -1568,24 +1625,6 @@ def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: else: tw.line(" no docstring available", red=True) - def write_item(item: nodes.Item) -> None: - # Not all items have _fixtureinfo attribute. - info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None) - if info is None or not info.name2fixturedefs: - # 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 session_item in session.items: write_item(session_item) diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index f756dca41c7..38902f7ebb3 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -1,7 +1,7 @@ from _pytest.pytester import Pytester -def test_no_items_should_not_show_output(pytester: Pytester) -> None: +def test_should_show_no_ouput_when_zero_items(pytester: Pytester) -> None: result = pytester.runpytest("--fixtures-per-test") result.stdout.no_fnmatch_line("*fixtures used by*") assert result.ret == 0 @@ -252,3 +252,74 @@ def test_arg1(arg1): " Docstring content that extends into a third paragraph.", ] ) + + +def test_should_not_show_pseudo_fixtures(pytester: Pytester) -> None: + """A fixture is considered pseudo if it was directly created using the + ``@pytest.mark.parametrize`` decorator as part of internal pytest + mechanisms (such as to manage batch execution). These fixtures should not + be included in the output because they don't satisfy user expectations for + how fixtures are created and used.""" + p = pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [1]) + def test_pseudo_fixture(x): + pass + """ + ) + result = pytester.runpytest("--fixtures-per-test", p) + 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.""" + p = 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", p) + 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", + ] + )