Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ Katarzyna Jachim
Katarzyna Król
Katerina Koukiou
Keri Volans
Kerim Sabic
Kevin C
Kevin Cox
Kevin Hierro Carrasco
Expand Down
1 change: 1 addition & 0 deletions changelog/14575.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/_pytest/cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down
76 changes: 76 additions & 0 deletions testing/test_cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading