diff --git a/changelog/11281.bugfix.rst b/changelog/11281.bugfix.rst new file mode 100644 index 00000000000..91f06704ed2 --- /dev/null +++ b/changelog/11281.bugfix.rst @@ -0,0 +1,3 @@ +Fixed autouse fixtures with the same name at different scopes (e.g., module and class) +so that all of them execute. Previously, only the closest-scoped fixture would run, +silently skipping broader-scoped autouse fixtures with the same name. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 84f90f946be..8989fe7ce0b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -591,6 +591,19 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]: raise FixtureLookupError(argname, self) fixturedef = fixturedefs[index] + # When an autouse fixture shadows a broader-scoped autouse fixture + # with the same name (e.g., class-level "setup" shadows module-level + # "setup"), both should run -- the broader-scoped one first. + # If the closer fixture doesn't explicitly request its super (i.e., + # argname not in its own argnames), the broader-scoped autouse + # fixture would never be activated. Ensure it runs here. (#11281) + if ( + fixturedef._autouse + and argname not in fixturedef.argnames + and len(fixturedefs) > 1 + ): + self._ensure_autouse_super_fixtures(argname, fixturedefs, index) + # Prepare a SubRequest object for calling the fixture. try: callspec = self._pyfuncitem.callspec @@ -622,6 +635,42 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]: self._fixture_defs[argname] = fixturedef return fixturedef + def _ensure_autouse_super_fixtures( + self, + argname: str, + fixturedefs: Sequence[FixtureDef[Any]], + index: int, + ) -> None: + """Ensure broader-scoped autouse fixtures in the override chain are executed. + + When an autouse fixture at a closer scope (e.g., class) shadows an + autouse fixture at a broader scope (e.g., module) with the same name, + the broader-scoped fixture would not run because the closer one does + not explicitly request it. This method activates the broader-scoped + autouse fixtures so they run first, preserving the documented behavior + that higher-scoped fixtures execute first. + + See issue #11281. + """ + # fixturedefs is ordered from broadest to closest scope. + # index is negative (-1 = closest). We want to activate all + # broader-scoped autouse fixtures that come before the active one. + active_pos = len(fixturedefs) + index + for i in range(active_pos): + super_fixturedef = fixturedefs[i] + if not super_fixturedef._autouse: + continue + if super_fixturedef.cached_result is not None: + # Already executed (e.g., by a module-level test). + continue + # Execute the broader-scoped autouse fixture. + super_scope = super_fixturedef._scope + self._check_scope(super_fixturedef, super_scope) + super_subrequest = SubRequest( + self, super_scope, NOTSET, 0, super_fixturedef, _ispytest=True + ) + super_fixturedef.execute(request=super_subrequest) + def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> None: """Check that this request is allowed to execute this fixturedef without a param.""" @@ -1028,6 +1077,11 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: + if self.cached_result is None: + # Already finished. It is assumed that finalizers cannot be added in + # this state. + return + exceptions: list[BaseException] = [] while self._finalizers: fin = self._finalizers.pop() @@ -1036,7 +1090,6 @@ def finish(self, request: SubRequest) -> None: except BaseException as e: exceptions.append(e) node = request.node - node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) # Even if finalization fails, we invalidate the cached fixture # value and remove all finalizers because they may be bound methods # which will keep instances alive. @@ -1096,6 +1149,15 @@ def execute(self, request: SubRequest) -> FixtureValue: for parent_fixture in requested_fixtures_that_should_finalize_us: parent_fixture.addfinalizer(finalizer) + # Register the pytest_fixture_post_finalizer as the first finalizer, + # which is executed last. + assert not self._finalizers + self.addfinalizer( + lambda: request.node.ihook.pytest_fixture_post_finalizer( + fixturedef=self, request=request + ) + ) + ihook = request.node.ihook try: # Setup the fixture, run the code in it, and cache the value diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 7122f7fef3b..3058fa8262c 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5426,3 +5426,72 @@ def test_foobar(self, fixture_bar): ) result = pytester.runpytest("-v") result.assert_outcomes(passed=1) + + +def test_autouse_same_name_module_and_class_both_run(pytester: Pytester) -> None: + """When module-scope and class-scope autouse fixtures share the same name, + both should execute for tests inside the class, with the module-scoped + fixture running first (higher scope executes first). + + Regression test for https://github.com/pytest-dev/pytest/issues/11281 + """ + pytester.makepyfile( + """ + import pytest + + call_order = [] + + @pytest.fixture(scope="module", autouse=True) + def setup(): + call_order.append("MODULE") + + class TestFoo: + @pytest.fixture(scope="class", autouse=True) + def setup(self): + call_order.append("CLASS") + + def test_in_class(self): + assert call_order == ["MODULE", "CLASS"] + + def test_module(): + # Module-level test only sees the module fixture. + assert "MODULE" in call_order + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=2) + + +def test_autouse_same_name_conftest_and_module_both_run(pytester: Pytester) -> None: + """When a conftest autouse fixture and a module autouse fixture share the + same name, both should execute, with the conftest (broader scope) running + first. + + Regression test for https://github.com/pytest-dev/pytest/issues/11281 + """ + pytester.makeconftest( + """ + import pytest + + call_order = [] + + @pytest.fixture(scope="session", autouse=True) + def setup(): + call_order.append("SESSION") + """ + ) + pytester.makepyfile( + """ + import pytest + from conftest import call_order + + @pytest.fixture(scope="module", autouse=True) + def setup(): + call_order.append("MODULE") + + def test_both_run(): + assert call_order == ["SESSION", "MODULE"] + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1)