Skip to content

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 took 0.200 seconds". A context variable set in 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

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.

IsolatedAsyncioTestCase behaviour on Python 3.14 A grid of 5 rows by 2 columns. IsolatedAsyncioTestCase behaviour on Python 3.14 aspect observed 204 async tests 0.33 s total (~0.6 ms each) loop debug mode on by default time.sleep(0.2) in a test 'took 0.200 seconds' warning contextvar set in asyncSetUp visible in the test task left running by a test cancelled at teardown, no failure A fresh loop per test, in debug mode, sharing one context per test.

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 took 0.200 seconds". Turn those warnings into failures where blocking would be a bug:

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.

One IsolatedAsyncioTestCase test A flow of 5 stages. One IsolatedAsyncioTestCase test new loop debug mode on setUp, asyncSetUp shared context test method same context cleanups, asyncTearDown reverse order cancel leftovers, close loop silently Isolation per test; leftovers are cancelled, not reported.

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.

Is IsolatedAsyncioTestCase the right tool here? A decision on What does the test suite need with 4 outcomes. Is IsolatedAsyncioTestCase the right tool here? What does the test suite need? no test dependencies IsolatedAsyncioTestCase stdlib only existing unittest suite IsolatedAsyncioTestCase + leak base gradual shared server/pool across tests pytest-asyncio loop scopes not per test parametrize, plugins pytest richer tooling Both run the same async code; the choice is about fixtures and tooling.

Verification

IsolatedAsyncioTestCase is used well when:

  • Resources are created in asyncSetUp and released with addAsyncCleanup.
  • 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.