diff --git a/changelog/13537.bugfix.rst b/changelog/13537.bugfix.rst new file mode 100644 index 00000000000..b069b000a03 --- /dev/null +++ b/changelog/13537.bugfix.rst @@ -0,0 +1 @@ +Fix bug in which ExceptionGroup with only Skipped exceptions in teardown was not handled correctly and showed as error diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index fb0607bfb95..9cdc52862a6 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -9,6 +9,7 @@ from io import StringIO import os from pprint import pprint +import sys from typing import Any from typing import cast from typing import final @@ -33,6 +34,11 @@ from _pytest.nodes import Item from _pytest.outcomes import fail from _pytest.outcomes import skip +from _pytest.outcomes import Skipped + + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: @@ -251,6 +257,50 @@ def _report_unserialization_failure( raise RuntimeError(stream.getvalue()) +def _format_failed_longrepr( + item: Item, call: CallInfo[None], excinfo: ExceptionInfo[BaseException] +): + if call.when == "call": + longrepr = item.repr_failure(excinfo) + else: # exception in setup or teardown + longrepr = item._repr_failure_py( + excinfo, style=item.config.getoption("tbstyle", "auto") + ) + return longrepr + + +def _format_exception_group_all_skipped_longrepr( + item: Item, + excinfo: ExceptionInfo[BaseException], + exceptions: list[Skipped], +) -> tuple[str, int, str]: + r = excinfo._getreprcrash() + assert r is not None, ( + "There should always be a traceback entry for skipping a test." + ) + if any(getattr(skip, "_use_item_location", False) for skip in exceptions): + path, line = item.reportinfo()[:2] + assert line is not None + loc = (os.fspath(path), line + 1) + default_msg = "skipped" + # longrepr = (*loc, r.message) + else: + assert r is not None + loc = (str(r.path), r.lineno) + default_msg = r.message + + # reason(s): order-preserving de-dupe, same fields as single-skip + msgs: list[str] = [] + for exception in exceptions: + m = exception.msg or exception.args[0] + if m and m not in msgs: + msgs.append(m) + + reason = "; ".join(msgs) if msgs else default_msg + longrepr = (*loc, reason) + return longrepr + + @final class TestReport(BaseReport): """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: if excinfo.value._use_item_location: path, line = item.reportinfo()[:2] assert line is not None - longrepr = os.fspath(path), line + 1, r.message + longrepr = (os.fspath(path), line + 1, r.message) else: longrepr = (str(r.path), r.lineno, r.message) + elif isinstance(excinfo.value, BaseExceptionGroup): + value: BaseExceptionGroup = excinfo.value + if value.exceptions and any( + isinstance(exception, skip.Exception) + for exception in value.exceptions + ): + outcome = "skipped" + skipped_exceptions = cast(list[Skipped], value.exceptions) + longrepr = _format_exception_group_all_skipped_longrepr( + item, excinfo, skipped_exceptions + ) + else: + # fall through to your existing failure path + outcome = "failed" + longrepr = _format_failed_longrepr(item, call, excinfo) else: outcome = "failed" - if call.when == "call": - longrepr = item.repr_failure(excinfo) - else: # exception in setup or teardown - longrepr = item._repr_failure_py( - excinfo, style=item.config.getoption("tbstyle", "auto") - ) + longrepr = _format_failed_longrepr(item, call, excinfo) for rwhen, key, content in item._report_sections: sections.append((f"Captured {key} {rwhen}", content)) return cls( diff --git a/testing/test_reports.py b/testing/test_reports.py index 5c44ec72841..e2e0108ac0e 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -434,6 +434,68 @@ def test_1(fixture_): timing.sleep(10) loaded_report = TestReport._from_json(data) assert loaded_report.stop - loaded_report.start == approx(report.duration) + @pytest.mark.parametrize("duplicate", [False, True]) + def test_exception_group_with_only_skips(self, pytester: Pytester, duplicate: bool): + """ + Test that when an ExceptionGroup with only Skipped exceptions is raised in teardown, + it is reported as a single skipped test, not as an error. + This is a regression test for issue #13537. + """ + reason = "A" if duplicate else "B" + pytester.makepyfile( + test_it=f""" + import pytest + @pytest.fixture + def fixA(): + yield + pytest.skip(reason="A") + @pytest.fixture + def fixB(): + yield + pytest.skip(reason="{reason}") + def test_skip(fixA, fixB): + assert True + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1, skipped=1) + out = result.stdout.str() + # Both reasons should appear + assert "(A)" in out if duplicate else "(A; B)" + assert "ERROR at teardown" not in out + + def test_exception_group_skips_use_item_location(self, pytester: Pytester): + """ + Regression for #13537: + If any skip inside an ExceptionGroup has _use_item_location=True, + the report location should point to the test item, not the fixture teardown. + """ + pytester.makepyfile( + test_it=""" + import pytest + @pytest.fixture + def fix_item_loc(): + yield + exc = pytest.skip.Exception("A") + exc._use_item_location = True + raise exc + @pytest.fixture + def fix_normal(): + yield + raise pytest.skip.Exception("B") + def test_both(fix_item_loc, fix_normal): + assert True + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1, skipped=1) + + out = result.stdout.str() + # Both reasons should appear + assert "A" in out and "B" in out + # Crucially, the skip should be attributed to the test item, not teardown + assert "test_both" in out + class TestHooks: """Test that the hooks are working correctly for plugins"""