Skip to content

Commit 22737e1

Browse files
authored
Merge branch 'master' into support-9.1
2 parents d3e130d + f095a51 commit 22737e1

3 files changed

Lines changed: 145 additions & 2 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ Features
1414

1515
- Add support for pytest 9.1.
1616

17+
- Rerun tests with failed subtests. This feature is only available on pytest 9.0
18+
and later. The pytest-subtests plugin is *not* supported.
19+
Fixes `#315 <https://github.com/pytest-dev/pytest-rerunfailures/issues/315>`_.
20+
1721

1822
16.3 (2026-05-22)
1923
-----------------

src/pytest_rerunfailures.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
from _pytest.runner import runtestprotocol
1717
from packaging.version import parse as parse_version
1818

19+
try:
20+
from _pytest.subtests import SubtestReport, failed_subtests_key
21+
except ImportError:
22+
if pytest.version_tuple >= (9, 0, 0):
23+
raise
24+
failed_subtests_key = None
25+
SubtestReport = None
26+
1927
try:
2028
from xdist.newhooks import pytest_handlecrashitem
2129

@@ -293,6 +301,95 @@ def _remove_failed_setup_state_from_session(item):
293301
del setup_state.stack[item]
294302

295303

304+
def _remove_failed_subtests_from_report(item, report):
305+
"""
306+
Clean up failed subtests stash entry.
307+
308+
Note: This function does nothing on pytest versions without subtests support.
309+
"""
310+
if failed_subtests_key is None:
311+
return
312+
313+
failed_subtests = item.config.stash.get(failed_subtests_key, None)
314+
if failed_subtests is not None and report.nodeid in failed_subtests:
315+
del failed_subtests[report.nodeid]
316+
317+
318+
def _remove_failed_subtest_reports_from_stats(item):
319+
"""
320+
Remove already-logged SubtestReports for this item from the terminal reporter's
321+
stats buckets.
322+
323+
SubtestReports are logged immediately during runtestprotocol (independent of
324+
log=False), so when a rerun is triggered they must be retroactively removed
325+
from all stat categories to avoid double-counting on the subsequent run.
326+
327+
Concretely:
328+
- Failed SubtestReports land in tr.stats["failed"].
329+
- Passed SubtestReports land in tr.stats["subtests passed"].
330+
Both must be removed so the final tally only reflects the last (successful) run.
331+
332+
Note: This function does nothing on pytest versions without subtests support.
333+
"""
334+
if SubtestReport is None:
335+
return
336+
337+
tr = item.config.pluginmanager.get_plugin("terminalreporter")
338+
if tr is None:
339+
return
340+
341+
def _remove_subtest_reports(key):
342+
"""
343+
Remove SubtestReports for item.nodeid from tr.stats[key].
344+
345+
Returns the number of removed reports, and deletes the key entirely when
346+
the list becomes empty, because some code just checks the presence of
347+
the 'failed' key, but doesn't check the content.
348+
"""
349+
if key not in tr.stats:
350+
return 0
351+
352+
num_items_before = len(tr.stats[key])
353+
tr.stats[key] = [
354+
r
355+
for r in tr.stats[key]
356+
if not isinstance(r, SubtestReport) or r.nodeid != item.nodeid
357+
]
358+
num_items_removed = num_items_before - len(tr.stats[key])
359+
360+
if not tr.stats[key]:
361+
del tr.stats[key]
362+
363+
return num_items_removed
364+
365+
failed_removed = _remove_subtest_reports("failed")
366+
if failed_removed > 0:
367+
# Decrement session.testsfailed which was incremented when the
368+
# SubtestReport was originally logged via pytest_runtest_logreport.
369+
item.session.testsfailed = max(0, item.session.testsfailed - failed_removed)
370+
371+
# When a test is rerun, subtests that already passed on the first attempt
372+
# will run again and produce a second SUBPASSED report. Remove the first
373+
# run's SUBPASSED entries so the count reflects each subtest exactly once.
374+
_remove_subtest_reports("subtests passed")
375+
376+
377+
def _get_num_failed_subtests(item, report):
378+
"""
379+
Return the number of failed subtests.
380+
381+
Note: Returns 0 on pytest versions without subtests support.
382+
"""
383+
if failed_subtests_key is None:
384+
return 0
385+
386+
failed_subtests = item.config.stash.get(failed_subtests_key, None)
387+
if failed_subtests is not None:
388+
return failed_subtests.get(report.nodeid, 0)
389+
390+
return 0
391+
392+
296393
def _get_rerun_filter_regex(item, regex_name):
297394
rerun_marker = _get_marker(item)
298395

@@ -358,9 +455,13 @@ def _should_not_rerun(item, report, reruns):
358455
xfail = hasattr(report, "wasxfail")
359456
is_terminal_error = item._terminal_errors[report.when]
360457
condition = get_reruns_condition(item)
458+
has_failed_subtests = (
459+
report.when == "call" and _get_num_failed_subtests(item, report) > 0
460+
)
461+
361462
return (
362463
item.execution_count > reruns
363-
or not report.failed
464+
or (not report.failed and not has_failed_subtests)
364465
or xfail
365466
or is_terminal_error
366467
or not condition
@@ -648,6 +749,8 @@ def pytest_runtest_protocol(item, nextitem):
648749
# cleanin item's cashed results from any level of setups
649750
_remove_cached_results_from_failed_fixtures(item)
650751
_remove_failed_setup_state_from_session(item)
752+
_remove_failed_subtests_from_report(item, report)
753+
_remove_failed_subtest_reports_from_stats(item)
651754

652755
break # trigger rerun
653756
else:

tests/test_pytest_rerunfailures.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import random
22
import time
3+
from textwrap import indent
34
from unittest import mock
45

56
import pytest
67

7-
from pytest_rerunfailures import HAS_PYTEST_HANDLECRASHITEM
8+
from pytest_rerunfailures import HAS_PYTEST_HANDLECRASHITEM, SubtestReport
89

910
pytest_plugins = "pytester"
1011

1112
has_xdist = HAS_PYTEST_HANDLECRASHITEM
13+
has_subtests = SubtestReport is not None
1214

1315

1416
def temporary_failure(count=1):
@@ -1526,3 +1528,37 @@ def test_pass():
15261528

15271529
result = testdir.runpytest("--reruns-mode", "bogus")
15281530
assert result.ret != 0
1531+
1532+
1533+
@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer")
1534+
def test_failing_subtests_are_rerun(testdir):
1535+
testdir.makepyfile(
1536+
f"""
1537+
import pytest
1538+
1539+
def test_subtests(subtests):
1540+
with subtests.test("Fails on first attempt"):
1541+
{indent(temporary_failure(), " ")}
1542+
"""
1543+
)
1544+
1545+
result = testdir.runpytest("--reruns", "1")
1546+
assert result.ret == 0
1547+
assert_outcomes(result, passed=1, rerun=1)
1548+
1549+
1550+
@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer")
1551+
def test_too_many_failing_subtests_are_failures(testdir):
1552+
testdir.makepyfile(
1553+
"""
1554+
import pytest
1555+
1556+
def test_subtests(subtests):
1557+
with subtests.test("Always fails"):
1558+
assert False
1559+
"""
1560+
)
1561+
1562+
result = testdir.runpytest("--reruns", "1")
1563+
assert result.ret != 0
1564+
assert_outcomes(result, passed=0, failed=2, rerun=1)

0 commit comments

Comments
 (0)