skip to content

Why does an IsolatedAsyncioTestCase test that forgets an await still pass?

level: middleimportance: must knowfreq 52%

answer

  1. Calling is not running
  2. What comes back is an object
  3. That object is never falsy
  4. The warning fires at collection time
  5. Assert on values, not truthiness

basics

~20 s

Calling an async def function without awaiting it runs none of the body — it returns a coroutine object, which is truthy and not None. So assertTrue succeeds, and the never-awaited RuntimeWarning only fires later, at garbage collection.

solid answer

~40 s

An `async def` function is a factory: calling it builds a coroutine object and runs nothing. That object is truthy and not `None`, so `self.assertTrue(deliver(payload))` passes no matter what `deliver` would have returned, and a call written for its side effect simply never happens. CPython does emit `RuntimeWarning: coroutine 'deliver' was never awaited` — but only when the object is finalized, and a warning does not fail a test. Even `-W error::RuntimeWarning` does not help, because the warning is raised from the finalizer, so it surfaces as unraisable output while the test still lands in the OK column. The defences that work are asserting on **awaited values** rather than truthiness, running a static type checker over the test files, and failing CI when "was never awaited" appears in the output.

code

python · 18 lines
python
import unittest


async def deliver(payload):
    return False


class TestDelivery(unittest.IsolatedAsyncioTestCase):
    async def test_delivery_reports_success(self):
        # deliver(...) builds a coroutine object, which is always truthy
        self.assertTrue(deliver("evt-1"))

    async def test_delivery_reports_success_properly(self):
        self.assertEqual(await deliver("evt-1"), False)


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

go deeper

for a junior

Remember that calling an async def function runs nothing and hands back a coroutine object, and that this object is truthy. If a test asserts only that something is there, a missing await slips past it.

for a middle

Explain the mechanics: where the coroutine object comes from, why truthiness assertions pass, when the RuntimeWarning is emitted, and why asserting on an awaited value is the assertion style that cannot be fooled.

for a senior

Demonstrate the operational fix. Say how you would stop the whole class of bug across a suite — value-based assertions, a type checker over the tests, and CI that fails on the warning text rather than trusting the runner to fail.

for a principal

Frame it as a false-confidence problem: a green suite that asserts nothing is worse than no suite. Be ready to say what guardrail you would mandate, what it costs, and how you would detect suites already silently passing.

### The shape of the bug `async def` functions are factories. Calling one executes none of its body: it constructs a coroutine object and hands it back. The body runs only when something drives that object — an `await`, a task wrapping it, `asyncio.run`, or a test runner such as `unittest.IsolatedAsyncioTestCase`. So `deliver(payload)` and `await deliver(payload)` differ in the most consequential way possible: one produces an object, the other produces a result. ### Why the test still reports success A coroutine object is an ordinary object. It is not `None`, and it is truthy, because it defines neither `__bool__` nor `__len__`. Every assertion that only checks "is something here" therefore succeeds: * `self.assertTrue(deliver(payload))` passes even when `deliver` would have returned `False`. * `self.assertIsNotNone(deliver(payload))` passes even when it would have returned `None`. * A call made purely for its side effect — recording a delivery, writing a row, sending a request — simply never happens, and nothing asserts that it did. One case fails, but misleadingly: `with self.assertRaises(ValueError): validate(payload)` fails with "ValueError not raised", sending you to hunt inside `validate` for a validation bug that is not there. Contrast all of that with `self.assertEqual(deliver(payload), "accepted")`, which fails immediately and legibly, because a coroutine object never equals a real value. That is the general lesson in one line: **assert on values, not on truthiness.** ### The warning, and why it is weaker than people think When the un-awaited coroutine object is finalized, CPython emits `RuntimeWarning: coroutine 'deliver' was never awaited`. Three things limit it: 1. It fires at **garbage-collection time**, not at the mistake. Usually that is right after the statement, but if the object is stored on an attribute, captured in a recorded call, or trapped in a reference cycle, the warning can arrive at interpreter shutdown, attributed to nothing useful. 2. It is a warning. The test still reports success; you get noise in the output, not a red run. 3. Raising warnings to errors does not close the gap. Running the suite with `-W error::RuntimeWarning` prints a traceback, but because the warning comes from the coroutine's finalizer the resulting exception is unraisable — the test is still counted as OK. The good news is small but real: `IsolatedAsyncioTestCase` runs its loop in debug mode, so coroutine origin tracking is on and the warning carries a "Coroutine created at" traceback pointing straight at the line missing its `await`. If you can see the warning at all, you can find it instantly. ### How to actually catch it * **Assert on awaited values.** `self.assertEqual(await deliver(payload), "accepted")` cannot silently pass. * **Never wrap a call to an async helper in a bare truthiness assertion.** `assertTrue` around anything `async def` is a code smell on its own. * **Run a static type checker over the tests.** "Coroutine passed where a `bool` was expected" and "result of an async call is unused" are exactly what it is good at, and it is the only check that fires before the suite runs. * **Make the warning fatal in CI yourself.** Grep the test output for "was never awaited" and fail the build; the runner will not do it for you. * **Adopt the review rule:** every call to an `async def` helper inside a test has `await` in front of it, or is deliberately handed to something that will drive it. ### The whole-class version of the same bug Write `async def test_` on a plain `unittest.TestCase` and no test in that class runs — same mechanism, larger blast radius. Since Python 3.11 unittest prints a `DeprecationWarning` about the test returning a non-`None` value, with an explicit hint that you may have forgotten to use `IsolatedAsyncioTestCase` as the base class. Learn to recognise that line on sight: the suite is otherwise a wall of green that tests nothing at all.

  • Does running the suite with -W error::RuntimeWarning make a forgotten await fail the test?
    No. The warning is emitted from the coroutine object's finalizer, so turning warnings into errors produces an unraisable-exception traceback in the output while the test itself is still recorded as passing. It makes the mistake loud, which is worth doing, but if you want a red build you have to fail on that string appearing in the output, or assert on awaited values so the assertion itself catches it.
  • Which assertion styles hide a missing await, and which expose it?
    Anything that only tests presence hides it: `assertTrue`, `assertIsNotNone`, or a bare call for its side effect. Anything that compares against a concrete expected value exposes it, because a coroutine object never equals a string, number or model instance — `assertEqual(await f(), expected)` fails loudly. `assertRaises` around an un-awaited call fails too, but with a misleading message about the exception not being raised.
  • You see 'coroutine ... was never awaited' but cannot tell where it came from. What helps?
    Inside `IsolatedAsyncioTestCase` the loop already runs in debug mode, so the warning carries a "Coroutine created at" traceback naming the exact line. Outside that class, enable development mode (`-X dev`, or `PYTHONDEVMODE=1`) to turn on coroutine origin tracking, and enable `tracemalloc` when the message asks for an allocation traceback. Failing that, narrow it by running one test module at a time.

A coroutine object is a sealed envelope. Holding one proves nothing about what is inside; only opening it — awaiting it — does. An assertion that checks you are holding an envelope always passes.

saying these in an interview costs you the question

  • Says a coroutine object is falsy until awaited
  • Believes the never-awaited warning fails the test
  • Uses assertTrue on a call to an async helper
  • Claims Python raises immediately when the await is missing
  • Thinks -W error is enough to catch it

context