diff --git a/changelog/15067.bugfix.rst b/changelog/15067.bugfix.rst new file mode 100644 index 00000000000..7bb7df762d8 --- /dev/null +++ b/changelog/15067.bugfix.rst @@ -0,0 +1,4 @@ +Fixed fixture finalizers registered via ``request.addfinalizer()`` being skipped +when fixture setup was interrupted (for example by ``KeyboardInterrupt`` or a +fail-fast plugin) before the fixture value was cached. The interruption is now +recorded so the registered finalizers still run during teardown, as documented. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 30f44d44dfc..ce1cfb8155c 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1404,6 +1404,12 @@ def pytest_fixture_setup( e._use_item_location = True fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) raise + except BaseException as e: + # Fixture setup was interrupted (e.g. by KeyboardInterrupt or a + # fail-fast plugin). Record the failure so that finalizers already + # registered via addfinalizer() still run during teardown (#15067). + fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) + raise fixturedef.cached_result = (result, my_cache_key, None) return result diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index bc7b5a40cc2..e998674a6ff 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1111,6 +1111,71 @@ def test_fix(myfix): mod = reprec.getcalls("pytest_runtest_setup")[0].item.module assert not mod.values + def test_request_addfinalizer_interrupted_setup(self, pytester: Pytester) -> None: + """Ensure finalizers registered before fixture setup is interrupted + (e.g. by KeyboardInterrupt) still run during teardown (#15067).""" + pytester.makeconftest( + """ + from pathlib import Path + + import pytest + + marker = Path(__file__).with_name("finalizer-ran") + + + @pytest.fixture + def resource(request): + request.addfinalizer( + lambda: marker.write_text("ran", encoding="utf-8") + ) + raise KeyboardInterrupt + """ + ) + pytester.makepyfile( + """ + def test_setup(resource): + pass + """ + ) + result = pytester.runpytest_subprocess() + assert result.ret == ExitCode.INTERRUPTED + assert pytester.path.joinpath("finalizer-ran").exists() + + def test_request_addfinalizer_interrupted_setup_inprocess( + self, pytester: Pytester + ) -> None: + """In-process variant of test_request_addfinalizer_interrupted_setup. + + Runs the interrupted setup in-process so coverage observes the + ``except BaseException`` branch in ``pytest_fixture_setup`` (#15067). + """ + pytester.makeconftest( + """ + from pathlib import Path + + import pytest + + marker = Path(__file__).with_name("finalizer-ran-inprocess") + + + @pytest.fixture + def resource(request): + request.addfinalizer( + lambda: marker.write_text("ran", encoding="utf-8") + ) + raise KeyboardInterrupt + """ + ) + pytester.makepyfile( + """ + def test_setup(resource): + pass + """ + ) + result = pytester.runpytest_inprocess(no_reraise_ctrlc=True) + assert result.ret == ExitCode.INTERRUPTED + assert pytester.path.joinpath("finalizer-ran-inprocess").exists() + def test_request_addfinalizer_partial_setup_failure( self, pytester: Pytester ) -> None: