Skip to content

Commit 2d37732

Browse files
committed
fix #13537: Add support for ExceptionGroup with only Skipped exceptions in teardown
1 parent c97a401 commit 2d37732

File tree

3 files changed

+137
-7
lines changed

3 files changed

+137
-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
@@ -35,6 +36,10 @@
3536
from _pytest.outcomes import skip
3637

3738

39+
if sys.version_info < (3, 11):
40+
from exceptiongroup import BaseExceptionGroup
41+
42+
3843
if TYPE_CHECKING:
3944
from typing_extensions import Self
4045

@@ -251,6 +256,50 @@ def _report_unserialization_failure(
251256
raise RuntimeError(stream.getvalue())
252257

253258

259+
def _format_failed_longrepr(
260+
item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException]
261+
):
262+
if call.when == "call":
263+
longrepr = item.repr_failure(excinfo)
264+
else: # exception in setup or teardown
265+
longrepr = item._repr_failure_py(
266+
excinfo, style=item.config.getoption("tbstyle", "auto")
267+
)
268+
return longrepr
269+
270+
271+
def _format_exception_group_all_skipped_longrepr(
272+
item: Item,
273+
excinfo: ExceptionInfo[BaseException],
274+
exceptions: Sequence[BaseException],
275+
) -> tuple[str, int, str]:
276+
r = excinfo._getreprcrash()
277+
assert r is not None, (
278+
"There should always be a traceback entry for skipping a test."
279+
)
280+
if any(getattr(skip, "_use_item_location", False) for skip in exceptions):
281+
path, line = item.reportinfo()[:2]
282+
assert line is not None
283+
loc = (os.fspath(path), line + 1)
284+
default_msg = "skipped"
285+
else:
286+
loc = (str(r.path), r.lineno)
287+
default_msg = r.message
288+
289+
# Get all unique skip messages.
290+
msgs: list[str] = []
291+
for exception in exceptions:
292+
m = getattr(exception, "msg", None) or (
293+
exception.args[0] if exception.args else None
294+
)
295+
if m and m not in msgs:
296+
msgs.append(m)
297+
298+
reason = "; ".join(msgs) if msgs else default_msg
299+
longrepr = (*loc, reason)
300+
return longrepr
301+
302+
254303
@final
255304
class TestReport(BaseReport):
256305
"""Basic test report object (also used for setup and teardown calls if
@@ -368,17 +417,28 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
368417
if excinfo.value._use_item_location:
369418
path, line = item.reportinfo()[:2]
370419
assert line is not None
371-
longrepr = os.fspath(path), line + 1, r.message
420+
longrepr = (os.fspath(path), line + 1, r.message)
372421
else:
373422
longrepr = (str(r.path), r.lineno, r.message)
423+
elif isinstance(excinfo.value, BaseExceptionGroup):
424+
if excinfo.value.exceptions and any(
425+
isinstance(exception, skip.Exception)
426+
for exception in excinfo.value.exceptions
427+
):
428+
outcome = "skipped"
429+
skipped_exceptions = cast(
430+
Sequence[BaseException], excinfo.value.exceptions
431+
)
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,75 @@ 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(
438+
"first_skip_reason, second_skip_reason, skip_reason_output",
439+
[("A", "B", "(A; B)"), ("A", "A", "(A)")],
440+
)
441+
def test_exception_group_with_only_skips(
442+
self,
443+
pytester: Pytester,
444+
first_skip_reason: str,
445+
second_skip_reason: str,
446+
skip_reason_output: str,
447+
):
448+
"""
449+
Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown,
450+
it is reported as a single skipped test, not as an error.
451+
This is a regression test for issue #13537.
452+
"""
453+
pytester.makepyfile(
454+
test_it=f"""
455+
import pytest
456+
@pytest.fixture
457+
def fixA():
458+
yield
459+
pytest.skip(reason="{first_skip_reason}")
460+
@pytest.fixture
461+
def fixB():
462+
yield
463+
pytest.skip(reason="{second_skip_reason}")
464+
def test_skip(fixA, fixB):
465+
assert True
466+
"""
467+
)
468+
result = pytester.runpytest("-v")
469+
result.assert_outcomes(passed=1, skipped=1)
470+
out = result.stdout.str()
471+
assert skip_reason_output in out
472+
assert "ERROR at teardown" not in out
473+
474+
def test_exception_group_skips_use_item_location(self, pytester: Pytester):
475+
"""
476+
Regression for #13537:
477+
If any skip inside an ExceptionGroup has _use_item_location=True,
478+
the report location should point to the test item, not the fixture teardown.
479+
"""
480+
pytester.makepyfile(
481+
test_it="""
482+
import pytest
483+
@pytest.fixture
484+
def fix_item_loc():
485+
yield
486+
exc = pytest.skip.Exception("A")
487+
exc._use_item_location = True
488+
raise exc
489+
@pytest.fixture
490+
def fix_normal():
491+
yield
492+
raise pytest.skip.Exception("B")
493+
def test_both(fix_item_loc, fix_normal):
494+
assert True
495+
"""
496+
)
497+
result = pytester.runpytest("-v")
498+
result.assert_outcomes(passed=1, skipped=1)
499+
500+
out = result.stdout.str()
501+
# Both reasons should appear
502+
assert "A" in out and "B" in out
503+
# Crucially, the skip should be attributed to the test item, not teardown
504+
assert "test_both" in out
505+
437506

438507
class TestHooks:
439508
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)