Skip to content

Commit 4dd3e3f

Browse files
asvetlovmiss-islington
authored andcommitted
bpo-32972: Async test case (GH-13386)
Add explicit `asyncSetUp` and `asyncTearDown` methods. The rest is the same as for #13228 `AsyncTestCase` create a loop instance for every test for the sake of test isolation. Sometimes a loop shared between all tests can speed up tests execution time a lot but it requires control of closed resources after every test finish. Basically, it requires nested supervisors support that was discussed with @1st1 many times. Sorry, asyncio supervisors have no chance to land on Python 3.8. The PR intentionally does not provide API for changing the used event loop or getting the test loop: use `asyncio.set_event_loop_policy()` and `asyncio.get_event_loop()` instead. The PR adds four overridable methods to base `unittest.TestCase` class: ``` def _callSetUp(self): self.setUp() def _callTestMethod(self, method): method() def _callTearDown(self): self.tearDown() def _callCleanup(self, function, /, *args, **kwargs): function(*args, **kwargs) ``` It allows using asyncio facilities with minimal influence on the unittest code. The last but not least: the PR respects contextvars. The context variable installed by `asyncSetUp` is available on test, `tearDown` and a coroutine scheduled by `addCleanup`. https://bugs.python.org/issue32972
1 parent 7d40869 commit 4dd3e3f

File tree

6 files changed

+373
-6
lines changed

6 files changed

+373
-6
lines changed

Lib/test/test_support.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def test_check__all__(self):
403403
("unittest.result", "unittest.case",
404404
"unittest.suite", "unittest.loader",
405405
"unittest.main", "unittest.runner",
406-
"unittest.signals"),
406+
"unittest.signals", "unittest.async_case"),
407407
extra=extra,
408408
blacklist=blacklist)
409409

Lib/unittest/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def testMultiply(self):
4444
SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
4545
"""
4646

47-
__all__ = ['TestResult', 'TestCase', 'TestSuite',
47+
__all__ = ['TestResult', 'TestCase', 'IsolatedAsyncioTestCase', 'TestSuite',
4848
'TextTestRunner', 'TestLoader', 'FunctionTestCase', 'main',
4949
'defaultTestLoader', 'SkipTest', 'skip', 'skipIf', 'skipUnless',
5050
'expectedFailure', 'TextTestResult', 'installHandler',
@@ -57,6 +57,7 @@ def testMultiply(self):
5757
__unittest = True
5858

5959
from .result import TestResult
60+
from .async_case import IsolatedAsyncioTestCase
6061
from .case import (addModuleCleanup, TestCase, FunctionTestCase, SkipTest, skip,
6162
skipIf, skipUnless, expectedFailure)
6263
from .suite import BaseTestSuite, TestSuite

Lib/unittest/async_case.py

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import asyncio
2+
import inspect
3+
4+
from .case import TestCase
5+
6+
7+
8+
class IsolatedAsyncioTestCase(TestCase):
9+
# Names intentionally have a long prefix
10+
# to reduce a chance of clashing with user-defined attributes
11+
# from inherited test case
12+
#
13+
# The class doesn't call loop.run_until_complete(self.setUp()) and family
14+
# but uses a different approach:
15+
# 1. create a long-running task that reads self.setUp()
16+
# awaitable from queue along with a future
17+
# 2. await the awaitable object passing in and set the result
18+
# into the future object
19+
# 3. Outer code puts the awaitable and the future object into a queue
20+
# with waiting for the future
21+
# The trick is necessary because every run_until_complete() call
22+
# creates a new task with embedded ContextVar context.
23+
# To share contextvars between setUp(), test and tearDown() we need to execute
24+
# them inside the same task.
25+
26+
# Note: the test case modifies event loop policy if the policy was not instantiated
27+
# yet.
28+
# asyncio.get_event_loop_policy() creates a default policy on demand but never
29+
# returns None
30+
# I believe this is not an issue in user level tests but python itself for testing
31+
# should reset a policy in every test module
32+
# by calling asyncio.set_event_loop_policy(None) in tearDownModule()
33+
34+
def __init__(self, methodName='runTest'):
35+
super().__init__(methodName)
36+
self._asyncioTestLoop = None
37+
self._asyncioCallsQueue = None
38+
39+
async def asyncSetUp(self):
40+
pass
41+
42+
async def asyncTearDown(self):
43+
pass
44+
45+
def addAsyncCleanup(self, func, /, *args, **kwargs):
46+
# A trivial trampoline to addCleanup()
47+
# the function exists because it has a different semantics
48+
# and signature:
49+
# addCleanup() accepts regular functions
50+
# but addAsyncCleanup() accepts coroutines
51+
#
52+
# We intentionally don't add inspect.iscoroutinefunction() check
53+
# for func argument because there is no way
54+
# to check for async function reliably:
55+
# 1. It can be "async def func()" iself
56+
# 2. Class can implement "async def __call__()" method
57+
# 3. Regular "def func()" that returns awaitable object
58+
self.addCleanup(*(func, *args), **kwargs)
59+
60+
def _callSetUp(self):
61+
self.setUp()
62+
self._callAsync(self.asyncSetUp)
63+
64+
def _callTestMethod(self, method):
65+
self._callMaybeAsync(method)
66+
67+
def _callTearDown(self):
68+
self._callAsync(self.asyncTearDown)
69+
self.tearDown()
70+
71+
def _callCleanup(self, function, *args, **kwargs):
72+
self._callMaybeAsync(function, *args, **kwargs)
73+
74+
def _callAsync(self, func, /, *args, **kwargs):
75+
assert self._asyncioTestLoop is not None
76+
ret = func(*args, **kwargs)
77+
assert inspect.isawaitable(ret)
78+
fut = self._asyncioTestLoop.create_future()
79+
self._asyncioCallsQueue.put_nowait((fut, ret))
80+
return self._asyncioTestLoop.run_until_complete(fut)
81+
82+
def _callMaybeAsync(self, func, /, *args, **kwargs):
83+
assert self._asyncioTestLoop is not None
84+
ret = func(*args, **kwargs)
85+
if inspect.isawaitable(ret):
86+
fut = self._asyncioTestLoop.create_future()
87+
self._asyncioCallsQueue.put_nowait((fut, ret))
88+
return self._asyncioTestLoop.run_until_complete(fut)
89+
else:
90+
return ret
91+
92+
async def _asyncioLoopRunner(self):
93+
queue = self._asyncioCallsQueue
94+
while True:
95+
query = await queue.get()
96+
queue.task_done()
97+
if query is None:
98+
return
99+
fut, awaitable = query
100+
try:
101+
ret = await awaitable
102+
if not fut.cancelled():
103+
fut.set_result(ret)
104+
except asyncio.CancelledError:
105+
raise
106+
except Exception as ex:
107+
if not fut.cancelled():
108+
fut.set_exception(ex)
109+
110+
def _setupAsyncioLoop(self):
111+
assert self._asyncioTestLoop is None
112+
loop = asyncio.new_event_loop()
113+
asyncio.set_event_loop(loop)
114+
loop.set_debug(True)
115+
self._asyncioTestLoop = loop
116+
self._asyncioCallsQueue = asyncio.Queue(loop=loop)
117+
self._asyncioCallsTask = loop.create_task(self._asyncioLoopRunner())
118+
119+
def _tearDownAsyncioLoop(self):
120+
assert self._asyncioTestLoop is not None
121+
loop = self._asyncioTestLoop
122+
self._asyncioTestLoop = None
123+
self._asyncioCallsQueue.put_nowait(None)
124+
loop.run_until_complete(self._asyncioCallsQueue.join())
125+
126+
try:
127+
# cancel all tasks
128+
to_cancel = asyncio.all_tasks(loop)
129+
if not to_cancel:
130+
return
131+
132+
for task in to_cancel:
133+
task.cancel()
134+
135+
loop.run_until_complete(
136+
asyncio.gather(*to_cancel, loop=loop, return_exceptions=True))
137+
138+
for task in to_cancel:
139+
if task.cancelled():
140+
continue
141+
if task.exception() is not None:
142+
loop.call_exception_handler({
143+
'message': 'unhandled exception during test shutdown',
144+
'exception': task.exception(),
145+
'task': task,
146+
})
147+
# shutdown asyncgens
148+
loop.run_until_complete(loop.shutdown_asyncgens())
149+
finally:
150+
asyncio.set_event_loop(None)
151+
loop.close()
152+
153+
def run(self, result=None):
154+
self._setupAsyncioLoop()
155+
try:
156+
return super().run(result)
157+
finally:
158+
self._tearDownAsyncioLoop()

Lib/unittest/case.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,18 @@ def _addUnexpectedSuccess(self, result):
645645
else:
646646
addUnexpectedSuccess(self)
647647

648+
def _callSetUp(self):
649+
self.setUp()
650+
651+
def _callTestMethod(self, method):
652+
method()
653+
654+
def _callTearDown(self):
655+
self.tearDown()
656+
657+
def _callCleanup(self, function, /, *args, **kwargs):
658+
function(*args, **kwargs)
659+
648660
def run(self, result=None):
649661
orig_result = result
650662
if result is None:
@@ -676,14 +688,14 @@ def run(self, result=None):
676688
self._outcome = outcome
677689

678690
with outcome.testPartExecutor(self):
679-
self.setUp()
691+
self._callSetUp()
680692
if outcome.success:
681693
outcome.expecting_failure = expecting_failure
682694
with outcome.testPartExecutor(self, isTest=True):
683-
testMethod()
695+
self._callTestMethod(testMethod)
684696
outcome.expecting_failure = False
685697
with outcome.testPartExecutor(self):
686-
self.tearDown()
698+
self._callTearDown()
687699

688700
self.doCleanups()
689701
for test, reason in outcome.skipped:
@@ -721,7 +733,7 @@ def doCleanups(self):
721733
while self._cleanups:
722734
function, args, kwargs = self._cleanups.pop()
723735
with outcome.testPartExecutor(self):
724-
function(*args, **kwargs)
736+
self._callCleanup(function, *args, **kwargs)
725737

726738
# return this for backwards compatibility
727739
# even though we no longer use it internally

0 commit comments

Comments
 (0)