Skip to content

Commit 6ed264c

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

File tree

3 files changed

+140
-7
lines changed

3 files changed

+140
-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: 69 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,52 @@ 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+
# longrepr = (*loc, r.message)
286+
else:
287+
assert r is not None
288+
loc = (str(r.path), r.lineno)
289+
default_msg = r.message
290+
291+
# reason(s): order-preserving de-dupe, same fields as single-skip
292+
msgs: list[str] = []
293+
for exception in exceptions:
294+
m = getattr(exception, "msg", None) or (
295+
exception.args[0] if exception.args else None
296+
)
297+
if m and m not in msgs:
298+
msgs.append(m)
299+
300+
reason = "; ".join(msgs) if msgs else default_msg
301+
longrepr = (*loc, reason)
302+
return longrepr
303+
304+
254305
@final
255306
class TestReport(BaseReport):
256307
"""Basic test report object (also used for setup and teardown calls if
@@ -368,17 +419,28 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport:
368419
if excinfo.value._use_item_location:
369420
path, line = item.reportinfo()[:2]
370421
assert line is not None
371-
longrepr = os.fspath(path), line + 1, r.message
422+
longrepr = (os.fspath(path), line + 1, r.message)
372423
else:
373424
longrepr = (str(r.path), r.lineno, r.message)
425+
elif isinstance(excinfo.value, BaseExceptionGroup):
426+
if excinfo.value.exceptions and any(
427+
isinstance(exception, skip.Exception)
428+
for exception in excinfo.value.exceptions
429+
):
430+
outcome = "skipped"
431+
skipped_exceptions = cast(
432+
Sequence[BaseException], excinfo.value.exceptions
433+
)
434+
longrepr = _format_exception_group_all_skipped_longrepr(
435+
item, excinfo, skipped_exceptions
436+
)
437+
else:
438+
# fall through to your existing failure path
439+
outcome = "failed"
440+
longrepr = _format_failed_longrepr(item, call, excinfo)
374441
else:
375442
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-
)
443+
longrepr = _format_failed_longrepr(item, call, excinfo)
382444
for rwhen, key, content in item._report_sections:
383445
sections.append((f"Captured {key} {rwhen}", content))
384446
return cls(

testing/test_reports.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,76 @@ 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+
# Both reasons should appear
472+
assert skip_reason_output in out
473+
assert "ERROR at teardown" not in out
474+
475+
def test_exception_group_skips_use_item_location(self, pytester: Pytester):
476+
"""
477+
Regression for #13537:
478+
If any skip inside an ExceptionGroup has _use_item_location=True,
479+
the report location should point to the test item, not the fixture teardown.
480+
"""
481+
pytester.makepyfile(
482+
test_it="""
483+
import pytest
484+
@pytest.fixture
485+
def fix_item_loc():
486+
yield
487+
exc = pytest.skip.Exception("A")
488+
exc._use_item_location = True
489+
raise exc
490+
@pytest.fixture
491+
def fix_normal():
492+
yield
493+
raise pytest.skip.Exception("B")
494+
def test_both(fix_item_loc, fix_normal):
495+
assert True
496+
"""
497+
)
498+
result = pytester.runpytest("-v")
499+
result.assert_outcomes(passed=1, skipped=1)
500+
501+
out = result.stdout.str()
502+
# Both reasons should appear
503+
assert "A" in out and "B" in out
504+
# Crucially, the skip should be attributed to the test item, not teardown
505+
assert "test_both" in out
506+
437507

438508
class TestHooks:
439509
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)