Skip to content

Commit 959a89f

Browse files
committed
fix #13537: Add support for ExceptionGroup with only Skipped exceptions in teardown
1 parent a6ae78b commit 959a89f

File tree

3 files changed

+130
-7
lines changed

3 files changed

+130
-7
lines changed

changelog/13537.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug in which ExceptionGroup with only Skipped exceptions in teardown was not handled correctly and showed as error

src/_pytest/reports.py

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from io import StringIO
1010
import os
1111
from pprint import pprint
12+
import sys
1213
from typing import Any
1314
from typing import cast
1415
from typing import final
@@ -33,6 +34,11 @@
3334
from _pytest.nodes import Item
3435
from _pytest.outcomes import fail
3536
from _pytest.outcomes import skip
37+
from _pytest.outcomes import Skipped
38+
39+
40+
if sys.version_info < (3, 11):
41+
from exceptiongroup import BaseExceptionGroup
3642

3743

3844
if TYPE_CHECKING:
@@ -251,6 +257,50 @@ def _report_unserialization_failure(
251257
raise RuntimeError(stream.getvalue())
252258

253259

260+
def _format_failed_longrepr(
261+
item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException]
262+
):
263+
if call.when == "call":
264+
longrepr = item.repr_failure(excinfo)
265+
else: # exception in setup or teardown
266+
longrepr = item._repr_failure_py(
267+
excinfo, style=item.config.getoption("tbstyle", "auto")
268+
)
269+
return longrepr
270+
271+
272+
def _format_exception_group_all_skipped_longrepr(
273+
item: Item,
274+
excinfo: ExceptionInfo[BaseException],
275+
exceptions: list[Skipped],
276+
) -> tuple[str, int, str]:
277+
r = excinfo._getreprcrash()
278+
assert r is not None, (
279+
"There should always be a traceback entry for skipping a test."
280+
)
281+
if any(getattr(skip, "_use_item_location", False) for skip in exceptions):
282+
path, line = item.reportinfo()[:2]
283+
assert line is not None
284+
loc = (os.fspath(path), line + 1)
285+
default_msg = "skipped"
286+
# longrepr = (*loc, r.message)
287+
else:
288+
assert r is not None
289+
loc = (str(r.path), r.lineno)
290+
default_msg = r.message
291+
292+
# reason(s): order-preserving de-dupe, same fields as single-skip
293+
msgs: list[str] = []
294+
for exception in exceptions:
295+
m = exception.msg or exception.args[0]
296+
if m and m not in msgs:
297+
msgs.append(m)
298+
299+
reason = "; ".join(msgs) if msgs else default_msg
300+
longrepr = (*loc, reason)
301+
return longrepr
302+
303+
254304
@final
255305
class TestReport(BaseReport):
256306
"""Basic test report object (also used for setup and teardown calls if
@@ -368,17 +418,27 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
368418
if excinfo.value._use_item_location:
369419
path, line = item.reportinfo()[:2]
370420
assert line is not None
371-
longrepr = os.fspath(path), line + 1, r.message
421+
longrepr = (os.fspath(path), line + 1, r.message)
372422
else:
373423
longrepr = (str(r.path), r.lineno, r.message)
424+
elif isinstance(excinfo.value, BaseExceptionGroup):
425+
value: BaseExceptionGroup = excinfo.value
426+
if value.exceptions and any(
427+
isinstance(exception, skip.Exception)
428+
for exception in value.exceptions
429+
):
430+
outcome = "skipped"
431+
skipped_exceptions = cast(list[Skipped], value.exceptions)
432+
longrepr = _format_exception_group_all_skipped_longrepr(
433+
item, excinfo, skipped_exceptions
434+
)
435+
else:
436+
# fall through to your existing failure path
437+
outcome = "failed"
438+
longrepr = _format_failed_longrepr(item, call, excinfo)
374439
else:
375440
outcome = "failed"
376-
if call.when == "call":
377-
longrepr = item.repr_failure(excinfo)
378-
else: # exception in setup or teardown
379-
longrepr = item._repr_failure_py(
380-
excinfo, style=item.config.getoption("tbstyle", "auto")
381-
)
441+
longrepr = _format_failed_longrepr(item, call, excinfo)
382442
for rwhen, key, content in item._report_sections:
383443
sections.append((f"Captured {key} {rwhen}", content))
384444
return cls(

testing/test_reports.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,68 @@ def test_1(fixture_): timing.sleep(10)
434434
loaded_report = TestReport._from_json(data)
435435
assert loaded_report.stop - loaded_report.start == approx(report.duration)
436436

437+
@pytest.mark.parametrize("duplicate", [False, True])
438+
def test_exception_group_with_only_skips(self, pytester: Pytester, duplicate: bool):
439+
"""
440+
Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown,
441+
it is reported as a single skipped test, not as an error.
442+
This is a regression test for issue #13537.
443+
"""
444+
reason = "A" if duplicate else "B"
445+
pytester.makepyfile(
446+
test_it=f"""
447+
import pytest
448+
@pytest.fixture
449+
def fixA():
450+
yield
451+
pytest.skip(reason="A")
452+
@pytest.fixture
453+
def fixB():
454+
yield
455+
pytest.skip(reason="{reason}")
456+
def test_skip(fixA, fixB):
457+
assert True
458+
"""
459+
)
460+
result = pytester.runpytest("-v")
461+
result.assert_outcomes(passed=1, skipped=1)
462+
out = result.stdout.str()
463+
# Both reasons should appear
464+
assert "(A)" in out if duplicate else "(A; B)"
465+
assert "ERROR at teardown" not in out
466+
467+
def test_exception_group_skips_use_item_location(self, pytester: Pytester):
468+
"""
469+
Regression for #13537:
470+
If any skip inside an ExceptionGroup has _use_item_location=True,
471+
the report location should point to the test item, not the fixture teardown.
472+
"""
473+
pytester.makepyfile(
474+
test_it="""
475+
import pytest
476+
@pytest.fixture
477+
def fix_item_loc():
478+
yield
479+
exc = pytest.skip.Exception("A")
480+
exc._use_item_location = True
481+
raise exc
482+
@pytest.fixture
483+
def fix_normal():
484+
yield
485+
raise pytest.skip.Exception("B")
486+
def test_both(fix_item_loc, fix_normal):
487+
assert True
488+
"""
489+
)
490+
result = pytester.runpytest("-v")
491+
result.assert_outcomes(passed=1, skipped=1)
492+
493+
out = result.stdout.str()
494+
# Both reasons should appear
495+
assert "A" in out and "B" in out
496+
# Crucially, the skip should be attributed to the test item, not teardown
497+
assert "test_both" in out
498+
437499

438500
class TestHooks:
439501
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)