Why does a test that calls an async def function without awaiting it still pass?
answer
- Nothing actually ran
- The call returns an object
- Truthy object, passing assertion
- RuntimeWarning fires at collection time
- Async runner plus warnings as errors
basics
~20 sCalling an async def function only builds a coroutine object; the body never runs. Assertions then inspect that object rather than a result, so the test passes vacuously. CPython only warns, at garbage-collection time, that the coroutine was never awaited.
solid answer
~40 s`async def` defines a coroutine *function*: calling it constructs a coroutine object and returns immediately without executing a line of the body. A coroutine object is truthy and not `None`, so `assertIsNotNone` or `assert result` passes while the code under test never ran. The only warning is `RuntimeWarning: coroutine ... was never awaited`, raised when the object is garbage-collected — it does not fail the build and may be attributed to a later test. Fixes: run async test bodies under an async-aware runner such as `unittest.IsolatedAsyncioTestCase` (or `asyncio.run`), prove once that a deliberately failing async test actually goes red, run the suite with `python -X dev` or warnings as errors, and assert on concrete values rather than on objects being non-null.
code
python · 14 linesimport warnings
async def fetch_count() -> int:
return 42
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = fetch_count() # no await, so the body never runs
assert result is not None # the "assertion" only sees a coroutine object
del result
print([type(w.message).__name__ for w in caught])go deeper
Be ready to say what calling an async def function returns before any await happens, and why an assertion on that object can pass while nothing ran. Knowing the phrase 'coroutine was never awaited' is expected.
Explain the mechanics: coroutine object construction, the runner that must drive it, and why the RuntimeWarning appears at garbage-collection time rather than at the call — so it can be blamed on the wrong test.
Show the suite-level judgement: prove the runner really awaits async tests, promote the warning to an error in CI, and insist on value assertions so a missing await cannot produce a green run.
Own the policy angle: a suite where a failing async test can pass is a false safety signal across every team using it. Decide where warnings-as-errors is enforced and how new async test infrastructure is verified.
### Calling and running are two different events `async def` does not define a function that executes when you call it. It defines a **coroutine function**, and calling it constructs a **coroutine object** and returns immediately. Not one line of the body has run at that point. The body runs only when something *drives* the coroutine: an `await` from inside another coroutine, `asyncio.run`, a task created with `asyncio.create_task`, or an async-aware test runner such as `unittest.IsolatedAsyncioTestCase`. That distinction is what produces the most dangerous failure mode in an async test suite: a test that is green about nothing. ### How the vacuous pass happens A synchronous test method calls the async code under test and asserts on the result: ```python def test_pick_list_is_built(self): result = build_pick_list("AISLE-04") # returns a coroutine object self.assertIsNotNone(result) # always true ``` `result` is a coroutine object. It is truthy, it is not `None`, it has a `__name__`, and it happily survives `assertIsNotNone`, `assertTrue`, and any `assert x` you write. The builder never ran, no warehouse bin was ever read, no exception could possibly be raised — and the test reports success. The same shape appears in a subtler form when the test method itself is `async def` but the runner is not async-aware: the runner calls the method, gets a coroutine object back, treats a non-raising call as a pass, and discards it. ### The only signal CPython gives you When the unawaited coroutine object is finally destroyed, CPython emits `RuntimeWarning: coroutine 'build_pick_list' was never awaited`. Three properties of that warning matter for test design: 1. **It is a warning, not an error.** By default it is printed once per location and the process exit status is unaffected, so a CI job stays green. 2. **It fires at destruction time, not at call time.** Under reference counting that is usually soon after the last reference drops, but if the coroutine object is captured in a local that outlives the test, or in a cycle awaiting the cyclic collector, the warning can be attributed to a *later* test — or to interpreter shutdown, where nobody reads it. 3. **It says nothing about the assertion that lied.** The test that passed and the warning that was printed are two separate lines of output, often far apart. ### Designing the failure out Three habits remove this class of bug: **Run every async test body under a real runner.** The test's asynchronous body must be awaited by something that owns an event loop for the duration of the test. If a test is `async def`, verify that the runner you use actually awaits it — write a deliberately failing async test once and confirm it goes red. A test suite where a failing async test passes is worth exactly nothing, and that check takes thirty seconds. **Turn the warning into an error.** Running the suite with `python -X dev`, or with warnings configured as errors for `RuntimeWarning`, converts silent leakage into a failure. Because the warning is raised at collection time, pair it with a per-test cleanup that drops references and, where you need determinism, forces a collection so the warning is attributed to the test that caused it rather than to its successor. **Assert on values, not on objects.** `assertIsNotNone(result)` passes for a coroutine object; `self.assertEqual(result, ["AISLE-04/BIN-12"])` cannot. Assertions that pin an actual value are immune to this failure mode, because a coroutine object never equals the data you expected. This is also why a helper that *returns* a coroutine rather than awaiting it is a hazard at every call site: each caller must remember the `await`, and forgetting it fails silently rather than loudly. ### Why interviewers ask it The question separates candidates who have internalised that `async def` is a *factory for suspendable work* from those who read `async` as a decoration that makes a function faster. It also probes test-suite judgement: a candidate who says "the test would fail with a TypeError" has never watched an async suite go green while doing nothing, and a candidate who shrugs at the never-awaited warning has not yet been burned by it. The strong answer names the coroutine object, names the RuntimeWarning and its garbage-collection timing, and closes with the design fix — an async-aware runner, warnings as errors, and assertions on values.
- How do you make the never-awaited RuntimeWarning fail the suite instead of scrolling past?Run the suite with `python -X dev`, or configure warnings so `RuntimeWarning` is raised as an error. Because the warning fires when the coroutine object is destroyed, also drop references in per-test cleanup — otherwise the failure lands on whichever test happens to be running when the collector gets to it, and you debug the wrong test.
- Why does asserting the result is not None hide this bug, and what assertion would not?A coroutine object is a perfectly ordinary object: truthy, not `None`, with a `__name__`. Any assertion that only checks existence passes. An assertion that pins an actual value — comparing against the expected list, string or number — cannot pass, because a coroutine object never equals the data you expected.
- How would you prove your async tests are actually being run at all?Write one async test that asserts something plainly false and confirm the suite goes red. If it passes, the runner is calling your test method and discarding the coroutine it returns, and every async test in the suite is currently worthless.
Calling an async def function is like filling in an order form: you now hold the form, but nothing has been picked. Awaiting it is handing the form to someone who does the work.
saying these in an interview costs you the question
- Says the body starts running as soon as you call the function
- Believes a green async test proves the code under test ran
- Treats the never-awaited RuntimeWarning as harmless noise
- Expects a TypeError when a coroutine is not awaited
- Assumes any test runner will await a returned coroutine
- Thinks assertIsNotNone meaningfully checks an async result