A test can report that assert_awaited_once_with('job-A') passed while the awaited dependency is still suspended behind an asyncio.Event. At that instant, the consumer has no successful result to validate, no terminal outcome to inspect, and no legitimate reason to publish anything into its local release list. That is the false approval this article is designed to eliminate.
The distinction matters because AsyncMock exposes several different observations. A call can exist without an await. An await can have entered without finishing. A task can be done because it failed or was cancelled rather than because it returned the expected value. And a caller can publish local state before the dependency has completed. Python’s API documents call and await records as separate evidence; CPython’s pinned 3.12.14 implementation shows the await record being updated before an async side_effect is awaited; task and Event APIs let a test hold those stages apart deterministically.
The release rule here is intentionally narrow: approve local completion only when the full evidence chain is present: correct call inventory, expected await entry, terminal success, exact result, and correctly ordered local release. Nothing in this laboratory proves a network write, database commit, provider delivery, payment, or exactly-once external effect.
Define what the async test is allowed to approve
The fixture fixes one immutable input, KEY = 'job-A', and one independently declared expected value, EXPECTED = ('job-A', 'accepted'). The consumer owns the local contract: it awaits a dependency, requires the result to be exactly a tuple equal to EXPECTED, appends that value to a local released list only after validation, and returns the same result. Those details matter because the test is not asking whether “something asynchronous happened.” It is asking whether a specific owned consumer reached a specific local postcondition.
That gives the acceptance proposition a precise shape:
The dependency inventory contains exactly the intended call set for this scenario.
The intended invocation entered an await with the immutable job-A argument.
The owned work reached a terminal successful outcome rather than remaining pending, failing, or being cancelled.
The result equals the independently declared tuple, including its concrete tuple type in this fixture.
The local publication boundary contains exactly the approved value, and publication occurred only after successful dependency completion and result validation.
The environment is equally bounded: CPython 3.12.14 for the supplied laboratory run, standard library only, one event loop, an owned consumer, owned tasks, and a plain local list. The Python 3.12 documentation inspected on 2026-10-28 identifies itself as 3.12.16, so it belongs to the same 3.12 reference family but is not the same patch build as the executed interpreter. The implementation-order observation therefore comes from the separately pinned CPython v3.12.14 source, not from pretending every cited artifact is one identical build.
Valid call signatures are already assumed in this article. For that separate prerequisite, see Refonte Learning’s synchronous mock signature checks. This fixture does not repeat autospec, spec, or patch-target guidance; its question starts later, after the argument shape is already valid.
Separate calls, await entry and successful outcomes
The core AsyncMock assert_called vs assert_awaited distinction is documented, but it is easy to overread. The Python 3.12 unittest.mock API, whose rolling branch header displayed 3.12.16 when inspected on 2026-10-28, says an AsyncMock call returns an awaitable and explicitly demonstrates that the mock can be called while assert_awaited() still fails until the returned coroutine is actually awaited. It defines await_count as the number of times the mock has been awaited and await_args_list as the await records in sequence. The same reference marks AsyncMock as added in Python 3.8; it is established functionality, not a feature introduced in 2026.
Those API statements support two independent inventories: call creation and await entry. They do not say that assert_awaited_once_with(...) proves that a useful result was produced. The method’s documented proposition is narrower: the mock was awaited exactly once with the specified arguments.
For the executed build, the pinned CPython v3.12.14 unittest.mock source, inspected on 2026-10-28, sharpens the temporal interpretation. In AsyncMockMixin._execute_mock_call, the implementation constructs an await call record, increments await_count, assigns await_args, and appends to await_args_list before it examines and then awaits an async side_effect. That ordering is the reason a controlled async side effect can set an entry barrier, remain blocked, and still leave assert_awaited_once_with(KEY) true in the supplied experiment.
That source placement is implementation evidence for CPython 3.12.14, not an all-Python completion guarantee. The public assertion contract remains what the API says it is. The controlled test then supplies the concrete counterexample to the stronger claim.
The separate Refonte article on call-time values and mutable arguments addresses a different evidence problem. Here the only argument is the immutable string job-A; the risk is progress and completion, not later mutation of a recorded payload.
An await assertion records entry, not a result
Think of assert_awaited_once_with(KEY) as an entry claim about an invocation, not as a receipt for terminal success. In the inspected CPython v3.12.14 source, the await counters and await arguments are updated before control reaches await effect(*args, **kwargs) for an async side effect. That is enough to make the assertion true while the side effect is suspended.
The testing discipline is simple: distinguish “the assertion executed and passed” from “the proposition asserted is strong enough for release.” Q05, Q06, and Q07 all preserve the first statement. Only Q05, after the gate opens and the owned consumer returns the exact value and publishes it in order, reaches a successful local completion state. Q06 ends cancelled. Q07 exposes a release that happened while the dependency was still pending.
The Python docs define the await assertion and counters; the laboratory establishes the pending case. Do not reverse those roles by claiming the API itself promises a pre-completion counter update, and do not promote the CPython source ordering into a universal interpreter contract.
Create a controlled entry and release boundary
A useful async completion test needs an observation point that does not depend on “waiting long enough.” The standard library already provides the pieces. The Python 3.12 Coroutines and Tasks reference, from the 3.12.16 reference family inspected on 2026-10-28, states that merely calling a coroutine does not schedule its body, while asyncio.create_task() schedules a coroutine as a Task. The same reference defines a Task as done when its coroutine returned, raised, or was cancelled, which is why done() alone is not equivalent to successful completion; terminal result, exception, and cancellation still have to be distinguished.
asyncio.Event supplies the ordering barrier. The Python 3.12 Synchronization Primitives reference, whose inspected branch header was 3.12.16 on 2026-10-28, says an Event starts unset, wait() blocks until its internal flag is set, and set() wakes waiting tasks. That contract lets the fixture establish “entered but not released” without inferring progress from a sleep duration.
The controlled side effect does two observable things around the gate: it appends entered, sets the entered Event, then awaits gate.wait(). Only after the test sets the gate can it append completed and return (key, 'accepted'). The test therefore gets a deterministic place to inspect await_count, the await assertion, task state, event history, and local release state while the dependency is known to be blocked.
The supplied run freezes CPython 3.12.14, Linux x86_64/glibc 2.39, standard library only, one event loop, the ten case IDs, and a SHA-256 for the exact source. It also closes unawaited coroutine objects, cancels and awaits owned pending tasks, and finally rejects any unfinished owned task visible through asyncio.all_tasks(). The docs define all_tasks() as the set of not-yet-finished Tasks for the loop, which makes that last check an appropriate local cleanup control.
Hold the dependency with Events instead of a timing guess
The entry protocol is deliberately asymmetric. entered.set() happens before await gate.wait(). The test waits for entered.wait() to finish, then records a snapshot while gate remains unset. At that point, the side effect has definitely reached its barrier but cannot yet reach the completed append or return statement under this owned fixture.
That is stronger than await asyncio.sleep(...) as evidence of ordering. A sleep can yield control, but elapsed time does not state which semantic checkpoint another task reached. An Event names the checkpoint explicitly.
The two wait_for(..., timeout=2) calls are guardrails for laboratory progress. They prevent a broken fixture from hanging indefinitely; they are not latency measurements, service-level objectives, provider deadlines, or scheduler benchmarks. The deterministic claim is correspondingly narrow: in this single-loop test, the recorded entered barrier precedes gate release, and completed is unreachable through that side effect until the gate is opened. Nothing here generalizes arbitrary production service scheduling.
Run the complete ten-case fixture
Save the following exact source as asyncmock_fixture.py. Its supplied SHA-256 is 9bcd187ddaa96b3fe449e0003ab8ef260fc4e196fa20e7abb9da4c51b3123b9c. The research record states that this source completed with PASS_10_CASES under CPython 3.12.14 on Linux x86_64/glibc 2.39 on 2026-10-28. That is the supplied laboratory observation; it is not being silently replaced here by a run under another interpreter.
"""Call, await-entry and completion are separate local test observations."""
import asyncio, hashlib, inspect, json, platform
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock
KEY = 'job-A'
EXPECTED = (KEY, 'accepted')
def require(ok, message):
if not ok:
raise AssertionError(message)
def assertion_passes(action):
try:
action()
return True
except AssertionError:
return False
async def consume(dependency, released):
result = await dependency(KEY)
if type(result) is not tuple or result != EXPECTED:
raise ValueError('Unexpected completion result')
released.append(result)
return result
def evidence(identity, mock, released, decision, extra):
return {'case': identity, 'call_count': mock.call_count,
'await_count': mock.await_count,
'call_args': repr(mock.call_args_list),
'await_args': repr(mock.await_args_list),
'released': list(released), 'decision': decision,
'status': 'PASS', extra}
async def must_raise(awaitable, kind):
try:
await awaitable
except BaseException as error:
require(type(error) is kind, 'Wrong exception: ' + repr(error))
return type(error).__name__
raise AssertionError('Expected exception absent')
async def stop(task):
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def exercise():
rows = []
m, released = AsyncMock(return_value=EXPECTED), []
pending = m(KEY)
try:
called = assertion_passes(lambda: m.assert_called_once_with(KEY))
awaited = assertion_passes(lambda: m.assert_awaited_once_with(KEY))
require(called and not awaited and inspect.getcoroutinestate(pending) == 'CORO_CREATED',
'Q01 call-only distinction')
rows.append(evidence('Q01', m, released, 'REPAIR_MISSING_AWAIT',
called_assertion=called, awaited_assertion=awaited))
finally:
pending.close() # Explicit ownership, no garbage-collection warning dependency.
m, released = AsyncMock(return_value=EXPECTED), []
result = await consume(m, released)
m.assert_called_once_with(KEY); m.assert_awaited_once_with(KEY)
require(result == EXPECTED and released == [EXPECTED], 'Q02 success')
rows.append(evidence('Q02', m, released, 'ACCEPT_LOCAL_COMPLETION'))
m, released = AsyncMock(return_value=('other-job', 'accepted')), []
error = await must_raise(consume(m, released), ValueError)
m.assert_awaited_once_with(KEY)
require(released == [], 'Q03 wrong result released')
rows.append(evidence('Q03', m, released, 'REJECT_WRONG_RESULT', error=error))
m, released = AsyncMock(side_effect=LookupError('synthetic failure')), []
error = await must_raise(consume(m, released), LookupError)
m.assert_awaited_once_with(KEY)
require(released == [], 'Q04 error released')
rows.append(evidence('Q04', m, released, 'REJECT_FAILED_OUTCOME', error=error))
for identity, mode in [('Q05', 'finish'), ('Q06', 'cancel'), ('Q07', 'premature')]:
entered, gate, released, events = asyncio.Event(), asyncio.Event(), [], []
async def controlled(key):
events.append('entered'); entered.set()
await gate.wait()
events.append('completed')
return (key, 'accepted')
m = AsyncMock(side_effect=controlled)
if mode == 'premature':
task = asyncio.create_task(m(KEY))
released.append(EXPECTED) # Deliberately faulty caller publication.
else:
task = asyncio.create_task(consume(m, released))
try:
await asyncio.wait_for(entered.wait(), timeout=2)
m.assert_awaited_once_with(KEY)
before = {'task_done': task.done(), 'released': list(released),
'events': list(events), 'awaited_assertion': True}
require(not task.done() and events == ['entered'], identity + ' barrier')
if mode == 'finish':
require(released == [], 'Q05 premature release')
gate.set()
result = await asyncio.wait_for(task, timeout=2)
require(result == EXPECTED and released == [EXPECTED]
and events == ['entered', 'completed'], 'Q05 completion')
decision = 'ACCEPT_ONLY_AFTER_COMPLETION'
elif mode == 'cancel':
task.cancel()
error = await must_raise(task, asyncio.CancelledError)
require(task.cancelled() and released == [] and events == ['entered'], identity)
decision = 'REJECT_CANCELLED_OUTCOME'
else:
require(released == [EXPECTED] and events == ['entered'], identity)
decision = 'REJECT_PREMATURE_RELEASE'
rows.append(evidence(identity, m, released, decision, before=before,
events=list(events), task_done=task.done(),
task_cancelled=task.cancelled()))
finally:
await stop(task)
m, released = AsyncMock(return_value=EXPECTED), []
unawaited = m(KEY)
try:
await consume(m, released)
m.assert_awaited_once_with(KEY)
called_once = assertion_passes(lambda: m.assert_called_once_with(KEY))
require(not called_once and m.call_count == 2 and m.await_count == 1, 'Q08 inventory')
rows.append(evidence('Q08', m, released, 'REJECT_EXTRA_UNAWAITED_CALL',
called_once_assertion=called_once, awaited_assertion=True))
finally:
unawaited.close()
events, released = [], []
async def local_reference(key):
events.append(('completed', key))
return (key, 'accepted')
m = AsyncMock(wraps=local_reference)
await consume(m, released)
m.assert_called_once_with(KEY); m.assert_awaited_once_with(KEY)
require(events == [('completed', KEY)] and released == [EXPECTED], 'Q09 wrapped reference')
rows.append(evidence('Q09', m, released, 'ACCEPT_LOCAL_REFERENCE', events=events))
m, released = AsyncMock(), []
error = await must_raise(consume(m, released), ValueError)
m.assert_awaited_once_with(KEY)
require(released == [], 'Q10 unconfigured return released')
rows.append(evidence('Q10', m, released, 'REJECT_UNCONFIGURED_RESULT', error=error))
require([r['case'] for r in rows] == ['Q%02d' % n for n in range(1, 11)], 'Case inventory')
others = [t for t in asyncio.all_tasks() if t is not asyncio.current_task() and not t.done()]
require(not others, 'Owned task left pending')
return rows
if name == '__main__':
started = datetime.now(timezone.utc).isoformat()
rows = asyncio.run(exercise())
print(json.dumps({'status': 'PASS_10_CASES', 'python': platform.python_version(),
'platform': platform.platform(), 'started_utc': started,
'finished_utc': datetime.now(timezone.utc).isoformat(),
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'cases': rows}, indent=2))Run it with the declared interpreter and capture the JSON artifact:
python3.12 asyncmock_fixture.py > asyncmock-results.jsonDo not reduce the manifest to “Python 3.12.” The output records platform.python_version(), platform details, UTC start and finish times, the source hash, every case row, and the overall status. A run under 3.12.15, 3.12.16, 3.13, PyPy, or another environment is a new observation and should retain its own manifest and output. If a new run disagrees with the supplied 3.12.14 record, the discrepancy is evidence to investigate; it is not legitimate to overwrite the observed value with the expected one and continue the acceptance claim.
The ten cases are not ten deployable behaviors. Several are deliberately defective negative controls that pass because the fixture successfully detects the defect. PASS_10_CASES means the laboratory observed the expected acceptance and rejection behavior for the full inventory. If setup fails, a case assertion fails, the inventory is incomplete, or JSON is never emitted, the operational disposition is HOLD, not “probably equivalent.”
Ownership is explicit throughout. Q01 closes the created-but-unawaited coroutine. Q08 closes the extra unawaited coroutine after recording the inventory defect. Q06 and Q07 cancel and await their owned tasks during cleanup. The final all_tasks() check rejects unfinished owned work left on the loop. Python documents that a mere coroutine call does not schedule execution, while Tasks are the scheduling unit used here.
This is also why the nearby Refonte discussion of coroutine objects and completed values remains a separate problem. The present fixture does not cache or reuse a coroutine; each scenario creates fresh work. The common lesson is only that an operation object and a completed value are different evidence.
Catch a dependency that was called but never awaited
Q01 is the baseline distinction. pending = m(KEY) records one call and returns an awaitable. Before that object is awaited or scheduled, m.assert_called_once_with(KEY) passes, m.assert_awaited_once_with(KEY) fails, call_count is 1, await_count is 0, and inspect.getcoroutinestate(pending) is CORO_CREATED. The supplied ledger therefore emits REPAIR_MISSING_AWAIT.
That behavior aligns with two documented contracts. The AsyncMock reference says a call produces an awaitable and explicitly separates “called” from “awaited.” The asyncio coroutine documentation says simply calling a coroutine does not schedule it to execute; a coroutine object can exist without its body having run.
This is the AsyncMock unawaited coroutine control that prevents an interaction-only test from approving work that never started. It is not enough to see a correct call argument in call_args_list. For this local acceptance contract, the intended dependency invocation must also appear in the await evidence, and the consumer must later establish terminal success and publication.
The repair belongs to the owned consumer or the owned test. If the application was supposed to await dependency(KEY), repair that execution path. If the test intentionally created a coroutine only to inspect call creation, then the test must not label the scenario completed. Either way, the distinction is explicit rather than delegated to runtime warnings.
Close the owned coroutine after recording the failure
Q01’s pending.close() is cleanup, not execution. It prevents the fixture from relying on garbage collection to emit an “unawaited coroutine” warning, and it makes ownership visible in the test source. The call remains unawaited evidence; closing the coroutine afterward does not retroactively make await_count become 1 or produce EXPECTED.
That ordering matters in a reliability review. First record the failed acceptance condition. Then clean up the object the fixture created. Do not relabel cleanup as successful behavior.
The advice is scoped to this owned coroutine. It is not a blanket instruction to close arbitrary coroutine objects that another component owns, nor is it a recommendation to suppress runtime warnings globally. The operational decision remains REPAIR_MISSING_AWAIT because the intended local execution never entered the await path.
Require the exact result before local publication
Q02, Q03, and Q10 show why AsyncMock completion testing needs a result oracle independent of the await record. In Q02, the mock is explicitly configured with return_value=EXPECTED. The consumer awaits it, receives exactly ('job-A', 'accepted'), validates both tuple type and value, appends once to released, and returns the same tuple. Both call and await assertions pass. Within this local contract, that is sufficient for ACCEPT_LOCAL_COMPLETION.
Q03 changes only the configured result: ('other-job', 'accepted'). The dependency is still awaited once with job-A, so assert_awaited_once_with(KEY) passes. But the consumer compares the returned value with its independent expected tuple, raises ValueError, and leaves released empty. The correct decision is REJECT_WRONG_RESULT.
Q10 leaves AsyncMock unconfigured. The Python 3.12.16 reference says that when no side_effect is defined, the async function returns return_value, and by default that is another AsyncMock object. That object is not the declared tuple, so the consumer raises ValueError; again, the await assertion passes while completion acceptance fails.
This is a particularly useful control because an unconfigured async mock can make interaction assertions look healthy while supplying a meaningless result to business code. The Python 3.12 unittest.mock examples, inspected in the rolling 3.12.16 reference family on 2026-10-28, also frame configuration and interaction assertions as bounded testing mechanisms; their asynchronous context-manager example uses await-specific assertions for the async protocol, not as proof of a provider’s external effect.
An explicit return value is part of the test contract
For a mocked success path, configure the response that the consumer is expected to validate and keep the expected value independently authored. In this fixture, EXPECTED is declared before the mock is built; the assertion does not copy whatever value the mock happened to return and then congratulate itself for equality.
The consumer’s type(result) is tuple check is intentionally strict for this synthetic contract. It demonstrates that a result assertion can include both type and value before local publication. It is not a universal recommendation that every production API reject tuple subclasses or use tuples at all.
Configuration remains setup, not verification. AsyncMock(return_value=EXPECTED) tells the test double what to return. Acceptance comes from observing the consumer receive that value, validate it, return it, and append it only after the awaited operation completes. None of those local facts establishes that a real external dependency would return the same value.
Keep exceptions separate from successful completion
Q04 demonstrates the failed terminal path. Its AsyncMock has side_effect=LookupError('synthetic failure'). The consumer awaits dependency(KEY), the LookupError propagates, assert_awaited_once_with(KEY) passes, and released remains empty. The decision is REJECT_FAILED_OUTCOME.
This is where task lifecycle vocabulary prevents sloppy acceptance logic. Python’s Task documentation says done() becomes true when the wrapped coroutine returned a value, raised an exception, or was cancelled. It separately defines result() so that a successful result is returned, a task exception is re-raised, and cancellation raises CancelledError. A terminal task is therefore not synonymous with a successful task.
The same principle applies even when the test awaits the coroutine directly rather than calling task.result(): the terminal outcome has to be classified. For ACCEPT_LOCAL_COMPLETION, “raised the expected synthetic failure” is useful negative-control evidence but not success evidence.
There is a related test-design problem in Refonte Learning’s article on reachable post-exception assertions. That page concerns assertions that never execute after an exception inside a pytest.raises scope. The present fixture is different: the misleading await assertion does execute, and it passes. The weakness is not reachability; the proposition itself is too narrow to certify successful completion.
Do not add an imaginary rollback claim. Q04 establishes that this local consumer did not append to its local list after the synthetic LookupError. It does not establish that a transaction was rolled back, that a remote write did not happen, or that an external provider is unchanged. Those effects are not implemented by this laboratory.
Observe a passing assertion while the task is still pending
Q05 is the decisive AsyncMock pending side_effect control. The mock wraps the controlled async side effect. consume(m, released) is scheduled as an owned task. The side effect records entered, sets the entry Event, and then blocks on gate.wait(). The test waits for the entry Event and immediately runs m.assert_awaited_once_with(KEY).
At that deliberate entry-time snapshot, four facts coexist: the await assertion is true; task.done() is false; events == ['entered']; and released == []. The dependency has entered, but the consumer has not received a result and has not crossed its publication boundary. This snapshot demonstrates a limitation of assert_awaited_once_with in the supplied CPython 3.12.14 run.
The implementation observation explains why that state is possible: CPython 3.12.14 increments and records await evidence before awaiting an async side_effect. The Event contract explains how the fixture knows the side effect is held at that checkpoint rather than merely “probably still running.”
Only then does the fixture call gate.set(). The side effect appends completed, returns ('job-A', 'accepted'), the consumer validates the exact result, appends it to released, and returns it. The owned task is awaited to obtain that result. The final state is events == ['entered', 'completed'], one exact local release, and the expected return value. Q05 records ACCEPT_ONLY_AFTER_COMPLETION.
Keep those two observations separate in reports. The first is an entry snapshot that proves a false approval would be possible if the test stopped at the await assertion. The second is terminal success evidence. Combining them into “awaited means finished” destroys the very distinction the case was built to measure.
Reject cancellation and premature publication
Q06 and Q07 use the same entry barrier to show two different ways a test can satisfy the await assertion and still fail release criteria.
Q06 schedules the owned consumer and waits until the controlled dependency has entered and blocked at the gate. At that point, assert_awaited_once_with(KEY) passes while the task is pending. The test then cancels the task. Python documents that cancelling a Task requests cancellation by injecting CancelledError into the wrapped coroutine; cancelled() becomes true only when cancellation was requested and the wrapped coroutine propagated that cancellation. In the supplied case, the task is cancelled, the completed event is absent, no result is accepted, and released stays empty. The decision is REJECT_CANCELLED_OUTCOME.
That is the AsyncMock task cancellation test control: an await record survives a terminal outcome that is explicitly not success. Cancellation is not a special flavor of successful completion, and a call/await counter cannot make it one.
Q07 tests premature publication from the opposite direction. It schedules m(KEY) directly, then deliberately appends EXPECTED to released before the controlled dependency is allowed through its gate. After the entry Event fires, the snapshot records task_done: false, task_cancelled: false, events: ['entered'], and a nonempty release list. assert_awaited_once_with(KEY) passes. The evidence is intentionally bad: local publication occurred while the dependency was still pending. The decision is REJECT_PREMATURE_RELEASE.
That release must remain visible in the evidence. Cleaning up the pending task afterward does not rewrite the historical fact that publication happened too early. This is why entry-time and cleanup-time observations need separate fields rather than a single final-state summary.
Read the observation before reading the cleanup outcome
Q07’s reported task_done:false is a pre-cleanup snapshot. In the finally block, stop(task) sees the task is not done, cancels it, awaits it, and consumes CancelledError. The final fixture-level all_tasks() check then verifies that no unfinished owned task remains. Python’s task APIs make both states legitimate to record: a Task can be pending at one observation point and later be cancelled and fully reaped during cleanup.
Do not normalize the earlier row to “cancelled” merely because cleanup later cancelled it. The defect under test is premature publication during pending execution, so the pending snapshot is the material evidence. Cleanup answers a different question: did the laboratory leave owned work behind?
The same limit applies to Q06. Cancelling this local task establishes the terminal state of the owned fixture task. It does not prove that an external side effect, had one existed, was reversed. The fixture implements no remote operation, and cancellation semantics alone would not justify such a claim.
Reconcile every call with the intended await inventory
Q08 catches the mirror-image inventory defect: one legitimate awaited invocation exists, but there is also an extra call whose returned coroutine is never awaited. The case first executes unawaited = m(KEY), which increments call history. It then runs the real local consumer, which calls and awaits m(KEY) again, validates EXPECTED, and appends one legitimate release.
At that point, call_count == 2 and await_count == 1. m.assert_awaited_once_with(KEY) passes because exactly one await with that argument occurred. m.assert_called_once_with(KEY) fails because there were two calls. The correct decision is REJECT_EXTRA_UNAWAITED_CALL, even though the awaited invocation itself completed successfully and produced one correct local release.
This is why analyzing AsyncMock call and await counts requires both inventories. call_args_list answers which call objects were created and with which arguments; await_args_list answers which invocations entered the mock’s await execution. The API documents those stores separately. Neither count is a substitute for the other.
The defect is not that await_count “should always equal” call_count as a universal rule. Some designs may intentionally create or pass around awaitables before another owner awaits them. The contract here is narrower: this owned consumer scenario expects one dependency call and one corresponding await. An extra unused invocation violates that declared inventory, so the test must reject it.
One awaited call can coexist with an extra unused coroutine
Q08 is a useful antidote to an overstrong reading of “awaited once.” The phrase constrains the await inventory, not the call inventory. One awaited call can coexist with another unawaited coroutine, and the standard call assertion exposes that extra creation.
Because the input is immutable, the two histories can be reconciled directly: two calls each show job-A, while only one await record shows job-A. There is no mutable-argument reconstruction problem here.
The fixture closes the unused coroutine in finally, again separating evidence from cleanup. Closing it prevents a warning dependency but does not erase the extra call. Nor does numeric equality, in a different test, automatically prove correct ordering or identity. The acceptance rule should compare the actual expected call and await inventories required by the owned behavior, not merely assert that two integers happen to match.
Add a completed local reference control
Q09 provides a second positive control with different setup. Instead of supplying a configured return_value, the mock uses wraps=local_reference, where local_reference is a real small async function owned by the fixture. That function records ('completed', key) and returns (key, 'accepted'). consume() awaits the wrapped call, validates the exact tuple, then publishes it to released.
The resulting evidence is intentionally local but useful: one call, one await, the wrapped function’s completion marker, one exact result, and one correctly ordered local release. The decision is ACCEPT_LOCAL_REFERENCE. CPython’s pinned AsyncMock implementation shows that when mockwraps is an async function, executemock_call awaits the wrapped function and returns its result. This makes Q09 a stronger comparator than a bare interaction assertion, while still stopping far short of an integration test.
The supplied ten-case ledger is the operational map:
Case | Observation | Call / await counts | Terminal or pending evidence | Local release | Decision |
Q01 | Called only; CORO_CREATED; called assertion passes, await assertion fails | 1 / 0 | Never entered await execution | None | REPAIR_MISSING_AWAIT |
Q02 | Exact configured result returned and consumed | 1 / 1 | Successful exact result | One expected tuple | ACCEPT_LOCAL_COMPLETION |
Q03 | Wrong-job result rejected with ValueError; await assertion passes | 1 / 1 | Terminal wrong result | None | REJECT_WRONG_RESULT |
Q04 | LookupError propagates; await assertion passes | 1 / 1 | Terminal failure | None | REJECT_FAILED_OUTCOME |
Q05 | Entry snapshot passes await assertion while pending; after gate, completion marker and exact result appear | 1 / 1 | Pending before gate; successful after gate | None before gate; one after completion | ACCEPT_ONLY_AFTER_COMPLETION |
Q06 | Cancelled after entry; await assertion passes; no completion marker | 1 / 1 | Terminal cancellation | None | REJECT_CANCELLED_OUTCOME |
Q07 | Deliberate publication while dependency remains pending | 1 / 1 | Snapshot has task_done:false | Premature tuple | REJECT_PREMATURE_RELEASE |
Q08 | Two calls, only one await; await-once passes, call-once fails | 2 / 1 | One legitimate invocation completed; one unused coroutine | One legitimate tuple | REJECT_EXTRA_UNAWAITED_CALL |
Q09 | Wrapped local async function records completion before consumer release | 1 / 1 | Successful wrapped-local result | One expected tuple | ACCEPT_LOCAL_REFERENCE |
Q10 | Default unconfigured result is not the declared tuple; await assertion passes | 1 / 1 | Terminal wrong/unconfigured result | None | REJECT_UNCONFIGURED_RESULT |
Q02 and Q09 are complementary successful controls. Q02 shows a deliberately configured test double whose result is independently validated. Q09 shows a real local async function executing through wraps. Neither says anything about a real provider. The Python mock documentation says AsyncMock can use side_effect, return_value, or wrapping behavior to define the awaited outcome; those mechanisms are test-double semantics, not evidence of external delivery.
Build the completion acceptance matrix
The release gate should be conjunctive. Do not rename assert_awaited_once_with to “completion check” and leave the evidence unchanged. Instead, require each layer that the local claim actually depends on.
Evidence gate | Accept condition | Reject or hold examples |
Call inventory | Exactly the owned scenario’s intended calls, no extras | Q01 is called but not awaited; Q08 has an extra call |
Await entry | Intended invocation appears in await evidence with job-A | Q01 has await_count == 0 |
Terminal outcome | Owned work finishes successfully | Q05 pre-gate is pending; Q04 fails; Q06 cancels |
Exact result | Result type/value equals independently declared EXPECTED | Q03 wrong job; Q10 default mock result |
Release ordering | released changes only after successful result validation | Q07 publishes while pending |
Cleanup/inventory completeness | All Q01 to Q10 cases present; no unfinished owned tasks | Missing row, assertion/setup failure, leaked task => HOLD |
For this fixture, ACCEPT_LOCAL_COMPLETION is justified only when all applicable gates are true at the terminal observation. Q02 satisfies them directly. Q05 demonstrates why the same invocation must remain unaccepted at the entry snapshot and can become acceptable only after the gate is released, the owned task is awaited, the exact result is retrieved, and the release list changes in the declared order. Q09 is a positive local-reference control, not a provider certification.
Fail closed on missing evidence. A passing negative control means the test successfully detected an anticipated defect; it does not make the defective behavior deployable. If Q03 “passes” as a negative control, the lesson is that the wrong result was rejected, not that wrong results are safe. If Q07 “passes,” the fixture has exposed premature release.
Repair should target the owned defect: add the missing await, remove the extra call, validate the exact result, propagate or handle failure according to the consumer’s real contract, defer publication until validated completion, or strengthen assertions so they inspect the declared evidence. Then rerun the full bounded matrix. Do not add retries that merely make the test green while hiding the original lifecycle defect.
Give the async test an owner and an evidence bundle
A release-quality async test should leave enough evidence for another maintainer to reproduce the judgment without guessing which stage “awaited” was intended to mean. For this laboratory, the bundle is compact but explicit: runtime patch version, platform, fixture hash, complete Q01 to Q10 inventory, call and await counts, call and await arguments, entry-time snapshots where relevant, terminal outcome, exact result status, local release state, and cleanup status.
The maintainer of the consumer/test owns that bundle because the acceptance claim is local. The fixture knows which task it created, which list constitutes publication, which tuple is expected, and which cases are mandatory. A framework cannot infer those business boundaries from await_count alone.
The upgrade gate is equally concrete. The public Python docs inspected here are the rolling 3.12.16 documentation, accessed 2026-10-28; the executed laboratory and implementation source are pinned to CPython 3.12.14. The source placement of await-record updates is therefore version-specific evidence for that build. A later CPython patch, a different interpreter, or changed mock implementation deserves a fresh observation if the acceptance argument depends on that ordering.
That does not mean every interpreter upgrade requires reinventing the whole test. It means the evidence bundle must say what actually ran, and reviewers must avoid claiming that a 3.12.14 implementation detail was guaranteed by a 3.12.16 API page. The public API still supports the stable, narrower distinction between calls and awaits; the controlled case verifies the stronger temporal observation for the pinned laboratory.
Keep cleanup material as well. Q01 and Q08 own coroutine objects that they explicitly close. Q06 and Q07 own Tasks that they cancel and await when needed. The final unfinished-task inventory must remain empty. Those controls reduce ambiguity about whether a “passing” JSON artifact was produced while owned asynchronous work was still abandoned.
For the broader discipline around repeatable test evidence, automation ownership, and release-oriented QA decisions, see Refonte Learning’s broader QA automation practice. This article deliberately stays inside one AsyncMock completion contract rather than importing that page’s career or market claims.
The boundary statement belongs in the bundle too: one event loop, local list only, no network/database/provider effect, no exactly-once claim, no transaction commit claim, no timing SLA, and no inference that task cancellation reverses work outside the fixture. A local test is strongest when it states both what it proves and what it does not implement.
Develop the QA judgment behind stronger assertions
The durable skill here is not memorizing one AsyncMock method. It is learning to decompose a release claim into observable stages and to make acceptance depend on the stage that actually matters. assert_awaited_once_with is valuable evidence of await usage with specific arguments; it becomes dangerous only when a test silently upgrades that proposition into “the operation completed successfully.” The ten-case matrix makes that upgrade visible and rejectable.
Refonte Learning’s Quality Assurance Engineering programme is described on its current page as a three-month programme with a commitment of 12 to 14 hours per week, and its curriculum highlights automated test scripts, test automation frameworks, CI/CD integration, performance/security testing, and a capstone project. Those are relevant foundations for building stronger test-design judgment, but the page does not confirm that AsyncMock, asyncio, this exact fixture, or CPython 3.12.14 implementation analysis is taught.
The programme page also contains two different admissions phrasings: one says applicants may be pursuing or have completed a related bachelor’s degree, while another says applicants must be working toward a bachelor’s or higher-level degree. Prospective learners should check the current applicability of those statements rather than assume one silently overrides the other. The page uses “Potential Internship” wording and lists certificates on successful completion; it should not be read here as a promise of external placement.
For this article’s operational question, the final rule is narrower and testable: do not approve local completion from an await record alone. Approve it only from the declared conjunction of inventory, await entry, successful terminal outcome, exact result, correctly ordered release, complete case evidence, and owned cleanup.
