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 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.
72 changes: 65 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 @@ -35,6 +36,10 @@
from _pytest.outcomes import skip


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


if TYPE_CHECKING:
from typing_extensions import Self

Expand Down Expand Up @@ -251,6 +256,52 @@ 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[BaseExceptionGroup[BaseException | BaseExceptionGroup]],
) -> tuple[str, int, str]:
r = excinfo._getreprcrash()
assert r is not None, (
"There should always be a traceback entry for skipping a test."
)
if all(
getattr(skip, "_use_item_location", False) for skip in excinfo.value.exceptions
):
path, line = item.reportinfo()[:2]
assert line is not None
loc = (os.fspath(path), line + 1)
default_msg = "skipped"
else:
loc = (str(r.path), r.lineno)
default_msg = r.message

# Get all unique skip messages.
msgs: list[str] = []
for exception in excinfo.value.exceptions:
m = getattr(exception, "msg", None) or (
exception.args[0] if exception.args else None
)
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 +419,24 @@ 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) and (
excinfo.value.split(skip.Exception)[1] is None
):
# All exceptions in the group are skip exceptions.
outcome = "skipped"
excinfo = cast(
ExceptionInfo[
BaseExceptionGroup[BaseException | BaseExceptionGroup]
],
excinfo,
)
longrepr = _format_exception_group_all_skipped_longrepr(item, 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
77 changes: 77 additions & 0 deletions testing/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,83 @@ 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(
"first_skip_reason, second_skip_reason, skip_reason_output",
[("A", "B", "(A; B)"), ("A", "A", "(A)")],
)
def test_exception_group_with_only_skips(
self,
pytester: Pytester,
first_skip_reason: str,
second_skip_reason: str,
skip_reason_output: str,
):
"""
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.
"""
pytester.makepyfile(
test_it=f"""
import pytest
@pytest.fixture
def fixA():
yield
pytest.skip(reason="{first_skip_reason}")
@pytest.fixture
def fixB():
yield
pytest.skip(reason="{second_skip_reason}")
def test_skip(fixA, fixB):
assert True
"""
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1, skipped=1)
out = result.stdout.str()
assert skip_reason_output in out
assert "ERROR at teardown" not in out

@pytest.mark.parametrize(
"use_item_location, skip_file_location",
[(True, "test_it.py"), (False, "runner.py")],
)
def test_exception_group_skips_use_item_location(
self, pytester: Pytester, use_item_location: bool, skip_file_location: str
):
"""
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=f"""
import pytest
@pytest.fixture
def fix_item1():
yield
exc = pytest.skip.Exception("A")
exc._use_item_location = True
raise exc
@pytest.fixture
def fix_item2():
yield
exc = pytest.skip.Exception("B")
exc._use_item_location = {use_item_location}
raise exc
def test_both(fix_item1, fix_item2):
assert True
"""
)
result = pytester.runpytest("-rs")
result.assert_outcomes(passed=1, skipped=1)

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


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