gh-134565: Use ExceptionGroup to handle multiple errors in unittest.doModuleCleanups() (GH-134566)

This commit is contained in:
Serhiy Storchaka 2025-05-23 21:07:49 +03:00 committed by GitHub
parent 77eade39f9
commit 393773ae87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 101 additions and 15 deletions

View file

@ -1282,14 +1282,22 @@ class TestOutputBuffering(unittest.TestCase):
suite(result) suite(result)
expected_out = '\nStdout:\ndo cleanup2\ndo cleanup1\n' expected_out = '\nStdout:\ndo cleanup2\ndo cleanup1\n'
self.assertEqual(stdout.getvalue(), expected_out) self.assertEqual(stdout.getvalue(), expected_out)
self.assertEqual(len(result.errors), 1) self.assertEqual(len(result.errors), 2)
description = 'tearDownModule (Module)' description = 'tearDownModule (Module)'
test_case, formatted_exc = result.errors[0] test_case, formatted_exc = result.errors[0]
self.assertEqual(test_case.description, description) self.assertEqual(test_case.description, description)
self.assertIn('ValueError: bad cleanup2', formatted_exc) self.assertIn('ValueError: bad cleanup2', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('TypeError', formatted_exc) self.assertNotIn('TypeError', formatted_exc)
self.assertIn(expected_out, formatted_exc) self.assertIn(expected_out, formatted_exc)
test_case, formatted_exc = result.errors[1]
self.assertEqual(test_case.description, description)
self.assertIn('TypeError: bad cleanup1', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ValueError', formatted_exc)
self.assertIn(expected_out, formatted_exc)
def testBufferSetUpModule_DoModuleCleanups(self): def testBufferSetUpModule_DoModuleCleanups(self):
with captured_stdout() as stdout: with captured_stdout() as stdout:
result = unittest.TestResult() result = unittest.TestResult()
@ -1313,22 +1321,34 @@ class TestOutputBuffering(unittest.TestCase):
suite(result) suite(result)
expected_out = '\nStdout:\nset up module\ndo cleanup2\ndo cleanup1\n' expected_out = '\nStdout:\nset up module\ndo cleanup2\ndo cleanup1\n'
self.assertEqual(stdout.getvalue(), expected_out) self.assertEqual(stdout.getvalue(), expected_out)
self.assertEqual(len(result.errors), 2) self.assertEqual(len(result.errors), 3)
description = 'setUpModule (Module)' description = 'setUpModule (Module)'
test_case, formatted_exc = result.errors[0] test_case, formatted_exc = result.errors[0]
self.assertEqual(test_case.description, description) self.assertEqual(test_case.description, description)
self.assertIn('ZeroDivisionError: division by zero', formatted_exc) self.assertIn('ZeroDivisionError: division by zero', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ValueError', formatted_exc) self.assertNotIn('ValueError', formatted_exc)
self.assertNotIn('TypeError', formatted_exc) self.assertNotIn('TypeError', formatted_exc)
self.assertIn('\nStdout:\nset up module\n', formatted_exc) self.assertIn('\nStdout:\nset up module\n', formatted_exc)
test_case, formatted_exc = result.errors[1] test_case, formatted_exc = result.errors[1]
self.assertIn(expected_out, formatted_exc) self.assertIn(expected_out, formatted_exc)
self.assertEqual(test_case.description, description) self.assertEqual(test_case.description, description)
self.assertIn('ValueError: bad cleanup2', formatted_exc) self.assertIn('ValueError: bad cleanup2', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ZeroDivisionError', formatted_exc) self.assertNotIn('ZeroDivisionError', formatted_exc)
self.assertNotIn('TypeError', formatted_exc) self.assertNotIn('TypeError', formatted_exc)
self.assertIn(expected_out, formatted_exc) self.assertIn(expected_out, formatted_exc)
test_case, formatted_exc = result.errors[2]
self.assertIn(expected_out, formatted_exc)
self.assertEqual(test_case.description, description)
self.assertIn('TypeError: bad cleanup1', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ZeroDivisionError', formatted_exc)
self.assertNotIn('ValueError', formatted_exc)
self.assertIn(expected_out, formatted_exc)
def testBufferTearDownModule_DoModuleCleanups(self): def testBufferTearDownModule_DoModuleCleanups(self):
with captured_stdout() as stdout: with captured_stdout() as stdout:
result = unittest.TestResult() result = unittest.TestResult()
@ -1355,21 +1375,32 @@ class TestOutputBuffering(unittest.TestCase):
suite(result) suite(result)
expected_out = '\nStdout:\ntear down module\ndo cleanup2\ndo cleanup1\n' expected_out = '\nStdout:\ntear down module\ndo cleanup2\ndo cleanup1\n'
self.assertEqual(stdout.getvalue(), expected_out) self.assertEqual(stdout.getvalue(), expected_out)
self.assertEqual(len(result.errors), 2) self.assertEqual(len(result.errors), 3)
description = 'tearDownModule (Module)' description = 'tearDownModule (Module)'
test_case, formatted_exc = result.errors[0] test_case, formatted_exc = result.errors[0]
self.assertEqual(test_case.description, description) self.assertEqual(test_case.description, description)
self.assertIn('ZeroDivisionError: division by zero', formatted_exc) self.assertIn('ZeroDivisionError: division by zero', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ValueError', formatted_exc) self.assertNotIn('ValueError', formatted_exc)
self.assertNotIn('TypeError', formatted_exc) self.assertNotIn('TypeError', formatted_exc)
self.assertIn('\nStdout:\ntear down module\n', formatted_exc) self.assertIn('\nStdout:\ntear down module\n', formatted_exc)
test_case, formatted_exc = result.errors[1] test_case, formatted_exc = result.errors[1]
self.assertEqual(test_case.description, description) self.assertEqual(test_case.description, description)
self.assertIn('ValueError: bad cleanup2', formatted_exc) self.assertIn('ValueError: bad cleanup2', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ZeroDivisionError', formatted_exc) self.assertNotIn('ZeroDivisionError', formatted_exc)
self.assertNotIn('TypeError', formatted_exc) self.assertNotIn('TypeError', formatted_exc)
self.assertIn(expected_out, formatted_exc) self.assertIn(expected_out, formatted_exc)
test_case, formatted_exc = result.errors[2]
self.assertEqual(test_case.description, description)
self.assertIn('TypeError: bad cleanup1', formatted_exc)
self.assertNotIn('ExceptionGroup', formatted_exc)
self.assertNotIn('ZeroDivisionError', formatted_exc)
self.assertNotIn('ValueError', formatted_exc)
self.assertIn(expected_out, formatted_exc)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -13,6 +13,7 @@ from test.test_unittest.support import (
LoggingResult, LoggingResult,
ResultWithNoStartTestRunStopTestRun, ResultWithNoStartTestRunStopTestRun,
) )
from test.support.testcase import ExceptionIsLikeMixin
def resultFactory(*_): def resultFactory(*_):
@ -604,7 +605,7 @@ class TestClassCleanup(unittest.TestCase):
@support.force_not_colorized_test_class @support.force_not_colorized_test_class
class TestModuleCleanUp(unittest.TestCase): class TestModuleCleanUp(ExceptionIsLikeMixin, unittest.TestCase):
def test_add_and_do_ModuleCleanup(self): def test_add_and_do_ModuleCleanup(self):
module_cleanups = [] module_cleanups = []
@ -646,11 +647,50 @@ class TestModuleCleanUp(unittest.TestCase):
[(module_cleanup_good, (1, 2, 3), [(module_cleanup_good, (1, 2, 3),
dict(four='hello', five='goodbye')), dict(four='hello', five='goodbye')),
(module_cleanup_bad, (), {})]) (module_cleanup_bad, (), {})])
with self.assertRaises(CustomError) as e: with self.assertRaises(Exception) as e:
unittest.case.doModuleCleanups() unittest.case.doModuleCleanups()
self.assertEqual(str(e.exception), 'CleanUpExc') self.assertExceptionIsLike(e.exception,
ExceptionGroup('module cleanup failed',
[CustomError('CleanUpExc')]))
self.assertEqual(unittest.case._module_cleanups, []) self.assertEqual(unittest.case._module_cleanups, [])
def test_doModuleCleanup_with_multiple_errors_in_addModuleCleanup(self):
def module_cleanup_bad1():
raise TypeError('CleanUpExc1')
def module_cleanup_bad2():
raise ValueError('CleanUpExc2')
class Module:
unittest.addModuleCleanup(module_cleanup_bad1)
unittest.addModuleCleanup(module_cleanup_bad2)
with self.assertRaises(ExceptionGroup) as e:
unittest.case.doModuleCleanups()
self.assertExceptionIsLike(e.exception,
ExceptionGroup('module cleanup failed', [
ValueError('CleanUpExc2'),
TypeError('CleanUpExc1'),
]))
def test_doModuleCleanup_with_exception_group_in_addModuleCleanup(self):
def module_cleanup_bad():
raise ExceptionGroup('CleanUpExc', [
ValueError('CleanUpExc2'),
TypeError('CleanUpExc1'),
])
class Module:
unittest.addModuleCleanup(module_cleanup_bad)
with self.assertRaises(ExceptionGroup) as e:
unittest.case.doModuleCleanups()
self.assertExceptionIsLike(e.exception,
ExceptionGroup('module cleanup failed', [
ExceptionGroup('CleanUpExc', [
ValueError('CleanUpExc2'),
TypeError('CleanUpExc1'),
]),
]))
def test_addModuleCleanup_arg_errors(self): def test_addModuleCleanup_arg_errors(self):
cleanups = [] cleanups = []
def cleanup(*args, **kwargs): def cleanup(*args, **kwargs):
@ -871,9 +911,11 @@ class TestModuleCleanUp(unittest.TestCase):
ordering = [] ordering = []
blowUp = True blowUp = True
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestableTest) suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestableTest)
with self.assertRaises(CustomError) as cm: with self.assertRaises(Exception) as cm:
suite.debug() suite.debug()
self.assertEqual(str(cm.exception), 'CleanUpExc') self.assertExceptionIsLike(cm.exception,
ExceptionGroup('module cleanup failed',
[CustomError('CleanUpExc')]))
self.assertEqual(ordering, ['setUpModule', 'setUpClass', 'test', self.assertEqual(ordering, ['setUpModule', 'setUpClass', 'test',
'tearDownClass', 'tearDownModule', 'cleanup_exc']) 'tearDownClass', 'tearDownModule', 'cleanup_exc'])
self.assertEqual(unittest.case._module_cleanups, []) self.assertEqual(unittest.case._module_cleanups, [])

View file

@ -149,9 +149,7 @@ def doModuleCleanups():
except Exception as exc: except Exception as exc:
exceptions.append(exc) exceptions.append(exc)
if exceptions: if exceptions:
# Swallows all but first exception. If a multi-exception handler raise ExceptionGroup('module cleanup failed', exceptions)
# gets written we should use that here instead.
raise exceptions[0]
def skip(reason): def skip(reason):

View file

@ -223,6 +223,11 @@ class TestSuite(BaseTestSuite):
if result._moduleSetUpFailed: if result._moduleSetUpFailed:
try: try:
case.doModuleCleanups() case.doModuleCleanups()
except ExceptionGroup as eg:
for e in eg.exceptions:
self._createClassOrModuleLevelException(result, e,
'setUpModule',
currentModule)
except Exception as e: except Exception as e:
self._createClassOrModuleLevelException(result, e, self._createClassOrModuleLevelException(result, e,
'setUpModule', 'setUpModule',
@ -235,15 +240,15 @@ class TestSuite(BaseTestSuite):
errorName = f'{method_name} ({parent})' errorName = f'{method_name} ({parent})'
self._addClassOrModuleLevelException(result, exc, errorName, info) self._addClassOrModuleLevelException(result, exc, errorName, info)
def _addClassOrModuleLevelException(self, result, exception, errorName, def _addClassOrModuleLevelException(self, result, exc, errorName,
info=None): info=None):
error = _ErrorHolder(errorName) error = _ErrorHolder(errorName)
addSkip = getattr(result, 'addSkip', None) addSkip = getattr(result, 'addSkip', None)
if addSkip is not None and isinstance(exception, case.SkipTest): if addSkip is not None and isinstance(exc, case.SkipTest):
addSkip(error, str(exception)) addSkip(error, str(exc))
else: else:
if not info: if not info:
result.addError(error, sys.exc_info()) result.addError(error, (type(exc), exc, exc.__traceback__))
else: else:
result.addError(error, info) result.addError(error, info)
@ -273,6 +278,13 @@ class TestSuite(BaseTestSuite):
previousModule) previousModule)
try: try:
case.doModuleCleanups() case.doModuleCleanups()
except ExceptionGroup as eg:
if isinstance(result, _DebugResult):
raise
for e in eg.exceptions:
self._createClassOrModuleLevelException(result, e,
'tearDownModule',
previousModule)
except Exception as e: except Exception as e:
if isinstance(result, _DebugResult): if isinstance(result, _DebugResult):
raise raise

View file

@ -0,0 +1,3 @@
:func:`unittest.doModuleCleanups` no longer swallows all but first exception
raised in the cleanup code, but raises a :exc:`ExceptionGroup` if multiple
errors occurred.