From c08782b1d7c2525165eb0257ce0375817bb81f78 Mon Sep 17 00:00:00 2001 From: Rafael Gama Date: Mon, 19 Oct 2026 19:50:24 +0000 Subject: [PATCH] Do not register function-scoped fixture finalizers with requested fixtures Function-scoped fixtures are always torn down by SetupState at the end of each test, before any higher-scoped fixture can be torn down or invalidated. Registering their finalizer with the fixtures they request was therefore unnecessary, and it made finalizers accumulate on higher-scoped fixtures (for example, one per test on tmp_path_factory for every test using tmp_path). Fixtures with a wider scope still register their finalizer, since a class-scoped fixture that requests a parametrized module-scoped fixture must be invalidated when the parameter changes while the class is still active. Add a test for that case as well. Refs #4871 --- changelog/4871.improvement.rst | 1 + src/_pytest/fixtures.py | 9 +++-- testing/python/fixtures.py | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 changelog/4871.improvement.rst diff --git a/changelog/4871.improvement.rst b/changelog/4871.improvement.rst new file mode 100644 index 00000000000..9275f451384 --- /dev/null +++ b/changelog/4871.improvement.rst @@ -0,0 +1 @@ +Function-scoped fixtures no longer register their finalizer with the higher-scoped fixtures they request, so finalizers no longer accumulate in fixtures such as ``tmp_path_factory`` for every test that uses ``tmp_path``. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 20e43f89132..255ed30dce5 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1273,8 +1273,13 @@ def execute(self, request: SubRequest) -> FixtureValue: # We make sure to do this after checking for cached value to avoid # adding our finalizer multiple times. (#12135) finalizer = functools.partial(self.finish, request=request) - for parent_fixture in requested_fixtures_that_should_finalize_us: - parent_fixture.addfinalizer(finalizer) + # Function-scoped fixtures are always torn down by SetupState at the end + # of each test, before any higher-scoped fixture can be torn down or + # invalidated, so registering with requested fixtures is not needed and + # would only accumulate finalizers on them (#4871). + if self._scope is not Scope.Function: + 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. diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..e3d4ec9c751 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -5688,6 +5688,71 @@ def test_result(): assert result.ret == 0 +def test_function_scoped_fixture_does_not_accumulate_finalizers( + pytester: Pytester, +) -> None: + """ + Function-scoped fixtures should not register their finalizer with the + higher-scoped fixtures they request, otherwise one finalizer accumulates + there for every test (#4871). + """ + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="session") + def session_fixture(): + ... + + @pytest.fixture + def function_fixture(session_fixture): + ... + + @pytest.mark.parametrize("i", range(5)) + def test_it(i, function_fixture, request): + fixturedef = request._get_active_fixturedef("session_fixture") + # Only the pytest_fixture_post_finalizer registered by the + # session fixture itself. + assert len(fixturedef._finalizers) == 1 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=5) + + +def test_fixture_invalidated_by_wider_scoped_param(pytester: Pytester) -> None: + """ + A class-scoped fixture requesting a parametrized module-scoped fixture must + be torn down when the parameter changes, even though the class node is + still active (#4871). + """ + pytester.makepyfile( + """ + import pytest + + values = [] + + @pytest.fixture(scope="module", params=["a", "b"]) + def module_param(request): + return request.param + + @pytest.fixture(scope="class") + def class_fixture(module_param): + values.append(module_param) + return module_param + + class TestIt: + def test_it(self, module_param, class_fixture): + assert class_fixture == module_param + + def test_values(): + assert values == ["a", "b"] + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=3) + + def test_parametrized_fixture_scope_allowed(pytester: Pytester) -> None: """ Make sure scope from parametrize does not affect fixture's ability to be