skip to content

What does unittest.IsolatedAsyncioTestCase do that unittest.TestCase cannot?

level: juniorimportance: should knowfreq 40%

answer

  1. Plain TestCase never awaits anything
  2. Something has to drive the coroutine
  3. Isolation is per test method
  4. asyncSetUp and asyncTearDown bracket the body
  5. A fresh loop, closed afterwards

basics

~20 s

unittest.IsolatedAsyncioTestCase gives every test method its own fresh asyncio event loop and awaits async def test methods on it, along with asyncSetUp and asyncTearDown. A plain TestCase just calls the method and drops the coroutine it gets back.

solid answer

~40 s

`unittest.TestCase` knows nothing about coroutines: it calls the test method, gets a coroutine object back and discards it, so the body never runs and the test reports success. `unittest.IsolatedAsyncioTestCase`, in the stdlib since Python 3.8, owns an event loop instead. For each test method it creates a new loop, runs the synchronous `setUp`, awaits `asyncSetUp`, awaits the `async def test_` method, awaits `asyncTearDown`, runs the synchronous `tearDown`, then unwinds cleanups — `addCleanup` and `addAsyncCleanup` share one LIFO stack — and finally cancels leftover tasks and closes the loop. *Isolated* is the load-bearing word: the loop is per test method, not per class or module, so nothing bound to one test's loop survives into the next. Plain `def test_` methods are still allowed on the class and run synchronously.

code

python · 18 lines
python
import asyncio
import unittest


class TestWebhookQueue(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        self.deliveries = asyncio.Queue()
        await self.deliveries.put("evt-1")

    async def test_receives_one_delivery(self):
        self.assertEqual(await self.deliveries.get(), "evt-1")

    async def asyncTearDown(self):
        self.assertTrue(self.deliveries.empty())


if __name__ == "__main__":
    unittest.main()

go deeper

for a junior

Be ready to name unittest.IsolatedAsyncioTestCase as the base class you subclass when a test needs await, and to say plainly that a normal TestCase would never execute the body of an async def test method.

for a middle

Explain the per-test sequence out loud: a fresh event loop, synchronous setUp, awaited asyncSetUp, the awaited test, asyncTearDown, synchronous tearDown, then cleanups popped LIFO before the loop closes.

for a senior

Show what the isolation buys and costs. Leaked tasks are cancelled with the loop, but nothing loop-bound can be shared between tests, and there is no async setUpClass. Expect a probe on what survives when asyncSetUp raises.

for a principal

Own the tradeoff between per-test isolation and suite runtime: which resources are worth rebuilding for every test, which expensive loop-free state can safely live at class scope, and what your house rule about async test isolation should say.

### Why the class exists at all `unittest.TestCase` is older than coroutines, and its runner treats a test method as an ordinary callable: it calls the method and ignores what comes back. Declare `async def test_x` on a plain `TestCase` and calling it merely *builds* a coroutine object — the body never executes, no assertion inside it ever runs, and the test is reported green. Since Python 3.11 unittest at least notices the smell and emits a `DeprecationWarning` saying the test returned a non-`None` value, with a hint to use `IsolatedAsyncioTestCase` as the base class, but the test still counts as a pass. `unittest.IsolatedAsyncioTestCase` (stdlib since Python 3.8) is the subclass that knows how to *drive* a coroutine. Subclass it instead of `TestCase` and each `async def test_` method is genuinely awaited. ### What happens around one test method `unittest` constructs a fresh instance of the case class for every test method, so the whole sequence below runs once per test: 1. A brand-new event loop is created. Since 3.11 the class drives the test through an `asyncio.Runner`, and it deliberately runs that loop in **debug mode** — which is why a never-awaited warning raised inside such a test arrives with a "Coroutine created at" traceback for free. 2. The synchronous `setUp` runs, with the loop already installed as the current one. 3. `asyncSetUp` is awaited. 4. The test method runs — awaited if it is `async def`, simply called if it is a plain `def`. Both are legal on this class, so one case class can hold a mix of sync and async tests. 5. `asyncTearDown` is awaited. Like the synchronous `tearDown`, it is **skipped** when `asyncSetUp` raised. 6. The synchronous `tearDown` runs. 7. Cleanups unwind. `addCleanup` and `addAsyncCleanup` push onto one stack and pop LIFO; the async ones are awaited, the sync ones called. `await self.enterAsyncContext(cm)` enters an async context manager and registers its `__aexit__` on that same stack. Cleanups run **even when `asyncSetUp` failed**, which is why "register the release the instant you acquire the resource" beats "release it in `asyncTearDown`". 8. The loop is shut down: tasks still pending are cancelled and given a chance to unwind, asynchronous generators are closed, then the loop is closed. ### What "Isolated" is promising The isolation is per **test method**, not per class and not per module. Two things follow. First, a task you forgot to await or cancel cannot leak into the next test — it dies with the loop. Second, and this is the part that bites in real suites, any object that captured that loop is dead too: an `asyncio.Event`, `Queue`, `Lock` or `Condition` binds to the running loop the first time it actually has to wait and refuses to be used from a different one afterwards, while streams, transports, subprocesses and background tasks hold their loop outright. Loop-bound state therefore has to be built inside `asyncSetUp` or the test itself, never shared at class scope. There is also no asynchronous equivalent of `setUpClass`: class-level hooks stay synchronous, because no loop is running when they execute. ### What it does not do It does not parallelize anything — tests still run one after another. It does not await your assertions for you, and it will not turn a forgotten `await` inside a test body into a failure; you get a passing test and a warning. And it does not replace `asyncio.run`: never call `asyncio.run` inside a test method on this class, because a loop is already running there and `asyncio.run` raises `RuntimeError` in that situation. ### How to answer in an interview Say what plain `TestCase` does with a coroutine (drops it), name the class, then give the order — sync `setUp`, `asyncSetUp`, the awaited test, `asyncTearDown`, sync `tearDown`, LIFO cleanups, loop closed — and finish on the isolation guarantee: one loop per test method. That is the whole mechanism, and reciting the order is what separates a candidate who has used it from one who has read about it.

  • If asyncSetUp raises halfway through, which of asyncTearDown and the registered cleanups still run?
    `asyncTearDown` is skipped, exactly as the synchronous `tearDown` is skipped when `setUp` raises. Anything already registered with `addCleanup`, `addAsyncCleanup` or `enterAsyncContext` still runs, in LIFO order, before the loop closes. That asymmetry is the practical argument for registering a release the moment you acquire a resource rather than pairing acquisition in `asyncSetUp` with release in `asyncTearDown`.
  • Can you still write a plain def test_ method on an IsolatedAsyncioTestCase subclass?
    Yes. The class calls a non-coroutine test method directly and awaits a coroutine one, so a single case class can hold both. The loop is still created and closed around the synchronous test, which costs a little time but keeps the lifecycle uniform. It is a normal way to keep a handful of pure-sync assertions next to the async ones they relate to.
  • Why should you never call asyncio.run inside an IsolatedAsyncioTestCase test method?
    The class is already running a loop in that thread, and `asyncio.run` refuses to start another one — it raises `RuntimeError`. Even if it worked, the coroutine would execute on a throwaway loop with no relationship to the test's own, so anything it created would be unusable in the rest of the test. Inside the test you simply `await`.

saying these in an interview costs you the question

  • Thinks a plain TestCase awaits async def test methods
  • Believes one event loop is shared by the whole class
  • Calls asyncio.run inside an async test method
  • Expects asyncTearDown to run after asyncSetUp raised
  • Thinks a sync def test_ method is illegal on the class

context