Skip to content

Commit ed58491

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

File tree

3 files changed

+91
-7
lines changed

3 files changed

+91
-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: 63 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,46 @@ 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+
if any(getattr(skip, "_use_item_location", False) for skip in exceptions):
278+
path, line = item.reportinfo()[:2]
279+
assert line is not None
280+
loc = (os.fspath(path), line + 1)
281+
default_msg = "skipped"
282+
else:
283+
r = excinfo._getreprcrash()
284+
assert r is not None
285+
loc = (str(r.path), r.lineno)
286+
default_msg = r.message
287+
288+
# reason(s): order-preserving de-dupe, same fields as single-skip
289+
msgs: list[str] = []
290+
for exception in exceptions:
291+
m = exception.msg or exception.args[0]
292+
if m and m not in msgs:
293+
msgs.append(m)
294+
295+
reason = "; ".join(msgs) if msgs else default_msg
296+
longrepr = (*loc, reason)
297+
return longrepr
298+
299+
254300
@final
255301
class TestReport(BaseReport):
256302
"""Basic test report object (also used for setup and teardown calls if
@@ -368,17 +414,27 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
368414
if excinfo.value._use_item_location:
369415
path, line = item.reportinfo()[:2]
370416
assert line is not None
371-
longrepr = os.fspath(path), line + 1, r.message
417+
longrepr = (os.fspath(path), line + 1, r.message)
372418
else:
373419
longrepr = (str(r.path), r.lineno, r.message)
420+
elif isinstance(excinfo.value, BaseExceptionGroup):
421+
value: BaseExceptionGroup = excinfo.value
422+
if value.exceptions and all(
423+
isinstance(exception, skip.Exception)
424+
for exception in value.exceptions
425+
):
426+
outcome = "skipped"
427+
skipped_exceptions = cast(list[Skipped], value.exceptions)
428+
longrepr = _format_exception_group_all_skipped_longrepr(
429+
item, excinfo, skipped_exceptions
430+
)
431+
else:
432+
# fall through to your existing failure path
433+
outcome = "failed"
434+
longrepr = _format_failed_longrepr(item, call, excinfo)
374435
else:
375436
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-
)
437+
longrepr = _format_failed_longrepr(item, call, excinfo)
382438
for rwhen, key, content in item._report_sections:
383439
sections.append((f"Captured {key} {rwhen}", content))
384440
return cls(

testing/test_reports.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,33 @@ 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="A")
454+
def test_skip(
455+
fixA,
456+
fixB
457+
):
458+
assert True
459+
"""
460+
)
461+
result = pytester.runpytest("-vv")
462+
result.assert_outcomes(passed=1, skipped=1)
463+
437464

438465
class TestHooks:
439466
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)