From 9deccd84596509143a18edee4c6be50db1fe7c4c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 9 Aug 2026 12:33:18 +0300 Subject: [PATCH 1/2] gh-155411: Fix test.support.subTests() for asynchronous tests An asynchronous test was wrapped in a synchronous function, which discarded the coroutine without awaiting it, so the test did not run at all and was reported as successful. --- Lib/test/support/__init__.py | 31 +++++++--- Lib/test/test_support.py | 58 +++++++++++++++++++ ...-08-09-14-00-00.gh-issue-155411.Qw8Lm2.rst | 3 + 3 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-08-09-14-00-00.gh-issue-155411.Qw8Lm2.rst diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 74d3794289bf69..1c6e9ab0be2248 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -1098,16 +1098,29 @@ def subTests(arg_names, arg_values, /, *, _do_cleanups=False): def decorator(func): if isinstance(func, type): raise TypeError('subTests() can only decorate methods, not classes') - @functools.wraps(func) - def wrapper(self, /, *args, **kwargs): + + def iter_subtest_kwargs(): for values in arg_values: - if single_param: - values = (values,) - subtest_kwargs = dict(zip(arg_names, values)) - with self.subTest(**subtest_kwargs): - func(self, *args, **kwargs, **subtest_kwargs) - if _do_cleanups: - self.doCleanups() + yield dict(zip(arg_names, (values,) if single_param else values)) + + # A synchronous wrapper would discard the coroutine without awaiting + # it, so an asynchronous test would not run at all. + if inspect.iscoroutinefunction(func): + @functools.wraps(func) + async def wrapper(self, /, *args, **kwargs): + for subtest_kwargs in iter_subtest_kwargs(): + with self.subTest(**subtest_kwargs): + await func(self, *args, **kwargs, **subtest_kwargs) + if _do_cleanups: + self.doCleanups() + else: + @functools.wraps(func) + def wrapper(self, /, *args, **kwargs): + for subtest_kwargs in iter_subtest_kwargs(): + with self.subTest(**subtest_kwargs): + func(self, *args, **kwargs, **subtest_kwargs) + if _do_cleanups: + self.doCleanups() return wrapper return decorator diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 7c59bb38aaee9a..34efe4b2779848 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -1247,5 +1247,63 @@ def test_skipped_without_subprocess_support(self): self.assertEqual(calls, []) +class TestSubTests(unittest.TestCase): + + def run_test(self, cls): + result = unittest.TestResult() + cls('test_it').run(result) + return result + + def test_sync(self): + ran = [] + + class Sample(unittest.TestCase): + @support.subTests('a', [1, 2, 3]) + def test_it(self, a): + ran.append(a) + self.assertNotEqual(a, 2) + + result = self.run_test(Sample) + self.assertEqual(ran, [1, 2, 3]) + self.assertEqual(result.testsRun, 1) + self.assertEqual(len(result.failures), 1) + self.assertEndsWith(result.failures[0][0].id(), 'test_it (a=2)') + + def test_async(self): + # An asynchronous test must be awaited: a synchronous wrapper would + # make it silently not run at all. + ran = [] + + class Sample(unittest.IsolatedAsyncioTestCase): + @support.subTests('a', [1, 2, 3]) + async def test_it(self, a): + ran.append(a) + self.assertNotEqual(a, 2) + + result = self.run_test(Sample) + self.assertEqual(ran, [1, 2, 3]) + self.assertEqual(result.testsRun, 1) + self.assertEqual(len(result.failures), 1) + self.assertEndsWith(result.failures[0][0].id(), 'test_it (a=2)') + + def test_multiple_parameters(self): + ran = [] + + class Sample(unittest.TestCase): + @support.subTests('a,b', [(1, 'x'), (2, 'y')]) + def test_it(self, a, b): + ran.append((a, b)) + + result = self.run_test(Sample) + self.assertTrue(result.wasSuccessful(), result.errors) + self.assertEqual(ran, [(1, 'x'), (2, 'y')]) + + def test_cannot_decorate_class(self): + with self.assertRaises(TypeError): + @support.subTests('a', [1]) + class Sample(unittest.TestCase): + pass + + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Tests/2026-08-09-14-00-00.gh-issue-155411.Qw8Lm2.rst b/Misc/NEWS.d/next/Tests/2026-08-09-14-00-00.gh-issue-155411.Qw8Lm2.rst new file mode 100644 index 00000000000000..50f68405252605 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-08-09-14-00-00.gh-issue-155411.Qw8Lm2.rst @@ -0,0 +1,3 @@ +Fix :func:`!test.support.subTests` for asynchronous test methods. They were +wrapped in a synchronous function, which discarded the coroutine without +awaiting it, so the test silently did not run at all. From 1f0029778c166b1285dc82f910b0139339ce1a74 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 10 Aug 2026 10:59:27 +0300 Subject: [PATCH 2/2] Skip the asynchronous test on platforms without socket support Running an asyncio event loop needs a working socket, which is why test_unittest.test_async_case skips the whole module without one. --- Lib/test/test_support.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 34efe4b2779848..84b91bb00cdbe4 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -1269,6 +1269,8 @@ def test_it(self, a): self.assertEqual(len(result.failures), 1) self.assertEndsWith(result.failures[0][0].id(), 'test_it (a=2)') + # Running an asyncio event loop needs a working socket. + @support.requires_working_socket() def test_async(self): # An asynchronous test must be awaited: a synchronous wrapper would # make it silently not run at all.