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 changelog/13537.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix bug in which ExceptionGroup with only Skipped exceptions in teardown was not handled correctly and showed as error
74 changes: 67 additions & 7 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from io import StringIO
import os
from pprint import pprint
import sys
from typing import Any
from typing import cast
from typing import final
Expand All @@ -33,6 +34,11 @@
from _pytest.nodes import Item
from _pytest.outcomes import fail
from _pytest.outcomes import skip
from _pytest.outcomes import Skipped


if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup


if TYPE_CHECKING:
Expand Down Expand Up @@ -251,6 +257,50 @@ def _report_unserialization_failure(
raise RuntimeError(stream.getvalue())


def _format_failed_longrepr(
item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException]
):
if call.when == "call":
longrepr = item.repr_failure(excinfo)
else: # exception in setup or teardown
longrepr = item._repr_failure_py(
excinfo, style=item.config.getoption("tbstyle", "auto")
)
return longrepr


def _format_exception_group_all_skipped_longrepr(
item: Item,
excinfo: ExceptionInfo[BaseException],
exceptions: list[Skipped],
) -> tuple[str, int, str]:
r = excinfo._getreprcrash()
assert r is not None, (
"There should always be a traceback entry for skipping a test."
)
if any(getattr(skip, "_use_item_location", False) for skip in exceptions):
path, line = item.reportinfo()[:2]
assert line is not None
loc = (os.fspath(path), line + 1)
default_msg = "skipped"
# longrepr = (*loc, r.message)
else:
assert r is not None
loc = (str(r.path), r.lineno)
default_msg = r.message

# reason(s): order-preserving de-dupe, same fields as single-skip
msgs: list[str] = []
for exception in exceptions:
m = exception.msg or exception.args[0]
if m and m not in msgs:
msgs.append(m)

reason = "; ".join(msgs) if msgs else default_msg
longrepr = (*loc, reason)
return longrepr


@final
class TestReport(BaseReport):
"""Basic test report object (also used for setup and teardown calls if
Expand Down Expand Up @@ -368,17 +418,27 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
if excinfo.value._use_item_location:
path, line = item.reportinfo()[:2]
assert line is not None
longrepr = os.fspath(path), line + 1, r.message
longrepr = (os.fspath(path), line + 1, r.message)
else:
longrepr = (str(r.path), r.lineno, r.message)
elif isinstance(excinfo.value, BaseExceptionGroup):
value: BaseExceptionGroup = excinfo.value
if value.exceptions and any(
isinstance(exception, skip.Exception)
for exception in value.exceptions
):
outcome = "skipped"
skipped_exceptions = cast(list[Skipped], value.exceptions)
longrepr = _format_exception_group_all_skipped_longrepr(
item, excinfo, skipped_exceptions
)
else:
# fall through to your existing failure path
outcome = "failed"
longrepr = _format_failed_longrepr(item, call, excinfo)
else:
outcome = "failed"
if call.when == "call":
longrepr = item.repr_failure(excinfo)
else: # exception in setup or teardown
longrepr = item._repr_failure_py(
excinfo, style=item.config.getoption("tbstyle", "auto")
)
longrepr = _format_failed_longrepr(item, call, excinfo)
for rwhen, key, content in item._report_sections:
sections.append((f"Captured {key} {rwhen}", content))
return cls(
Expand Down
62 changes: 62 additions & 0 deletions testing/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,68 @@ def test_1(fixture_): timing.sleep(10)
loaded_report = TestReport._from_json(data)
assert loaded_report.stop - loaded_report.start == approx(report.duration)

@pytest.mark.parametrize("duplicate", [False, True])
def test_exception_group_with_only_skips(self, pytester: Pytester, duplicate: bool):
"""
Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown,
it is reported as a single skipped test, not as an error.
This is a regression test for issue #13537.
"""
reason = "A" if duplicate else "B"
pytester.makepyfile(
test_it=f"""
import pytest
@pytest.fixture
def fixA():
yield
pytest.skip(reason="A")
@pytest.fixture
def fixB():
yield
pytest.skip(reason="{reason}")
def test_skip(fixA, fixB):
assert True
"""
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1, skipped=1)
out = result.stdout.str()
# Both reasons should appear
assert "(A)" in out if duplicate else "(A; B)"
assert "ERROR at teardown" not in out

def test_exception_group_skips_use_item_location(self, pytester: Pytester):
"""
Regression for #13537:
If any skip inside an ExceptionGroup has _use_item_location=True,
the report location should point to the test item, not the fixture teardown.
"""
pytester.makepyfile(
test_it="""
import pytest
@pytest.fixture
def fix_item_loc():
yield
exc = pytest.skip.Exception("A")
exc._use_item_location = True
raise exc
@pytest.fixture
def fix_normal():
yield
raise pytest.skip.Exception("B")
def test_both(fix_item_loc, fix_normal):
assert True
"""
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1, skipped=1)

out = result.stdout.str()
# Both reasons should appear
assert "A" in out and "B" in out
# Crucially, the skip should be attributed to the test item, not teardown
assert "test_both" in out


class TestHooks:
"""Test that the hooks are working correctly for plugins"""
Expand Down