Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve(fixtures-per-test): exclude pseudo fixtures from output #11295 #12129

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions AUTHORS
Expand Up @@ -423,6 +423,7 @@ Vlad Radziuk
Vladyslav Rachek
Volodymyr Kochetkov
Volodymyr Piskun
Warren Markham
Wei Lin
Wil Cooley
William Lee
Expand Down
1 change: 1 addition & 0 deletions changelog/11295.improvement.rst
@@ -0,0 +1 @@
Improved output of --fixtures-per-test by excluding internal, pseudo fixtures.
79 changes: 59 additions & 20 deletions src/_pytest/python.py
Expand Up @@ -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
Expand Down Expand Up @@ -1482,7 +1483,7 @@

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.
Expand Down Expand Up @@ -1534,6 +1535,48 @@
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 []

Check warning on line 1559 in src/_pytest/python.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/python.py#L1559

Added line #L1559 was not covered by tests

# 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

Expand All @@ -1552,7 +1595,21 @@
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
Comment on lines 1614 to 1615
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this condition needs to be moved into write_item before the occurrence of the terminal writes

     tw.sep("-", f"fixtures used by {item.name}")
     tw.sep("-", f"({get_best_relpath(item.function)})") 

and adapted to filter fixturedefs of such private fixtures (assuming verbosity less than or equal to zero).

Otherwise there is a circumstance where all an item's fixtures have been excluded from the output yet the terminal still starts being written to as if fixture output is about to follow.

For example, if all of test_private_fixtures's fixtures start with _ and verbosity is zero or less, then no fixtures will be shown but fixtures used by test_private_fixtures. . . etc will be shown.

TODO:

  • add tests for fixtures starting with _
  • adapt condition to conditionally filter the fixturedefs list

I could be wrong about all of this. I will write up the test tonight or tomorrow and see.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have confirmed that in this PR private fixtures are not shown when verbosity is zero or less but the fixtures used by . . . is still printed. Is this desired behaviour?
I don't know. It is how pytest currently handles reporting on private fixtures.

import pytest

# DEFINE FIXTURES
#################

@pytest.fixture
def _private():
    """Private fixture"""
    pass

@pytest.fixture
def public():
    """Public fixture."""
    pass

# TESTS
#################

def test_private_fixture(_private):
    pass

def test_public_fixture(public):
    pass

@pytest.mark.parametrize("pseudo", [1])
def test_pseudo_fixture(pseudo):
    pass

def test_no_fixture():
    pass

Running pytest --fixtures-per-test on the current version of pytest shows:

----------- fixtures used by test_private_fixture ------------
-------------------- (test_private.py:14) --------------------

------------ fixtures used by test_public_fixture ------------
-------------------- (test_private.py:17) --------------------
public -- test_private.py:9
    Public fixture.

---------- fixtures used by test_pseudo_fixture[1] -----------
-------------------- (test_private.py:20) --------------------
pseudo -- .../_pytest/python.py:1112
    no docstring available

I'm proposing it shows:

--------------------- fixtures used by test_public_fixture ----------------------
-------------------------- (test_fixture_marker.py:23) --------------------------
public -- test_fixture_marker.py:12
    Public fixture.

Notable differences:

  • pseudo fixtures in tests are ignored
  • a test with only private fixtures is treated like a test with no fixtures at verbosity zero or less (i.e. the test is not reported on at all)

The output this PR currently produces is:

--------------------- fixtures used by test_private_fixture ---------------------
-------------------------- (test_fixture_marker.py:20) --------------------------

--------------------- fixtures used by test_public_fixture ----------------------
-------------------------- (test_fixture_marker.py:23) --------------------------
public -- test_fixture_marker.py:12
    Public fixture.

Expand All @@ -1568,24 +1625,6 @@
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)

Expand Down
73 changes: 72 additions & 1 deletion 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
Expand Down Expand Up @@ -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",
]
)