diff --git a/changelog/13537.bugfix.rst b/changelog/13537.bugfix.rst new file mode 100644 index 00000000000..47743b24f05 --- /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..8186806d00f 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 @@ -35,6 +36,10 @@ from _pytest.outcomes import skip +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + + if TYPE_CHECKING: from typing_extensions import Self @@ -251,6 +256,52 @@ 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[BaseExceptionGroup[BaseException | BaseExceptionGroup]], +) -> tuple[str, int, str]: + r = excinfo._getreprcrash() + assert r is not None, ( + "There should always be a traceback entry for skipping a test." + ) + if all( + getattr(skip, "_use_item_location", False) for skip in excinfo.value.exceptions + ): + path, line = item.reportinfo()[:2] + assert line is not None + loc = (os.fspath(path), line + 1) + default_msg = "skipped" + else: + loc = (str(r.path), r.lineno) + default_msg = r.message + + # Get all unique skip messages. + msgs: list[str] = [] + for exception in excinfo.value.exceptions: + m = getattr(exception, "msg", None) or ( + exception.args[0] if exception.args else None + ) + 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 +419,24 @@ 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) and ( + excinfo.value.split(skip.Exception)[1] is None + ): + # All exceptions in the group are skip exceptions. + outcome = "skipped" + excinfo = cast( + ExceptionInfo[ + BaseExceptionGroup[BaseException | BaseExceptionGroup] + ], + excinfo, + ) + longrepr = _format_exception_group_all_skipped_longrepr(item, 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..b81371587d9 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -434,6 +434,83 @@ 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( + "first_skip_reason, second_skip_reason, skip_reason_output", + [("A", "B", "(A; B)"), ("A", "A", "(A)")], + ) + def test_exception_group_with_only_skips( + self, + pytester: Pytester, + first_skip_reason: str, + second_skip_reason: str, + skip_reason_output: str, + ): + """ + 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. + """ + pytester.makepyfile( + test_it=f""" + import pytest + @pytest.fixture + def fixA(): + yield + pytest.skip(reason="{first_skip_reason}") + @pytest.fixture + def fixB(): + yield + pytest.skip(reason="{second_skip_reason}") + def test_skip(fixA, fixB): + assert True + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1, skipped=1) + out = result.stdout.str() + assert skip_reason_output in out + assert "ERROR at teardown" not in out + + @pytest.mark.parametrize( + "use_item_location, skip_file_location", + [(True, "test_it.py"), (False, "runner.py")], + ) + def test_exception_group_skips_use_item_location( + self, pytester: Pytester, use_item_location: bool, skip_file_location: str + ): + """ + 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=f""" + import pytest + @pytest.fixture + def fix_item1(): + yield + exc = pytest.skip.Exception("A") + exc._use_item_location = True + raise exc + @pytest.fixture + def fix_item2(): + yield + exc = pytest.skip.Exception("B") + exc._use_item_location = {use_item_location} + raise exc + def test_both(fix_item1, fix_item2): + assert True + """ + ) + result = pytester.runpytest("-rs") + result.assert_outcomes(passed=1, skipped=1) + + out = result.stdout.str() + # Both reasons should appear + assert "A" and "B" in out + # Crucially, the skip should be attributed to the test item, not teardown + assert skip_file_location in out + class TestHooks: """Test that the hooks are working correctly for plugins"""