From eb3c4f6dc3c435e84a604a3490258678b992576b Mon Sep 17 00:00:00 2001 From: Kerim Sabic Date: Mon, 12 Oct 2026 23:27:11 +0200 Subject: [PATCH] Fix last-failed cache for unittest subtest failures AI assistance: Codex helped investigate, implement, and validate this change. Kerim Sabic confirmed an in-depth review before upstream submission. --- AUTHORS | 1 + changelog/14575.bugfix.rst | 1 + src/_pytest/cacheprovider.py | 13 ++++++ testing/test_cacheprovider.py | 76 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 changelog/14575.bugfix.rst diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..8e1f0423c6a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -270,6 +270,7 @@ Katarzyna Jachim Katarzyna Król Katerina Koukiou Keri Volans +Kerim Sabic Kevin C Kevin Cox Kevin Hierro Carrasco diff --git a/changelog/14575.bugfix.rst b/changelog/14575.bugfix.rst new file mode 100644 index 00000000000..658240a266b --- /dev/null +++ b/changelog/14575.bugfix.rst @@ -0,0 +1 @@ +Tests with failed ``unittest.TestCase.subTest`` checks are retained in the last-failed cache and rerun by ``--last-failed``, even when later subtests or the parent test pass. diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 5d4a528077d..2c7d51111b0 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -33,6 +33,7 @@ from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.reports import TestReport +from _pytest.subtests import SubtestReport CACHEDIR_FILES: dict[str, bytes] = { @@ -336,6 +337,7 @@ def __init__(self, config: Config) -> None: self._previously_failed_count: int | None = None self._report_status: str | None = None self._skipped_files = 0 # count skipped files during collection due to --lf + self._failed_subtests: set[NodeId] = set() if config.getoption("lf"): self._last_failed_paths = self.get_last_failed_paths() @@ -360,6 +362,17 @@ def pytest_report_collectionfinish(self) -> str | None: return None def pytest_runtest_logreport(self, report: TestReport) -> None: + if isinstance(report, SubtestReport): + if report.failed: + self.lastfailed[report.id] = True + self._failed_subtests.add(report.id) + return + + if report.when == "call" and report.id in self._failed_subtests: + # A passing parent report must not erase its failed subtests. + self._failed_subtests.remove(report.id) + return + if (report.when == "call" and report.passed) or report.skipped: self.lastfailed.pop(report.id, None) elif report.failed: diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index a38a72c6011..0e17c627bed 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -366,6 +366,82 @@ def pytest_configure(config): class TestLastFailed: + @pytest.mark.parametrize( + "outcomes", + [ + ("failed", "passed"), + ("failed", "skipped"), + ("passed", "failed"), + ("failed", "parent-skipped"), + ("error", "passed"), + ], + ) + @pytest.mark.parametrize("terminal", [True, False]) + def test_unittest_subtest_failures( + self, pytester: Pytester, outcomes: tuple[str, str], terminal: bool + ) -> None: + source = f""" + import unittest + + class TestSubtests(unittest.TestCase): + def test_subtests(self): + for outcome in {outcomes!r}: + if outcome == "parent-skipped": + self.skipTest("parent skipped") + with self.subTest(outcome=outcome): + if outcome == "failed": + self.fail("subtest failed") + if outcome == "error": + raise RuntimeError("subtest error") + if outcome == "skipped": + self.skipTest("subtest skipped") + """ + pytester.makepyfile(test_unittest_subtest_lastfailed=source) + args = [] if terminal else ["-p", "no:terminal"] + failed_nodeid = ( + "test_unittest_subtest_lastfailed.py::TestSubtests::test_subtests" + ) + + result = pytester.runpytest(*args) + assert result.ret == ExitCode.TESTS_FAILED + assert self.get_cached_last_failed(pytester) == [failed_nodeid] + + result = pytester.runpytest(*args, "--last-failed", "--lfnf=none") + assert result.ret == ExitCode.TESTS_FAILED + assert self.get_cached_last_failed(pytester) == [failed_nodeid] + + # Once all subtests pass or skip, the parent must leave the cache. + fixed_outcomes = tuple( + "passed" if x in ("failed", "error") else x for x in outcomes + ) + pytester.makepyfile( + test_unittest_subtest_lastfailed=source.replace( + repr(outcomes), repr(fixed_outcomes) + ) + ) + result = pytester.runpytest(*args, "--last-failed", "--lfnf=none") + assert result.ret == ExitCode.OK + assert self.get_cached_last_failed(pytester) == [] + + def test_repeated_unittest_subtest_item(self, pytester: Pytester) -> None: + path = pytester.makepyfile( + """ + import unittest + + class TestSubtests(unittest.TestCase): + runs = 0 + + def test_subtests(self): + type(self).runs += 1 + with self.subTest(): + self.assertEqual(type(self).runs, 2) + """ + ) + result = pytester.runpytest(path, path, "--keepduplicates") + assert result.ret == ExitCode.TESTS_FAILED + # The first run failed, but the same node ID subsequently passed. + assert self.get_cached_last_failed(pytester) == [] + def test_lastfailed_usecase( self, pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: