Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions tests/test_testresult.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion testtools/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
6 changes: 3 additions & 3 deletions testtools/testresult/doubles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions testtools/testresult/real.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
TypeAlias,
TypedDict,
TypeVar,
cast,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down