Skip to content
Merged
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 changes/357.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep module, class, and session scoped fixtures alive across re-runs of a test whose call phase failed through subtests only.
22 changes: 17 additions & 5 deletions src/pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,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.

Expand All @@ -496,7 +496,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

Expand Down Expand Up @@ -567,7 +567,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 (
Expand Down Expand Up @@ -1000,10 +1000,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
Comment thread
icemac marked this conversation as resolved.
)
and not any(item._test_xfailed.values())
and not any(item._terminal_errors.values())
and get_reruns_condition(item)
):
Expand Down Expand Up @@ -1035,12 +1040,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)
Expand Down
101 changes: 101 additions & 0 deletions tests/test_pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2545,6 +2545,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",
Expand Down
Loading