Skip to content

Commit 9deccd8

Browse files
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.
1 parent 998b890 commit 9deccd8

3 files changed

Lines changed: 83 additions & 9 deletions

File tree

Lib/test/support/__init__.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,16 +1098,29 @@ def subTests(arg_names, arg_values, /, *, _do_cleanups=False):
10981098
def decorator(func):
10991099
if isinstance(func, type):
11001100
raise TypeError('subTests() can only decorate methods, not classes')
1101-
@functools.wraps(func)
1102-
def wrapper(self, /, *args, **kwargs):
1101+
1102+
def iter_subtest_kwargs():
11031103
for values in arg_values:
1104-
if single_param:
1105-
values = (values,)
1106-
subtest_kwargs = dict(zip(arg_names, values))
1107-
with self.subTest(**subtest_kwargs):
1108-
func(self, *args, **kwargs, **subtest_kwargs)
1109-
if _do_cleanups:
1110-
self.doCleanups()
1104+
yield dict(zip(arg_names, (values,) if single_param else values))
1105+
1106+
# A synchronous wrapper would discard the coroutine without awaiting
1107+
# it, so an asynchronous test would not run at all.
1108+
if inspect.iscoroutinefunction(func):
1109+
@functools.wraps(func)
1110+
async def wrapper(self, /, *args, **kwargs):
1111+
for subtest_kwargs in iter_subtest_kwargs():
1112+
with self.subTest(**subtest_kwargs):
1113+
await func(self, *args, **kwargs, **subtest_kwargs)
1114+
if _do_cleanups:
1115+
self.doCleanups()
1116+
else:
1117+
@functools.wraps(func)
1118+
def wrapper(self, /, *args, **kwargs):
1119+
for subtest_kwargs in iter_subtest_kwargs():
1120+
with self.subTest(**subtest_kwargs):
1121+
func(self, *args, **kwargs, **subtest_kwargs)
1122+
if _do_cleanups:
1123+
self.doCleanups()
11111124
return wrapper
11121125
return decorator
11131126

Lib/test/test_support.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,5 +1247,63 @@ def test_skipped_without_subprocess_support(self):
12471247
self.assertEqual(calls, [])
12481248

12491249

1250+
class TestSubTests(unittest.TestCase):
1251+
1252+
def run_test(self, cls):
1253+
result = unittest.TestResult()
1254+
cls('test_it').run(result)
1255+
return result
1256+
1257+
def test_sync(self):
1258+
ran = []
1259+
1260+
class Sample(unittest.TestCase):
1261+
@support.subTests('a', [1, 2, 3])
1262+
def test_it(self, a):
1263+
ran.append(a)
1264+
self.assertNotEqual(a, 2)
1265+
1266+
result = self.run_test(Sample)
1267+
self.assertEqual(ran, [1, 2, 3])
1268+
self.assertEqual(result.testsRun, 1)
1269+
self.assertEqual(len(result.failures), 1)
1270+
self.assertEndsWith(result.failures[0][0].id(), 'test_it (a=2)')
1271+
1272+
def test_async(self):
1273+
# An asynchronous test must be awaited: a synchronous wrapper would
1274+
# make it silently not run at all.
1275+
ran = []
1276+
1277+
class Sample(unittest.IsolatedAsyncioTestCase):
1278+
@support.subTests('a', [1, 2, 3])
1279+
async def test_it(self, a):
1280+
ran.append(a)
1281+
self.assertNotEqual(a, 2)
1282+
1283+
result = self.run_test(Sample)
1284+
self.assertEqual(ran, [1, 2, 3])
1285+
self.assertEqual(result.testsRun, 1)
1286+
self.assertEqual(len(result.failures), 1)
1287+
self.assertEndsWith(result.failures[0][0].id(), 'test_it (a=2)')
1288+
1289+
def test_multiple_parameters(self):
1290+
ran = []
1291+
1292+
class Sample(unittest.TestCase):
1293+
@support.subTests('a,b', [(1, 'x'), (2, 'y')])
1294+
def test_it(self, a, b):
1295+
ran.append((a, b))
1296+
1297+
result = self.run_test(Sample)
1298+
self.assertTrue(result.wasSuccessful(), result.errors)
1299+
self.assertEqual(ran, [(1, 'x'), (2, 'y')])
1300+
1301+
def test_cannot_decorate_class(self):
1302+
with self.assertRaises(TypeError):
1303+
@support.subTests('a', [1])
1304+
class Sample(unittest.TestCase):
1305+
pass
1306+
1307+
12501308
if __name__ == '__main__':
12511309
unittest.main()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix :func:`!test.support.subTests` for asynchronous test methods. They were
2+
wrapped in a synchronous function, which discarded the coroutine without
3+
awaiting it, so the test silently did not run at all.

0 commit comments

Comments
 (0)