Skip to content

Commit 0380548

Browse files
committed
fix #13537: Add support for ExceptionGroup with only Skipped exceptions in teardown
1 parent 4abfdc5 commit 0380548

File tree

3 files changed

+135
-7
lines changed

3 files changed

+135
-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 all(
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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,73 @@ 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+
def test_exception_group_with_only_skips(self, pytester: Pytester):
438+
"""
439+
Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown,
440+
it is reported as a single skipped test, not as an error.
441+
This is a regression test for issue #13537.
442+
"""
443+
pytester.makepyfile(
444+
test_it="""
445+
import pytest
446+
@pytest.fixture
447+
def fixA():
448+
yield
449+
pytest.skip(reason="A")
450+
@pytest.fixture
451+
def fixB():
452+
yield
453+
pytest.skip(reason="B")
454+
def test_skip(
455+
fixA,
456+
fixB
457+
):
458+
assert True
459+
"""
460+
)
461+
result = pytester.runpytest("-v")
462+
result.assert_outcomes(passed=1, skipped=1)
463+
out = result.stdout.str()
464+
# Both reasons should appear
465+
assert "A" in out and "B" in out
466+
assert "ERROR at teardown" not in out
467+
468+
def test_exception_group_skips_use_item_location(self, pytester: Pytester):
469+
"""
470+
Regression for #13537:
471+
If any skip inside an ExceptionGroup has _use_item_location=True,
472+
the report location should point to the test item, not the fixture teardown.
473+
"""
474+
pytester.makepyfile(
475+
test_it="""
476+
import pytest
477+
478+
@pytest.fixture
479+
def fix_item_loc():
480+
yield
481+
# Create skip.Exception and set flag to use item location
482+
exc = pytest.skip.Exception("A")
483+
exc._use_item_location = True
484+
raise exc
485+
486+
@pytest.fixture
487+
def fix_normal():
488+
yield
489+
raise pytest.skip.Exception("B")
490+
491+
def test_both(fix_item_loc, fix_normal):
492+
assert True
493+
"""
494+
)
495+
result = pytester.runpytest("-v")
496+
result.assert_outcomes(passed=1, skipped=1)
497+
498+
out = result.stdout.str()
499+
# Both reasons should appear
500+
assert "A" in out and "B" in out
501+
# Crucially, the skip should be attributed to the test item, not teardown
502+
assert "test_both" in out
503+
437504

438505
class TestHooks:
439506
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)