diff --git a/changelog/3225.bugfix.rst b/changelog/3225.bugfix.rst new file mode 100644 index 00000000000..9985da918e7 --- /dev/null +++ b/changelog/3225.bugfix.rst @@ -0,0 +1 @@ +A non-autouse fixture overriding an autouse one no longer runs automatically, without changing explicit fixture requests. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7656fca2f5b..118557e3c76 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1124,7 +1124,7 @@ def __init__( ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, *, node: nodes.Node | NotSetType = NOTSET, - # only used in a deprecationwarning msg, can be removed in pytest9 + # Whether the fixture is autouse; consulted during fixture closure (#3225). _autouse: bool = False, _ispytest: bool = False, ) -> None: @@ -1186,7 +1186,7 @@ def __init__( self.cached_result: _FixtureCachedResult[FixtureValue] | None = None self._finalizers: Final[list[Callable[[], object]]] = [] - # only used to emit a deprecationwarning, can be removed in pytest9 + # Whether the fixture is autouse; consulted during fixture closure (#3225). self._autouse = _autouse @property @@ -1938,14 +1938,27 @@ def pytest_collection_finish(self) -> None: def _getautousenames(self, node: nodes.Node) -> Iterator[str]: """Return the names of autouse fixtures visible to node.""" + usefixtures_ini = set(self.config.getini("usefixtures")) for parentnode in node.listchain(): basenames = self._node_autousenames.get(parentnode) if basenames: - yield from basenames - # Legacy fallback: check string-based nodeid autouse names. + for name in basenames: + if name in usefixtures_ini or self._is_autouse(name, node): + yield name + # Legacy fallback: string-based nodeid autouse names. nodeid_basenames = self._nodeid_autousenames.get(parentnode.nodeid) if nodeid_basenames: - yield from nodeid_basenames + for name in nodeid_basenames: + if self._is_autouse(name, node): + yield name + + def _is_autouse(self, name: str, node: nodes.Node) -> bool: + """Whether the fixture resolved for name is itself autouse. + + A non-autouse override cancels the autouse fixture it shadows (#3225). + """ + fixturedefs = self.getfixturedefs(name, node) + return not fixturedefs or fixturedefs[-1]._autouse def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]: """Return the names of usefixtures fixtures visible to node.""" diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 8e779a93d76..9d599216db9 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2560,6 +2560,112 @@ def test_hello(arg1): reprec = pytester.inline_run() reprec.assertoutcome(passed=1) + def test_autouse_cancelled_by_non_autouse_override( + self, pytester: Pytester + ) -> None: + """A non-autouse override cancels the autouse fixture it shadows (#3225).""" + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(autouse=True) + def foo(): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.fixture() + def foo(): + assert False + + def test_bar(foo): + pass + + def test_baz(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, errors=1) + result = pytester.runpytest("-o", "usefixtures=foo") + result.assert_outcomes(errors=2) + + def test_autouse_cancelled_by_non_autouse_class_override( + self, pytester: Pytester + ) -> None: + """A non-autouse class-level override cancels the autouse fixture (#3225).""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(autouse=True) + def foo(): + pass + + class TestClass: + @pytest.fixture() + def foo(self): + assert False + + def test_with_request(self, foo): + pass + + def test_no_request(self): + pass + + def test_module_level(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2, errors=1) + + def test_getautousenames_legacy_nodeid_autouse(self, pytester: Pytester) -> None: + """Autouse registered via the deprecated nodeid API is still yielded.""" + pytester.makeconftest( + """ + import pytest + + @pytest.fixture + def fm(request): + return request._fixturemanager + + @pytest.fixture + def item(request): + return request._pyfuncitem + """ + ) + pytester.makepyfile( + """ + import warnings + + def test_legacy(item, fm): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + fm._register_fixture( + name="legacy_auto", + func=lambda: None, + nodeid=item.nodeid, + autouse=True, + ) + assert "legacy_auto" in list(fm._getautousenames(item)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + fm._register_fixture( + name="legacy_auto", + func=lambda: None, + nodeid=item.nodeid, + autouse=False, + ) + assert "legacy_auto" not in list(fm._getautousenames(item)) + """ + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=1) + @pytest.mark.parametrize("param1", ["", "params=[1]"], ids=["p00", "p01"]) @pytest.mark.parametrize("param2", ["", "params=[1]"], ids=["p10", "p11"]) def test_ordering_dependencies_torndown_first(