From 6c3f95fa5cd35fdc473c3ab2f1e8574d106932aa Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Thu, 27 Aug 2026 08:48:51 -0400 Subject: [PATCH 1/2] Restore higher-scoped teardown when a teardown error rules out a re-run pytest_runtest_teardown takes the module, class and session finalizers off the setup stack when it expects a re-run. The error a teardown raises is only classified as terminal afterwards, in pytest_runtest_makereport, so that decision cannot see it. With --rerun-except or --only-rerun matching that error the protocol then declines to re-run, nothing puts the finalizers back, and the higher-scoped fixture teardown is skipped for the rest of the session. Make the hook a wrapper so it can look at the error the teardown phase raised, and when that error rules out a re-run, put the finalizers back and tear down what the item was the last user of. --- changes/356.bugfix.rst | 1 + src/pytest_rerunfailures.py | 52 ++++++++++++-- tests/test_pytest_rerunfailures.py | 109 +++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 changes/356.bugfix.rst diff --git a/changes/356.bugfix.rst b/changes/356.bugfix.rst new file mode 100644 index 0000000..ec69a55 --- /dev/null +++ b/changes/356.bugfix.rst @@ -0,0 +1 @@ +Restore module, class, and session scoped fixture teardown when an error raised during teardown rules out a re-run. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index b294296..bacf31b 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -534,6 +534,10 @@ def _should_hard_fail_on_error(item, report, excinfo): if report.outcome != "failed": return False + return _is_terminal_error(item, excinfo) + + +def _is_terminal_error(item, excinfo): rerun_errors = _get_rerun_filter_regex(item, "only_rerun") rerun_except_errors = _get_rerun_filter_regex(item, "rerun_except") @@ -898,17 +902,21 @@ def _is_rerun_path_excluded(item): ) -def pytest_runtest_teardown(item, nextitem): +def _suspend_finalizers_for_rerun(item): + """Hold back the higher-scope finalizers if the test is going to be re-run. + + Returns whether they were taken off the stack. + """ reruns = get_reruns_count(item) if reruns is None: # global setting is not specified, and this test is not marked with # flaky - return + return False if not hasattr(item, "execution_count"): # pytest_runtest_protocol hook of this plugin was not executed # -> teardown needs to be skipped as well - return + return False _test_failed_statuses = getattr(item, "_test_failed_statuses", {}) @@ -918,7 +926,7 @@ def pytest_runtest_teardown(item, nextitem): and item.session.config.failures_db.get_suite_reruns() >= max_suite_reruns ): _restore_suspended_finalizers(item) - return + return False # Only remove non-function level actions from the stack if the test is to be re-run # Exceeding re-run limits, being free of failue statuses, encountering @@ -940,9 +948,41 @@ def pytest_runtest_teardown(item, nextitem): if key not in suspended_finalizers: suspended_finalizers[key] = item.session._setupstate.stack[key] del item.session._setupstate.stack[key] - else: - # restore suspended finalizers + return True + + # restore suspended finalizers + _restore_suspended_finalizers(item) + return False + + +def _teardown_error_is_terminal(item, outcome): + """Report whether the teardown phase raised an error that stops re-runs.""" + exc_info = outcome.excinfo + if exc_info is None: + return False + + return _is_terminal_error(item, pytest.ExceptionInfo.from_exc_info(exc_info)) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_teardown(item, nextitem): + suspended = _suspend_finalizers_for_rerun(item) + outcome = yield + + # The error a teardown raises is only classified once this hook has + # returned, so the decision above could not take it into account. A + # terminal one means there is no re-run to hold the finalizers back for, + # so put them back on the stack and tear down what this item was the last + # user of. + if suspended and _teardown_error_is_terminal(item, outcome): _restore_suspended_finalizers(item) + try: + item.session._setupstate.teardown_exact(nextitem) + except BaseException as exc: + # The error raised above is the one that rules out a re-run, so it + # has to stay the error of this phase. Chain this one onto it + # instead of replacing it, so both end up in the report. + outcome.excinfo[1].__context__ = exc @pytest.hookimpl(hookwrapper=True) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index c09e3f1..79998f9 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -1431,6 +1431,115 @@ def test_fail(): assert result.stdout.str().count("module teardown") == 1 +@pytest.mark.parametrize("scope", ["class", "module", "session"]) +def test_terminal_teardown_error_preserves_higher_scope_teardown(testdir, scope): + testdir.makepyfile( + f""" + import pytest + + @pytest.fixture(scope="{scope}", autouse=True) + def higher_scope_fixture(): + yield + print("{scope} teardown") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") + + class TestFlaky: + @pytest.mark.flaky(reruns=2, rerun_except=["ValueError"]) + def test_fail(self, broken_fixture): + assert False""" + ) + + result = testdir.runpytest("-s") + assert_outcomes(result, passed=0, failed=1, error=1, rerun=0) + result.stdout.fnmatch_lines(f"*{scope} teardown*") + + +def test_terminal_teardown_error_preserves_teardown_of_earlier_module(testdir): + testdir.makepyfile( + test_flaky_module=""" + import pytest + + @pytest.fixture(scope="module", autouse=True) + def flaky_module_fixture(): + yield + print("flaky module teardown") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") + + @pytest.mark.flaky(reruns=2, rerun_except=["ValueError"]) + def test_fail(broken_fixture): + assert False""", + test_later_module=""" + def test_pass(): + print("later module test")""", + ) + + result = testdir.runpytest("-s") + assert_outcomes(result, passed=1, failed=1, error=1, rerun=0) + result.stdout.fnmatch_lines( + ["*flaky module teardown*", "*later module test*"], + ) + + +def test_terminal_teardown_error_reports_failing_higher_scope_teardown(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + raise RuntimeError("module teardown error") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") + + @pytest.mark.flaky(reruns=2, rerun_except=["ValueError"]) + def test_fail(broken_fixture): + assert False""" + ) + + result = testdir.runpytest() + assert_outcomes(result, passed=0, failed=1, error=1, rerun=0) + result.stdout.fnmatch_lines( + ["*RuntimeError: module teardown error*", "*ValueError: teardown error*"], + ) + + +def test_rerunnable_teardown_error_tears_down_module_fixture_once(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + print("module teardown") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") + + @pytest.mark.flaky(reruns=2, only_rerun=["AssertionError", "ValueError"]) + def test_fail(broken_fixture): + assert False""" + ) + + result = testdir.runpytest("-s") + assert_outcomes(result, passed=0, failed=1, error=3, rerun=2) + assert result.stdout.str().count("module teardown") == 1 + + @pytest.mark.parametrize( "marker_only_rerun,cli_only_rerun,should_rerun", [ From 475153672cabe9e12af79bc92f1f138954b7f5b4 Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Fri, 28 Aug 2026 04:34:08 -0400 Subject: [PATCH 2/2] Decide the terminal teardown error from the report, not the exception The teardown hookwrapper had to work out whether a teardown error rules out a re-run before pytest built the report for the phase, so it re-derived the answer from the raw exception. That second derivation disagreed with pytest_runtest_makereport for every error pytest does not report as a failure: pytest.skip() and pytest.xfail() in a teardown were classified as terminal, the higher-scoped fixtures were torn down, and then the test was re-run anyway. Move the restore and the teardown into pytest_runtest_makereport, which already has the report and _terminal_errors, and drop _is_terminal_error. There is one derivation now, so the two cannot disagree. Run the second teardown through CallInfo.from_call with the same reraise as pytest, so an Exit or a KeyboardInterrupt from a higher-scoped finalizer still ends the session instead of being swallowed. When both teardowns fail, report them as a BaseExceptionGroup the way pytest does for several failing finalizers, rather than assigning __context__, which was lost whenever the first error was raised with "from None". --- src/pytest_rerunfailures.py | 114 +++++++++++++++++------------ tests/test_pytest_rerunfailures.py | 89 ++++++++++++++++++++++ 2 files changed, 155 insertions(+), 48 deletions(-) diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index bacf31b..22aef22 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -13,10 +13,13 @@ from typing import Any import pytest -from _pytest.outcomes import fail +from _pytest.outcomes import Exit, fail from _pytest.runner import runtestprotocol from packaging.version import parse as parse_version +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + failed_subtests_key: Any SubtestReport: Any _failed_subtests_key: Any = None @@ -534,10 +537,6 @@ def _should_hard_fail_on_error(item, report, excinfo): if report.outcome != "failed": return False - return _is_terminal_error(item, excinfo) - - -def _is_terminal_error(item, excinfo): rerun_errors = _get_rerun_filter_regex(item, "only_rerun") rerun_except_errors = _get_rerun_filter_regex(item, "rerun_except") @@ -902,21 +901,65 @@ def _is_rerun_path_excluded(item): ) -def _suspend_finalizers_for_rerun(item): - """Hold back the higher-scope finalizers if the test is going to be re-run. +def _teardown_suspended_finalizers(item, call, report): + """Tear down the scopes held back for a re-run that will not happen. - Returns whether they were taken off the stack. + pytest_runtest_teardown takes the module, class and session finalizers off + the setup stack when it expects the test to be re-run. Whether the error a + teardown raised rules that re-run out is only known here, where the report + of the phase says how the error was classified, so put the finalizers back + and tear down whatever this item was the last user of. + + Returns the report of the teardown phase. """ + if not getattr(item, "_finalizers_suspended", False): + return report + + item._finalizers_suspended = False + _restore_suspended_finalizers(item) + + def teardown_higher_scopes(): + try: + item.session._setupstate.teardown_exact(item._teardown_nextitem) + except (Exit, KeyboardInterrupt): + # A fixture that ends the session has to keep ending it. + raise + except BaseException as exc: + if call.excinfo is None: + raise + # Both errors come from the teardown of this item, so report them + # together, the way pytest reports several failing finalizers. + raise BaseExceptionGroup( + "errors during test teardown", [exc, call.excinfo.value] + ) from None + + teardown_call = pytest.CallInfo.from_call( + teardown_higher_scopes, when="teardown", reraise=(Exit, KeyboardInterrupt) + ) + if teardown_call.excinfo is None: + return report + + # The report of the phase is already built, so the only way for the error + # above to reach the terminal is a report that replaces it. + return pytest.TestReport.from_item_and_call(item, teardown_call) + + +def pytest_runtest_teardown(item, nextitem): + # pytest_runtest_makereport needs both of these to finish a teardown this + # hook left half done because it expected a re-run. + item._finalizers_suspended = False + item._teardown_nextitem = nextitem + reruns = get_reruns_count(item) if reruns is None: # global setting is not specified, and this test is not marked with # flaky - return False + return if not hasattr(item, "execution_count"): # pytest_runtest_protocol hook of this plugin was not executed # -> teardown needs to be skipped as well - return False + return _test_failed_statuses = getattr(item, "_test_failed_statuses", {}) @@ -926,7 +969,7 @@ def _suspend_finalizers_for_rerun(item): and item.session.config.failures_db.get_suite_reruns() >= max_suite_reruns ): _restore_suspended_finalizers(item) - return False + return # Only remove non-function level actions from the stack if the test is to be re-run # Exceeding re-run limits, being free of failue statuses, encountering @@ -948,47 +991,17 @@ def _suspend_finalizers_for_rerun(item): if key not in suspended_finalizers: suspended_finalizers[key] = item.session._setupstate.stack[key] del item.session._setupstate.stack[key] - return True - - # restore suspended finalizers - _restore_suspended_finalizers(item) - return False - - -def _teardown_error_is_terminal(item, outcome): - """Report whether the teardown phase raised an error that stops re-runs.""" - exc_info = outcome.excinfo - if exc_info is None: - return False - - return _is_terminal_error(item, pytest.ExceptionInfo.from_exc_info(exc_info)) - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_teardown(item, nextitem): - suspended = _suspend_finalizers_for_rerun(item) - outcome = yield - - # The error a teardown raises is only classified once this hook has - # returned, so the decision above could not take it into account. A - # terminal one means there is no re-run to hold the finalizers back for, - # so put them back on the stack and tear down what this item was the last - # user of. - if suspended and _teardown_error_is_terminal(item, outcome): + item._finalizers_suspended = True + else: + # restore suspended finalizers _restore_suspended_finalizers(item) - try: - item.session._setupstate.teardown_exact(nextitem) - except BaseException as exc: - # The error raised above is the one that rules out a re-run, so it - # has to stay the error of this phase. Chain this one onto it - # instead of replacing it, so both end up in the report. - outcome.excinfo[1].__context__ = exc -@pytest.hookimpl(hookwrapper=True) +# A wrapper rather than an old-style hookwrapper because it has to be able to +# let an Exit or a KeyboardInterrupt out of _teardown_suspended_finalizers. +@pytest.hookimpl(wrapper=True) def pytest_runtest_makereport(item, call): - outcome = yield - result = outcome.get_result() + result = yield if result.when == "setup": # clean failed statuses at the beginning of each test/rerun setattr(item, "_test_failed_statuses", {}) @@ -1003,6 +1016,11 @@ def pytest_runtest_makereport(item, call): item, result, call.excinfo ) + if result.when == "teardown" and item._terminal_errors["teardown"]: + result = _teardown_suspended_finalizers(item, call, result) + + return result + def pytest_runtest_protocol(item, nextitem): """ diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 79998f9..c257bbf 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -1540,6 +1540,95 @@ def test_fail(broken_fixture): assert result.stdout.str().count("module teardown") == 1 +@pytest.mark.parametrize( + "outcome,skipped,xfailed", + [("skip", 3, 0), ("xfail", 0, 3)], +) +def test_teardown_error_that_is_not_a_failure_does_not_stop_reruns( + testdir, outcome, skipped, xfailed +): + testdir.makepyfile( + f""" + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + print("module teardown") + + @pytest.fixture + def not_failing_fixture(): + yield + pytest.{outcome}("{outcome} in teardown") + + @pytest.mark.flaky(reruns=2, only_rerun=["AssertionError"]) + def test_fail(not_failing_fixture): + assert False""" + ) + + result = testdir.runpytest("-s") + assert_outcomes( + result, passed=0, skipped=skipped, xfailed=xfailed, failed=1, rerun=2 + ) + assert result.stdout.str().count("module teardown") == 1 + + +def test_terminal_teardown_error_lets_a_higher_scope_teardown_exit(testdir): + testdir.makepyfile( + test_flaky_module=""" + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + pytest.exit("exit from teardown") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") + + @pytest.mark.flaky(reruns=2, rerun_except=["ValueError"]) + def test_fail(broken_fixture): + assert False""", + test_later_module=""" + def test_pass(): + print("later module test")""", + ) + + result = testdir.runpytest("-s") + assert result.ret == pytest.ExitCode.INTERRUPTED + result.stdout.fnmatch_lines("*Exit: exit from teardown*") + assert "later module test" not in result.stdout.str() + + +def test_terminal_teardown_error_reports_higher_scope_teardown_without_context(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + raise RuntimeError("module teardown error") + + @pytest.fixture + def broken_fixture(): + yield + raise ValueError("teardown error") from None + + @pytest.mark.flaky(reruns=2, rerun_except=["ValueError"]) + def test_fail(broken_fixture): + assert False""" + ) + + result = testdir.runpytest() + assert_outcomes(result, passed=0, failed=1, error=1, rerun=0) + result.stdout.fnmatch_lines( + ["*RuntimeError: module teardown error*", "*ValueError: teardown error*"], + ) + + @pytest.mark.parametrize( "marker_only_rerun,cli_only_rerun,should_rerun", [