From 7e7503c0b015f61d9d21d3b5f55990b7fcd683f7 Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Sat, 27 Apr 2024 08:49:05 -0400 Subject: [PATCH] unittest: report class cleanup exceptions (#12250) Fixes #11728 --------- Co-authored-by: Bruno Oliveira --- AUTHORS | 1 + changelog/11728.improvement.rst | 1 + src/_pytest/unittest.py | 19 +++++++ testing/test_unittest.py | 89 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 changelog/11728.improvement.rst diff --git a/AUTHORS b/AUTHORS index d7148acfc5..4f61c05914 100644 --- a/AUTHORS +++ b/AUTHORS @@ -101,6 +101,7 @@ Cyrus Maden Damian Skrzypczak Daniel Grana Daniel Hahler +Daniel Miller Daniel Nuri Daniel Sánchez Castelló Daniel Valenzuela Zenteno diff --git a/changelog/11728.improvement.rst b/changelog/11728.improvement.rst new file mode 100644 index 0000000000..1e87fc5ed8 --- /dev/null +++ b/changelog/11728.improvement.rst @@ -0,0 +1 @@ +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 5099904fd4..8f1791bf74 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -32,6 +32,9 @@ import pytest +if sys.version_info[:2] < (3, 11): + from exceptiongroup import ExceptionGroup + if TYPE_CHECKING: import unittest @@ -111,6 +114,20 @@ def _register_unittest_setup_class_fixture(self, cls: type) -> None: return None cleanup = getattr(cls, "doClassCleanups", lambda: None) + 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 + exceptions = [exc for (_, exc, _) in exc_infos] + # If a single exception, raise it directly as this provides a more readable + # error (hopefully this will improve in #12255). + if len(exceptions) == 1: + raise exceptions[0] + else: + raise ExceptionGroup("Unittest class cleanup errors", exceptions) + def unittest_setup_class_fixture( request: FixtureRequest, ) -> Generator[None, None, None]: @@ -125,6 +142,7 @@ def unittest_setup_class_fixture( # follow this here. except Exception: cleanup() + process_teardown_exceptions() raise yield try: @@ -132,6 +150,7 @@ def unittest_setup_class_fixture( teardown() finally: cleanup() + 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 9ecb548eee..d726e74d60 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1500,6 +1500,95 @@ def test_cleanup_called_the_right_number_of_times(): assert passed == 1 +class TestClassCleanupErrors: + """ + Make sure to show exceptions raised during class cleanup function (those registered + via addClassCleanup()). + + See #11728. + """ + + 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: """Regression test for #9610 - doesn't crash during traceback pruning.""" pytester.makepyfile(