Async Mocks: Assert Awaited, Not Called
Called but never awaited. It is the async test trap that passes green. When your code calls an async function but drops the await, Python creates the coroutine object and throws it away — the work never runs. And if that function is an AsyncMock in your test, assert_called_once() still passes, because calling is exactly what happened. The test is green; production is broken.
Why called ≠ awaited
Since Python 3.8, unittest.mock ships AsyncMock, which tracks two separate ledgers:
- Call tracking —
call_count,called,assert_called_once(): incremented the moment the mock is called, i.e. when the coroutine object is created. - Await tracking —
await_count,await_args,assert_awaited_once(): incremented only when that coroutine is actually awaited.
A forgotten await bumps the first ledger and never touches the second. Straight from the standard-library docs:
>>> mock = AsyncMock()
>>> coro = mock() # called — but never awaited
>>> mock.called
True
>>> mock.assert_awaited()
AssertionError: Expected mock to have been awaited.
So assert_called_once() is the wrong question for an async dependency. It asks "did you build the coroutine?" — not "did the work run?"
The one-word fix
# Fragile: passes even if the await is missing
service.send_email.assert_called_once_with(user.id)
# Correct: fails loudly when the coroutine was never awaited
service.send_email.assert_awaited_once_with(user.id)
AsyncMock mirrors the whole call-assertion family on the await side, so the migration is mechanical:
| Call-side assertion | Await-side equivalent |
|---|---|
assert_called() | assert_awaited() |
assert_called_once() | assert_awaited_once() |
assert_called_with(...) | assert_awaited_with(...) |
assert_called_once_with(...) | assert_awaited_once_with(...) |
assert_any_call(...) | assert_any_await(...) |
assert_not_called() | assert_not_awaited() |
call_count / call_args_list | await_count / await_args_list |
Rule of thumb: if the mock is an AsyncMock, every called in your assertions should read awaited. This matters double when an AI assistant writes your tests — models trained on years of sync-mock code reach for assert_called_once by default, so review generated async tests for exactly this substitution.
Why this bug loves AI-generated code
A missing await is a one-token diff that type-checks in plain Python, runs without an exception, and only surfaces as a RuntimeWarning: coroutine '...' was never awaited — printed after the fact, not raised. Assistants also produce it when converting sync code to async, because every call site needs a manual edit. Your tests are the net; assert_awaited_once is what makes the net actually catch.
Power-user moves
- Turn the warning into a failure. Python emits
RuntimeWarning: coroutine 'x' was never awaitedwhen an unawaited coroutine is garbage-collected. In pytest, promote it: addfilterwarnings = ["error::RuntimeWarning"]to your config and the missingawaitfails the suite even where you forgot the assertion. - Trace where the coroutine was created. Run the loop in debug mode (
asyncio.run(main(), debug=True)) and the never-awaited warning includes a traceback pointing at the exact call site that dropped theawait. - Let autospec pick the mock class.
patch(..., autospec=True)andcreate_autospec()detectasync deffunctions and produce anAsyncMockautomatically — and aMagicMockfor sync methods on the same class — so specs stay honest without hand-wiring. reset_mock()clears both ledgers. It zeroesawait_countand clearsawait_args_listalong with the call side, so per-phase assertions in a long test stay clean.
Resources
AsyncMock— unittest.mock docs (assert_awaited family)assert_awaited_oncereference- Detect never-awaited coroutines — asyncio dev guide
- Turning warnings into errors — pytest docs
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.