From 5e87576ee193abf927b5ff42b7576a63e473768c Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Thu, 27 Aug 2026 08:48:56 -0400 Subject: [PATCH] Keep higher-scoped fixtures alive across re-runs of failed subtests A call phase that only has failed subtests leaves the call report itself passing, so the failure statuses pytest_runtest_teardown looks at are all false. The hook then treats the test as finished and lets the module, class and session finalizers run, while the protocol goes on to re-run the test. The higher-scoped fixtures are torn down and set up again on every attempt. Count the failed subtests in that check, the same way the re-run decision already does. --- changes/357.bugfix.rst | 1 + src/pytest_rerunfailures.py | 14 ++++-- tests/test_pytest_rerunfailures.py | 73 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 changes/357.bugfix.rst diff --git a/changes/357.bugfix.rst b/changes/357.bugfix.rst new file mode 100644 index 0000000..9ae8325 --- /dev/null +++ b/changes/357.bugfix.rst @@ -0,0 +1 @@ +Keep module, class, and session scoped fixtures alive across re-runs of a test whose call phase failed through subtests only. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index b294296..8766412 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -481,7 +481,7 @@ def is_matching_subtest_report(report): _remove_subtest_reports("subtests passed") -def _get_num_failed_subtests(item, report): +def _get_num_failed_subtests(item, nodeid): """ Return the number of failed subtests. @@ -492,7 +492,7 @@ def _get_num_failed_subtests(item, report): failed_subtests = item.config.stash.get(failed_subtests_key, None) if failed_subtests is not None: - return failed_subtests.get(report.nodeid, 0) + return failed_subtests.get(nodeid, 0) return 0 @@ -563,7 +563,7 @@ def _should_not_rerun(item, report, reruns): is_terminal_error = any(item._terminal_errors.values()) condition = get_reruns_condition(item) has_failed_subtests = ( - report.when == "call" and _get_num_failed_subtests(item, report) > 0 + report.when == "call" and _get_num_failed_subtests(item, report.nodeid) > 0 ) return ( @@ -923,10 +923,14 @@ def pytest_runtest_teardown(item, nextitem): # 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 # allowable exceptions, and a falsy flaky condition indicate that the test is - # not to be re-ran. + # not to be re-ran. A failure can also be carried by failed subtests alone, + # which leaves the call phase itself passing. if ( item.execution_count <= reruns - and any(_test_failed_statuses.values()) + and ( + any(_test_failed_statuses.values()) + or _get_num_failed_subtests(item, item.nodeid) > 0 + ) and not any(item._terminal_errors.values()) and get_reruns_condition(item) ): diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index c09e3f1..0b38f3f 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -2254,6 +2254,79 @@ def test_subtests(subtests): assert_outcomes(result, passed=0, failed=2, rerun=1) +@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") +@pytest.mark.parametrize("scope", ["class", "module", "session"]) +def test_failing_subtests_keep_higher_scope_fixture_alive(testdir, scope): + testdir.makepyfile( + f""" + import pytest + + @pytest.fixture(scope="{scope}", autouse=True) + def higher_scope_fixture(): + yield + print("{scope} teardown") + + class TestSubtests: + def test_subtests(self, subtests): + with subtests.test("Fails on first attempt"): + {indent(temporary_failure(), " ")} + """ + ) + + result = testdir.runpytest("-s", "--reruns", "1") + assert_outcomes(result, passed=1, rerun=1) + assert result.stdout.str().count(f"{scope} teardown") == 1 + + +@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") +def test_failing_subtests_keep_earlier_module_fixture_alive(testdir): + testdir.makepyfile( + test_flaky_subtests_module=f""" + import pytest + + @pytest.fixture(scope="module", autouse=True) + def subtests_module_fixture(): + yield + print("subtests module teardown") + + def test_subtests(subtests): + with subtests.test("Fails on first attempt"): + {indent(temporary_failure(), " ")}""", + test_later_module=""" + def test_pass(): + print("later module test")""", + ) + + result = testdir.runpytest("-s", "--reruns", "1") + assert_outcomes(result, passed=2, rerun=1) + assert result.stdout.str().count("subtests module teardown") == 1 + result.stdout.fnmatch_lines( + ["*subtests module teardown*", "*later module test*"], + ) + + +@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") +def test_too_many_failing_subtests_tear_down_module_fixture_once(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(scope="module", autouse=True) + def module_fixture(): + yield + print("module teardown") + + def test_subtests(subtests): + with subtests.test("Always fails"): + assert False + """ + ) + + result = testdir.runpytest("-s", "--reruns", "1") + assert_outcomes(result, passed=0, failed=2, rerun=1) + assert result.stdout.str().count("module teardown") == 1 + + @pytest.mark.skipif( not has_subtests or not has_xdist, reason="Requires pytest 9.0 or newer and xdist",