skip to content

Why do IsolatedAsyncioTestCase tests fail after the first when a webhook-receiver suite shares one async client created in setUpClass?

level: seniorimportance: should knowfreq 28%

answer

  1. First test passes, the rest fail
  2. Renaming a test moves the survivor
  3. Isolation is per test method
  4. Primitives capture the running loop
  5. Build it in asyncSetUp instead

basics

~20 s

Each IsolatedAsyncioTestCase test method gets a new event loop, closed when the test ends, and setUpClass cannot await anything. An async object built for the first test stays bound to that dead loop, so the second test raises RuntimeError.

solid answer

~50 s

`unittest.IsolatedAsyncioTestCase` creates and closes an event loop **per test method**, so class-scoped async state is bound to a loop that no longer exists by the second test. Since Python 3.10 the asyncio primitives — `Event`, `Lock`, `Condition`, `Semaphore`, `Queue` — capture the running loop the first time they have to wait and raise `RuntimeError: ... is bound to a different event loop` afterwards; streams, transports and background tasks hold their loop outright and are cancelled when it closes. `setUpClass` cannot help anyway, because it is synchronous. Build loop-bound resources in `asyncSetUp` and register their release immediately with `await self.enterAsyncContext(cm)` or `self.addAsyncCleanup(...)`, so they are torn down even when the test fails. If construction is genuinely expensive, keep the loop-free part — parsed fixtures, a large in-memory working set — at class scope and rebuild only the thin loop-bound handles per test.

code

python · 25 lines
python
import asyncio
import unittest


class TestSharedEvent(unittest.IsolatedAsyncioTestCase):
    ready = asyncio.Event()

    async def _wait_briefly(self):
        try:
            async with asyncio.timeout(0.01):
                await self.ready.wait()
        except TimeoutError:
            pass

    async def test_first(self):
        await self._wait_briefly()

    async def test_second(self):
        # the Event is bound to test_first's loop, which is closed
        with self.assertRaises(RuntimeError):
            await self._wait_briefly()


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

go deeper

for a junior

Take away the rule of thumb: build anything you have to await inside asyncSetUp, not on the class. Sharing an async object between tests breaks because each test runs on its own event loop.

for a middle

Explain the mechanism, not just the rule: which objects capture a loop and when, why setUpClass cannot await at all, and how addAsyncCleanup and enterAsyncContext release resources even when setup fails partway.

for a senior

Diagnose it from the symptom. First test green, the rest failing, survivor changes when tests are renamed — then name the shared loop-bound object, and describe the split between loop-free state that may be shared and handles that must be rebuilt.

for a principal

Own the policy question: how much per-test isolation the suite buys at what runtime cost, where the line falls between unit tests on a per-test loop and integration tests on a long-lived one, and how the team detects order-dependent suites before they rot.

