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
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ This is useful in large test suites to bound resource usage when many tests
are flaky at the same time. The cap applies after rerun selection, including
tests configured with ``--force-reruns`` and ``@pytest.mark.flaky``.

Mark exhausted flaky failures as xfail
--------------------------------------

To keep flaky failures from failing the test run, pass ``--xfail-flaky``.
A test that still fails after all its re-runs is then reported as
``xfailed`` instead of ``failed``:

.. code-block:: bash

$ pytest --reruns 2 --xfail-flaky

Only tests that were actually re-run are affected. Failures excluded from
re-running by ``--only-rerun``/``--rerun-except`` still report as failed.

Show tracebacks for retried failures
------------------------------------

Expand Down
2 changes: 2 additions & 0 deletions changes/92.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``--xfail-flaky`` flag that reports a test as ``xfailed`` instead of
``failed`` when it still fails after all its re-runs are exhausted.
21 changes: 21 additions & 0 deletions src/pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ def pytest_addoption(parser):
dest="fail_on_flaky",
help="Fail the test run with exit code 7 if a flaky test passes on a rerun.",
)
group._addoption(
"--xfail-flaky",
action="store_true",
dest="xfail_flaky",
help="Mark tests that still fail after exhausting their reruns as "
"xfailed instead of failed. Only applies to tests that were actually "
"rerun, and not to failures excluded by --only-rerun/--rerun-except.",
)
group._addoption(
"--rerun-show-tracebacks",
action="store_true",
Expand Down Expand Up @@ -1324,6 +1332,19 @@ def pytest_runtest_protocol(item, nextitem):
# nonmatching failure as a final result first.
continue
elif _should_not_rerun(item, report, reruns, condition):
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Nit: this repeats checks that _should_not_rerun already does (execution_count > reruns, _terminal_errors) and adds another special case to the main protocol loop. A small helper such as _mark_exhausted_failure_as_xfail(item, report, reruns) would be easier to read. The same helper could then be called from the suite-cap branch and limited to the deciding report, which also fixes the findings above.

item.session.config.option.xfail_flaky
and report.failed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Teardown failures are converted too, so a test is counted twice and the teardown error is hidden. This check runs on every report: setup, call and teardown. A test whose call passes but whose teardown always fails ends up as passed + xfailed. A test whose call and teardown both fail ends up as 2 xfailed. I reproduced it with --reruns 1 --xfail-flaky on two such tests: the run printed 2 passed, 3 xfailed, and the teardown error disappeared with exit code 0. Should this only convert the report that decides the test's outcome, the call report or a failed setup, and leave teardown errors as errors? A test for the teardown case would also help.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Failures that come only from subtests are not converted. When a test fails only through subtests, the call report itself passed (report.failed is False). The failed subtest reports are logged by pytest's subtests plugin, not by this code. Reproduced with a test whose only subtest always fails, run with --reruns 1 --xfail-flaky: it still reports FAILED ... contains 1 failed subtest and the run fails. Either handle subtests or say in the docs that they are not covered.

and item.execution_count > reruns

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

--max-suite-reruns bypasses the conversion. When the suite-wide cap is used up, the final failure is logged in the else branch ("Suite-wide limit exhausted"), not here. A test that was rerun and then hit the cap is still reported as failed. Reproduced with two always-failing tests and --reruns 2 --max-suite-reruns 1 --xfail-flaky: 2 failed, 1 rerun. The README says "Only tests that were actually re-run are affected", but this test was rerun and was not affected. The README should either document this case, or the cap-exhausted path should apply the same conversion.

and item.execution_count > 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Under xdist, a crash on the last attempt is not converted. When a worker crashes, XDistHooks.pytest_handlecrashitem builds the report on the controller, and this code never runs. A flaky test that crashes the worker on its final attempt is still failed, while the same test failing normally becomes xfailed. The two paths behave differently.

and not any(item._terminal_errors.values())
):
# reruns are exhausted: report the final failure as xfail
report.outcome = "skipped"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Using this with --fail-on-flaky gives exit code 7 for a test that never passed. Before this change, a test that failed every time made the session exit with 1, and pytest_sessionfinish returned early. Now the session status is 0, and pytest_sessionfinish sees execution_count > 1 and sets exit code 7. That code means "a flaky test passed on a rerun". Reproduced with def test_fail(): assert False and --reruns 1 --xfail-flaky --fail-on-flaky: the output is 1 xfailed, 1 rerun, exit 7. Should --fail-on-flaky skip items whose final report was converted, or should the two options be rejected together?

report.wasxfail = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

The failure reason is lost. Before, the short summary showed FAILED nodeid - AssertionError: .... Now it shows only XFAIL nodeid - test failed after N rerun(s), marked as xfail, and the test no longer appears in the FAILURES section. That makes the remaining flaky failures hard to diagnose. Consider adding the crash message (report.longrepr.reprcrash.message, when available) to wasxfail.

f"test failed after {item.execution_count - 1} "
"rerun(s), marked as xfail"
)
# no rerun needed or one already triggered, log normally
item.ihook.pytest_runtest_logreport(report=report)
else:
Expand Down
60 changes: 60 additions & 0 deletions tests/test_pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,66 @@ def test_pass():
assert_outcomes(result, passed=1, rerun=2)


def test_xfail_flaky_marks_exhausted_reruns_as_xfail(testdir):
testdir.makepyfile("def test_fail(): assert False")
result = testdir.runpytest("--reruns", "2", "--xfail-flaky")
assert result.ret == 0
assert_outcomes(result, passed=0, failed=0, xfailed=1, rerun=2)


def test_xfail_flaky_does_not_affect_eventual_pass(testdir):
testdir.makepyfile(
f"""
def test_pass():
{temporary_failure()}"""
)
result = testdir.runpytest("--reruns", "2", "--xfail-flaky")
assert_outcomes(result, passed=1, rerun=1)


def test_xfail_flaky_does_not_affect_test_without_reruns(testdir):
testdir.makepyfile("def test_fail(): assert False")
result = testdir.runpytest("--xfail-flaky")
assert_outcomes(result, passed=0, failed=1, xfailed=0, rerun=0)


def test_xfail_flaky_ignores_unmatched_only_rerun_error(testdir):
testdir.makepyfile("def test_fail(): raise ValueError('nope')")
result = testdir.runpytest(
"--reruns", "2", "--only-rerun", "AssertionError", "--xfail-flaky"
)
assert_outcomes(result, passed=0, failed=1, xfailed=0, rerun=0)


def test_xfail_flaky_with_flaky_marker(testdir):
testdir.makepyfile(
"""
import pytest

@pytest.mark.flaky(reruns=1)
def test_fail(): assert False
"""
)
result = testdir.runpytest("--xfail-flaky")
assert result.ret == 0
assert_outcomes(result, passed=0, failed=0, xfailed=1, rerun=1)


def test_xfail_flaky_marks_exhausted_setup_failures_as_xfail(testdir):
testdir.makepyfile(
"""
import pytest

@pytest.fixture
def broken_fixture():
raise ValueError("setup always fails")

def test_fail(broken_fixture): pass
"""
)
result = testdir.runpytest("--reruns", "2", "--xfail-flaky")
assert result.ret == 0
assert_outcomes(result, passed=0, failed=0, xfailed=1, rerun=2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

— Comment created by Claude

Two blank lines are missing before def test_flaky_marker_with_zero_reruns_disables_rerun. The ruff-format pre-commit hook rewrites this, so the pre-commit check fails.

def test_flaky_marker_with_zero_reruns_disables_rerun(testdir):
testdir.makepyfile(
f"""
Expand Down
Loading