diff --git a/tests/test_testresult.py b/tests/test_testresult.py index af9982ca..598ab452 100644 --- a/tests/test_testresult.py +++ b/tests/test_testresult.py @@ -16,7 +16,15 @@ from itertools import chain, combinations from queue import Queue from typing import Any -from unittest import TestSuite +from unittest import ( + TestCase as StdlibTestCase, +) +from unittest import ( + TestResult as StdlibTestResult, +) +from unittest import ( + TestSuite, +) import testresources @@ -2954,9 +2962,11 @@ def check_outcome_details_to_exec_info(self, outcome, expected=None): event_outcome, event_test, event_err = self.result._events[0] self.assertEqual(expected, event_outcome) self.assertEqual(self, event_test) - # Compare exc type and value; traceback objects differ between calls + # Compare exc type and value; traceback is always None because the + # exception is synthesised, not raised from user code — attaching a + # live traceback would leak testtools internals into error reports. self.assertEqual(expected_err[:2], event_err[:2]) - self.assertIsNotNone(event_err[2]) + self.assertIsNone(event_err[2]) def check_outcome_details_to_nothing(self, outcome, expected=None): """Call an outcome with a details dict to be swallowed.""" @@ -3131,6 +3141,36 @@ class TestExtendedToOriginalAddFailure(TestExtendedToOriginalAddError): outcome = "addFailure" +class TestExtendedToOriginalDetailsStdlibReport(TestCase): + """Regression test: details converted to exc_info for a stdlib TestResult + must not cause testtools' own frames to appear in the error report. + + Before the fix for the 2.9.0 regression, ``_details_to_exc_info`` returned + a live traceback pointing into ``testresult/real.py`` itself, which + ``unittest.TestResult._exc_info_to_string`` then formatted into the + user-visible string. + """ + + def _report_for(self, outcome, events_attr): + client = StdlibTestResult() + decorator = ExtendedToOriginalDecorator(client) + details = {"traceback": text_content("foo.c:53:ERROR invalid state\n")} + getattr(decorator, outcome)(StdlibTestCase("run"), details=details) + events = getattr(client, events_attr) + self.assertEqual(1, len(events)) + return events[0][1] + + def test_add_error(self): + report = self._report_for("addError", "errors") + self.assertNotIn("testresult/real.py", report) + self.assertNotIn("Traceback (most recent call last)", report) + + def test_add_failure(self): + report = self._report_for("addFailure", "failures") + self.assertNotIn("testresult/real.py", report) + self.assertNotIn("Traceback (most recent call last)", report) + + class TestExtendedToOriginalAddExpectedFailure(TestExtendedToOriginalAddError): outcome = "addExpectedFailure" diff --git a/testtools/_types.py b/testtools/_types.py index 2c4769d7..b0c52401 100644 --- a/testtools/_types.py +++ b/testtools/_types.py @@ -4,5 +4,5 @@ from typing import TypeAlias # Type for exc_info tuples from sys.exc_info() -ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType] +ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType | None] OptExcInfo: TypeAlias = ExcInfo | tuple[None, None, None] diff --git a/testtools/testresult/doubles.py b/testtools/testresult/doubles.py index 405ca790..639b3d27 100644 --- a/testtools/testresult/doubles.py +++ b/testtools/testresult/doubles.py @@ -95,13 +95,13 @@ def __init__(self, event_log: list[LogEvent] | None = None) -> None: self.collectedDurations: list[tuple[unittest.TestCase, float]] = [] def addError(self, test: unittest.TestCase, err: OptExcInfo) -> None: - super().addError(test, err) + super().addError(test, err) # type: ignore[arg-type] self._events.append(("addError", test, err)) if self.failfast: self.stop() def addFailure(self, test: unittest.TestCase, err: OptExcInfo) -> None: - super().addFailure(test, err) + super().addFailure(test, err) # type: ignore[arg-type] self._events.append(("addFailure", test, err)) if self.failfast: self.stop() @@ -111,7 +111,7 @@ def addSuccess(self, test: unittest.TestCase) -> None: self._events.append(("addSuccess", test)) def addExpectedFailure(self, test: unittest.TestCase, err: OptExcInfo) -> None: - super().addExpectedFailure(test, err) + super().addExpectedFailure(test, err) # type: ignore[arg-type] self._events.append(("addExpectedFailure", test, err)) def addSkip(self, test: unittest.TestCase, reason: str) -> None: diff --git a/testtools/testresult/real.py b/testtools/testresult/real.py index cd20d6ae..bb887cac 100644 --- a/testtools/testresult/real.py +++ b/testtools/testresult/real.py @@ -39,7 +39,6 @@ TypeAlias, TypedDict, TypeVar, - cast, ) if TYPE_CHECKING: @@ -2113,12 +2112,15 @@ def _check_args(self, err: object, details: object) -> None: ) def _details_to_exc_info(self, details: DetailsDict) -> ExcInfo: - """Convert a details dict to an exc_info tuple.""" - try: - raise _StringException(_details_to_str(details, special="traceback")) - except _StringException: - # we know this won't be null - return cast(ExcInfo, sys.exc_info()) + """Convert a details dict to an exc_info tuple. + + The returned tuple has ``tb=None`` on purpose: the exception is + synthesised here, not raised by user code, so attaching a live + traceback would leak testtools' own internals into the user-visible + error report emitted by e.g. ``unittest.TestResult._exc_info_to_string``. + """ + exc = _StringException(_details_to_str(details, special="traceback")) + return (_StringException, exc, None) @property def current_tags(self) -> set[str]: @@ -2755,7 +2757,7 @@ def addSubTest( subtest: unittest.TestCase, err: OptExcInfo | None, ) -> None: - self.decorated.addSubTest(test, subtest, err) + self.decorated.addSubTest(test, subtest, err) # type: ignore[arg-type] def addDuration(self, test: unittest.TestCase, duration: float) -> None: self.decorated.addDuration(test, duration)