From 122fd05e1ef5cbc7023203121a51d9272db6b54a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 26 Apr 2024 20:10:32 -0300 Subject: [PATCH] Use ExceptionGroup instead of printing, improve changelog --- changelog/11728.improvement.rst | 2 +- src/_pytest/unittest.py | 30 ++++--- testing/test_unittest.py | 145 ++++++++++++++++++-------------- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/changelog/11728.improvement.rst b/changelog/11728.improvement.rst index 3464bd90b..1e87fc5ed 100644 --- a/changelog/11728.improvement.rst +++ b/changelog/11728.improvement.rst @@ -1 +1 @@ -Class cleanup exceptions are now reported. +For ``unittest``-based tests, exceptions during class cleanup (as raised by functions registered with :meth:`TestCase.addClassCleanup `) are now reported instead of silently failing. diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 10cd7212e..d6457c878 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -32,6 +32,9 @@ from _pytest.runner import CallInfo import pytest +if sys.version_info[:2] < (3, 11): + from exceptiongroup import BaseExceptionGroup + if TYPE_CHECKING: import unittest @@ -111,18 +114,19 @@ class UnitTestCase(Class): return None cleanup = getattr(cls, "doClassCleanups", lambda: None) - def process_teardown_exceptions(raise_last: bool): - errors = getattr(cls, "tearDown_exceptions", None) - if not errors: + def process_teardown_exceptions() -> None: + # tearDown_exceptions is a list set in the class containing exc_infos for errors during + # teardown for the class. + exc_infos = getattr(cls, "tearDown_exceptions", None) + if not exc_infos: return - others = errors[:-1] if raise_last else errors - if others: - num = len(errors) - for n, (exc_type, exc, tb) in enumerate(others, start=1): - print(f"\nclass cleanup error ({n} of {num}):", file=sys.stderr) - traceback.print_exception(exc_type, exc, tb) - if raise_last: - raise errors[-1][1] + exceptions = [exc for (_, exc, _) in exc_infos] + # If a single exception, raise it directly as this provides a more readable + # error. + if len(exceptions) == 1: + raise exceptions[0] + else: + raise BaseExceptionGroup("Unittest class cleanup errors", exceptions) def unittest_setup_class_fixture( request: FixtureRequest, @@ -138,7 +142,7 @@ class UnitTestCase(Class): # follow this here. except Exception: cleanup() - process_teardown_exceptions(raise_last=False) + process_teardown_exceptions() raise yield try: @@ -146,7 +150,7 @@ class UnitTestCase(Class): teardown() finally: cleanup() - process_teardown_exceptions(raise_last=True) + process_teardown_exceptions() self.session._fixturemanager._register_fixture( # Use a unique name to speed up lookup. diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 00d364fc1..d726e74d6 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1500,70 +1500,93 @@ def test_do_cleanups_on_teardown_failure(pytester: Pytester) -> None: assert passed == 1 -def test_class_cleanups_failure_in_setup(pytester: Pytester) -> None: - testpath = pytester.makepyfile( - """ - import unittest - class MyTestCase(unittest.TestCase): - @classmethod - def setUpClass(cls): - def cleanup(n): - raise Exception(f"fail {n}") - cls.addClassCleanup(cleanup, 2) - cls.addClassCleanup(cleanup, 1) - raise Exception("fail 0") - def test(self): - pass +class TestClassCleanupErrors: """ - ) - result = pytester.runpytest("-s", testpath) - result.assert_outcomes(passed=0, errors=1) - result.stderr.fnmatch_lines( - [ - "class cleanup error (1 of 2):", - "Exception: fail 1", - "class cleanup error (2 of 2):", - "Exception: fail 2", - ] - ) - result.stdout.fnmatch_lines( - [ - "* ERROR at setup of MyTestCase.test *", - "E * Exception: fail 0", - ] - ) + Make sure to show exceptions raised during class cleanup function (those registered + via addClassCleanup()). - -def test_class_cleanups_failure_in_teardown(pytester: Pytester) -> None: - testpath = pytester.makepyfile( - """ - import unittest - class MyTestCase(unittest.TestCase): - @classmethod - def setUpClass(cls): - def cleanup(n): - raise Exception(f"fail {n}") - cls.addClassCleanup(cleanup, 2) - cls.addClassCleanup(cleanup, 1) - def test(self): - pass + See #11728. """ - ) - result = pytester.runpytest("-s", testpath) - result.assert_outcomes(passed=1, errors=1) - result.stderr.fnmatch_lines( - [ - "class cleanup error (1 of 2):", - "Traceback *", - "Exception: fail 1", - ] - ) - result.stdout.fnmatch_lines( - [ - "* ERROR at teardown of MyTestCase.test *", - "E * Exception: fail 2", - ] - ) + + def test_class_cleanups_failure_in_setup(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + raise Exception("fail 0") + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=0, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + result.stdout.fnmatch_lines( + [ + "* ERROR at setup of MyTestCase.test *", + "E * Exception: fail 0", + ] + ) + + def test_class_cleanups_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 2) + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*Unittest class cleanup errors *2 sub-exceptions*", + "*Exception: fail 1", + "*Exception: fail 2", + ] + ) + + def test_class_cleanup_1_failure_in_teardown(self, pytester: Pytester) -> None: + testpath = pytester.makepyfile( + """ + import unittest + class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + def cleanup(n): + raise Exception(f"fail {n}") + cls.addClassCleanup(cleanup, 1) + def test(self): + pass + """ + ) + result = pytester.runpytest("-s", testpath) + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of MyTestCase.test*", + "*Exception: fail 1", + ] + ) def test_traceback_pruning(pytester: Pytester) -> None: