Testing with unittest IsolatedAsyncioTestCase¶
unittest.IsolatedAsyncioTestCase is the standard library's answer to async tests: each test method can be async def, and each test gets a fresh event loop. It needs no plugins, which makes it the natural choice for libraries that avoid test dependencies and for codebases already on unittest. Tested on Python 3.14: 204 async test methods ran in 0.33 s, including one that blocked for 0.2 s — about 0.6 ms of overhead per test for the fresh loop. The loop runs in debug mode by default, and it reported the blocking test with "Executing asyncSetUp was visible inside the test, because setup, test and teardown share one context. And a task the test left running was cancelled silently at teardown — no failure, no warning. This guide uses the class effectively and covers the gaps.
Prerequisites¶
- Python 3.11+ (context sharing between setup and test arrived in 3.11); tested on 3.14.
- Async testing concepts, from Testing Async Code.
- pytest users: compare with testing asyncio code with pytest-asyncio.
1. Write async tests with async setup and cleanup¶
Subclass IsolatedAsyncioTestCase and use the async hooks for resources that need the event loop:
import unittest
class OrderServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.db = await create_test_pool()
self.addAsyncCleanup(self.db.close) # runs even if setup fails later
self.service = OrderService(self.db)
async def test_place_order(self) -> None:
order_id = await self.service.place(customer=1, items=[("sku-1", 2)])
self.assertIsInstance(order_id, int)
async def test_rejects_empty_order(self) -> None:
with self.assertRaises(ValueError):
await self.service.place(customer=1, items=[])
if __name__ == "__main__":
unittest.main()
asyncSetUp and asyncTearDown run on the same loop as the test; addAsyncCleanup registers coroutine functions to run after the test in reverse order, and is the safest way to release resources because it runs even when a later setup step raises. Synchronous setUp and tearDown still work and run before and after their async counterparts. pytest runs these classes as well, so a unittest-based suite can migrate gradually.
Verify: a deliberate exception in the second half of asyncSetUp still closes the pool, via the cleanup registered before it.
2. Use the shared context for request-like state¶
Setup, the test and teardown run in one contextvars.Context, so values set in asyncSetUp are visible in the test — tested: the test read the value set in asyncSetUp:
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
class HandlerTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
request_id.set("test-req-1") # like middleware would
async def test_logs_carry_request_id(self) -> None:
with self.assertLogs("app", level="INFO") as logs:
await handle_order(order)
self.assertIn("test-req-1", logs.output[0])
That makes it easy to test code that depends on request context — logging filters, tenant scoping, tracing — without passing values explicitly. Before Python 3.11, setup and test ran in different contexts and this did not work; if you support older versions, set context inside the test. The context mechanics are covered in propagating request IDs with contextvars.
Verify: a value set in asyncSetUp is read back in a test, and does not leak into the next test.
3. Use debug mode's warnings¶
The test loop runs with asyncio debug mode on, which reports slow callbacks, unawaited coroutines and other mistakes. Tested: a test that called time.sleep(0.2) produced "Executing
class NonBlockingTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
loop = asyncio.get_running_loop()
loop.slow_callback_duration = 0.05 # 50 ms, stricter than the 100 ms default
async def test_handler_does_not_block(self) -> None:
with self.assertNoLogs("asyncio", level="WARNING"):
await handle_request(sample_request)
assertNoLogs (Python 3.10+) fails if asyncio logs a slow-callback warning during the call. Lowering slow_callback_duration catches smaller stalls. Debug mode also makes some operations slower; for performance-sensitive tests, loop.set_debug(False) in asyncSetUp turns it off. Finding the blocking call itself is covered in finding blocking calls with asyncio debug mode.
Verify: introducing a time.sleep into a handler makes its test fail through assertNoLogs.
4. Catch the tasks it cancels silently¶
At teardown, remaining tasks are cancelled and awaited before the loop closes. That keeps tests isolated, but it also hides background tasks a test should not have left behind — tested: a leaked task was cancelled with no failure. Add the check yourself:
class LeakCheckedTestCase(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._tasks_before = set(asyncio.all_tasks())
async def asyncTearDown(self) -> None:
await asyncio.sleep(0)
leaked = [t for t in asyncio.all_tasks() - self._tasks_before
if t is not asyncio.current_task() and not t.done()]
for t in leaked:
t.cancel()
if leaked:
self.fail("tasks left running: " + ", ".join(t.get_coro().__qualname__ for t in leaked))
Subclasses that override asyncSetUp or asyncTearDown must call super(). The same check for pytest is in detecting leaked tasks in tests. Use this base class for every async test case in the project so the rule applies everywhere.
Verify: a test that spawns a fire-and-forget task now fails with the coroutine's name.
5. Know when to choose pytest instead¶
IsolatedAsyncioTestCase covers the basics well; some needs are easier with pytest and pytest-asyncio:
# Fresh loop per test is the only mode: expensive shared resources are per test
class SlowFixtureTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.server = await start_server() # started for every single test method
self.addAsyncCleanup(self.server.close)
# pytest-asyncio can share a server across a module with a module-scoped loop:
# @pytest_asyncio.fixture(scope="module", loop_scope="module")
Because every test gets a new loop, resources bound to a loop — servers, connection pools, clients — cannot be shared across tests; with ~0.6 ms of loop overhead per test that is cheap, but starting a real server or database pool per test may not be. pytest also brings parametrization, fixtures with dependencies, and plugins such as virtual time and repeat runs. Neither approach is wrong; stay with unittest when you want zero dependencies, and move to pytest when fixture sharing or parametrization start to matter, as discussed in choosing event loop scopes in pytest-asyncio.
Verify: the test suite's slowest tests (python -m unittest -v timings or pytest --durations) are not dominated by per-test resource setup.
Verification¶
IsolatedAsyncioTestCase is used well when:
- Resources are created in
asyncSetUpand released withaddAsyncCleanup. - Debug-mode warnings are asserted where blocking would be a bug.
- A shared base class fails tests that leak tasks.
- Per-test setup cost stays small, or the suite moves to pytest fixtures.
Diagnostic Hook: run the suite with -W error::RuntimeWarning and asyncio debug mode, and grep the output for "took" and "was never awaited". Each line is a blocking call or a forgotten await in tested code, already located by test name.
Pitfalls & edge cases¶
- Expecting leaked tasks to fail tests. Tested: they are cancelled silently.
- Sharing loop-bound resources across tests. Each test has its own loop.
- Overriding async hooks without
super(). Base-class checks stop running. - Relying on setup context before 3.11. Setup and test shared no context then.
Frequently Asked Questions¶
How do I write async tests with unittest?
Subclass unittest.IsolatedAsyncioTestCase and make test methods async def; use asyncSetUp, asyncTearDown and addAsyncCleanup for resources that need the event loop.
Does IsolatedAsyncioTestCase run each test in a new event loop?
Yes. In testing, 204 async tests ran in 0.33 s, about 0.6 ms of overhead per test. Loop-bound resources therefore cannot be shared across tests.
Is asyncio debug mode enabled in IsolatedAsyncioTestCase?
Yes, by default. A test that blocked for 0.2 s produced a 'took 0.200 seconds' warning in testing.
What happens to tasks left running in an IsolatedAsyncioTestCase test?
They are cancelled at teardown without failing the test. Add a check in asyncTearDown that compares asyncio.all_tasks() with a snapshot from asyncSetUp.
Related¶
- Testing Async Code — up to the topic overview.
- Property-based testing of async code with Hypothesis — generating test inputs instead of writing them.
- Resilience, Cancellation & Error Handling — the section overview.