Skip to content

Commit e26c0ae

Browse files
authored
fix #13537: Add support for ExceptionGroup with only Skipped exceptions in teardown (#13741)
1 parent 5d46b52 commit e26c0ae

File tree

3 files changed

+143
-7
lines changed

3 files changed

+143
-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: 65 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:
265+
# 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[BaseExceptionGroup[BaseException | BaseExceptionGroup]],
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 all(
281+
getattr(skip, "_use_item_location", False) for skip in excinfo.value.exceptions
282+
):
283+
path, line = item.reportinfo()[:2]
284+
assert line is not None
285+
loc = (os.fspath(path), line + 1)
286+
default_msg = "skipped"
287+
else:
288+
loc = (str(r.path), r.lineno)
289+
default_msg = r.message
290+
291+
# Get all unique skip messages.
292+
msgs: list[str] = []
293+
for exception in excinfo.value.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,24 @@ 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) and (
426+
excinfo.value.split(skip.Exception)[1] is None
427+
):
428+
# All exceptions in the group are skip exceptions.
429+
outcome = "skipped"
430+
excinfo = cast(
431+
ExceptionInfo[
432+
BaseExceptionGroup[BaseException | BaseExceptionGroup]
433+
],
434+
excinfo,
435+
)
436+
longrepr = _format_exception_group_all_skipped_longrepr(item, excinfo)
374437
else:
375438
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-
)
439+
longrepr = _format_failed_longrepr(item, call, excinfo)
382440
for rwhen, key, content in item._report_sections:
383441
sections.append((f"Captured {key} {rwhen}", content))
384442
return cls(

testing/test_reports.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,83 @@ 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+
@pytest.mark.parametrize(
475+
"use_item_location, skip_file_location",
476+
[(True, "test_it.py"), (False, "runner.py")],
477+
)
478+
def test_exception_group_skips_use_item_location(
479+
self, pytester: Pytester, use_item_location: bool, skip_file_location: str
480+
):
481+
"""
482+
Regression for #13537:
483+
If any skip inside an ExceptionGroup has _use_item_location=True,
484+
the report location should point to the test item, not the fixture teardown.
485+
"""
486+
pytester.makepyfile(
487+
test_it=f"""
488+
import pytest
489+
@pytest.fixture
490+
def fix_item1():
491+
yield
492+
exc = pytest.skip.Exception("A")
493+
exc._use_item_location = True
494+
raise exc
495+
@pytest.fixture
496+
def fix_item2():
497+
yield
498+
exc = pytest.skip.Exception("B")
499+
exc._use_item_location = {use_item_location}
500+
raise exc
501+
def test_both(fix_item1, fix_item2):
502+
assert True
503+
"""
504+
)
505+
result = pytester.runpytest("-rs")
506+
result.assert_outcomes(passed=1, skipped=1)
507+
508+
out = result.stdout.str()
509+
# Both reasons should appear
510+
assert "A" and "B" in out
511+
# Crucially, the skip should be attributed to the test item, not teardown
512+
assert skip_file_location in out
513+
437514

438515
class TestHooks:
439516
"""Test that the hooks are working correctly for plugins"""

0 commit comments

Comments
 (0)