From 71806f6a2756582548cf0f3614e4bce53177ff0b Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Mon, 31 Aug 2026 08:09:59 -0400 Subject: [PATCH] Keep higher-scoped fixtures alive across re-runs of failed subtests --- changes/357.bugfix.rst | 1 + src/pytest_rerunfailures.py | 22 +++++-- tests/test_pytest_rerunfailures.py | 101 +++++++++++++++++++++++++++++ 3 files changed, 119 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 22aef22..e815214 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -484,7 +484,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. @@ -495,7 +495,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 @@ -566,7 +566,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 ( @@ -974,10 +974,15 @@ 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._test_xfailed.values()) and not any(item._terminal_errors.values()) and get_reruns_condition(item) ): @@ -1009,12 +1014,19 @@ def pytest_runtest_makereport(item, call): # create a dict to store error-check results for each stage setattr(item, "_terminal_errors", {}) + # create a dict to store xfail results for each stage + setattr(item, "_test_xfailed", {}) + _test_failed_statuses = getattr(item, "_test_failed_statuses", {}) _test_failed_statuses[result.when] = result.failed item._test_failed_statuses = _test_failed_statuses item._terminal_errors[result.when] = _should_hard_fail_on_error( item, result, call.excinfo ) + # subtests emit extra "call" reports, so accumulate rather than overwrite + item._test_xfailed[result.when] = item._test_xfailed.get( + result.when, False + ) or hasattr(result, "wasxfail") if result.when == "teardown" and item._terminal_errors["teardown"]: result = _teardown_suspended_finalizers(item, call, result) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index c257bbf..0dde9c4 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -2452,6 +2452,107 @@ 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_xfail_after_failing_subtest_restores_module_fixture(testdir): + testdir.makepyfile( + test_early_xfail_module=""" + import pytest + + @pytest.fixture(scope="module", autouse=True) + def xfail_module_fixture(): + yield + print("xfail module teardown") + + def test_xfail_after_subtest(subtests): + with subtests.test("Fails"): + assert False + pytest.xfail("known issue")""", + test_later_module=""" + def test_pass(): + print("later module test")""", + ) + + result = testdir.runpytest("-s", "--reruns", "2") + assert_outcomes(result, passed=1, failed=1, xfailed=1, rerun=0) + assert result.stdout.str().count("xfail module teardown") == 1 + result.stdout.fnmatch_lines( + ["*xfail 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",