### What the failure looks like A webhook-receiver suite builds its async client once in `setUpClass` "for speed". The first test passes; every test after it fails with `RuntimeError: ... is bound to a different event loop`, or with `Event loop is closed`, or it hangs. Rename a test and a *different* one becomes the survivor. That signature — first test green, the rest broken, order-dependent — is the fingerprint of loop-bound state shared across tests. ### Why it happens `unittest.IsolatedAsyncioTestCase` creates a **new event loop for every test method** and closes it once that test's cleanups have unwound. Anything created on the first loop is now attached to a loop that no longer exists: * The asyncio synchronization primitives — `asyncio.Event`, `Lock`, `Condition`, `Semaphore`, `Queue` — capture the running loop the first time they actually have to wait. Since Python 3.10 removed their explicit loop parameter this binding is implicit and one-way: use the object from another loop later and you get `RuntimeError: ... is bound to a different event loop`. * Streams, transports, subprocesses and any background task hold their loop outright. A client with a keep-alive task loses that task when the loop closes, because closing cancels whatever is still pending. * A future created on the old loop can never be resolved by the new one, so code that waits on it hangs until the test times out. `setUpClass` cannot rescue this even in principle, because it is synchronous — there is no running loop while it executes, so you cannot `await` the client's construction there at all. The usual next mistake is calling `asyncio.run(build_client())` inside `setUpClass`, which builds the object on a *third* loop that is closed before any test starts, producing the same failure one step earlier. ### The fix Create loop-bound resources per test, inside `asyncSetUp`, and register the release the moment you acquire it: ```python async def asyncSetUp(self): self.receiver = await self.enterAsyncContext(receiver_session()) ``` `enterAsyncContext` (Python 3.11+) awaits `__aenter__`, hands you the result, and pushes `__aexit__` onto the cleanup stack, so the resource is released even if the test fails or a later line of `asyncSetUp` raises. Where there is no context manager, `self.addAsyncCleanup(self.receiver.aclose)` does the same job; `contextlib.AsyncExitStack` composes several of them. All of these unwind LIFO, on the same loop that created the objects, before that loop closes — which is exactly the property class-scoped setup cannot offer. ### When per-test construction really is too expensive Split the object rather than sharing it. The expensive part is usually loop-free: parsed fixture data, a compiled schema, a 2.4 GB working set read from disk. Build **that** once at class scope, where it is immutable and holds nothing loop-bound, and rebuild only the thin loop-bound handles per test. If even that is too slow, the honest conclusion is that the test is an integration test and belongs in a suite that owns a single long-lived loop, not in a class whose entire promise is per-test isolation. ### The sibling bug worth naming While you are moving state off the class, look for the synchronous version of the same mistake: a mutable class attribute such as `pending = {}` used as a per-test scratchpad. Nothing resets it between tests, so one test's leftovers become another's preconditions. The suite passes in the order you wrote it and fails when a test is renamed, filtered out, or run alone — and, worse, it can pass for the wrong reason, asserting on data an earlier test happened to leave behind. It is the same root cause as the shared client: state whose lifetime is wider than the thing that owns it. The same fix applies — build it per test, on the instance. ### What to say in an interview Name the guarantee first (one loop per test method, closed at the end), then the mechanism (loop-bound objects capture their loop and refuse another), then the fix (`asyncSetUp` plus `addAsyncCleanup` or `enterAsyncContext`), then the cost-conscious variant (share the loop-free part, rebuild the handles). Mentioning that the symptom is order-dependent is the detail that shows you have debugged this rather than read about it.

  • Why is enterAsyncContext usually preferable to releasing the resource in asyncTearDown?
    Because `asyncTearDown` is skipped when `asyncSetUp` raises. If you acquire three resources in `asyncSetUp` and the third acquisition fails, the first two leak. `await self.enterAsyncContext(cm)` registers the exit at the moment of acquisition, on the shared LIFO cleanup stack, so everything already acquired is released regardless of where setup stopped or whether the test passed.
  • What happens to a task the test started but never awaited when the test method returns?
    It is cancelled as the test's loop is shut down, and the loop is given a chance to let the cancellation unwind before closing. That is a real benefit of the per-test loop — a leaked task cannot bleed into the next test — but it also means a fire-and-forget task may be killed before it does its work, so a test that depends on background progress must await or synchronize on it explicitly.
  • The suite must share an expensive resource. What do you actually share?
    Only the parts with no loop affinity: parsed fixture data, compiled schemas, a large read-only in-memory structure, connection settings. Build those once at class scope and treat them as immutable. Rebuild the loop-bound handles — clients, queues, events, anything with a background task — inside `asyncSetUp`. If the loop-bound part itself is the expensive half, that is a signal the test is really an integration test and needs a different harness.

Each test runs in a workshop that is dismantled the moment it ends. A tool bolted to the first workbench is not merely inconvenient at the second bench — it no longer exists.

saying these in an interview costs you the question

  • Creates async clients in setUpClass and reuses them
  • Thinks one event loop is shared across the class
  • Calls asyncio.run inside setUpClass to build state
  • Keeps per-test state in a mutable class attribute
  • Assumes asyncio primitives stay loop-agnostic forever
  • Blames flakiness on test order without finding shared state

context