Some async code is correct only if things happen in a particular order: persist the order before publishing the order_created event, acquire the lock before reading the balance, commit before acknowledging the message. Each collaborator can be doubled with an AsyncMock, and each double records its own awaits — but none of them knows about the others, so per-mock assertions cannot tell whether the publish happened before or after the write.
That gap is not academic. Ordering bugs in async code are some of the hardest to find in production, because they depend on timing and appear only under load, and the tests that should have caught them usually did assert on each collaborator — just never on the relationship between them. A suite can have full coverage of the save path and full coverage of the publish path and still permit publishing before saving. The tool for the global view is a shared parent mock. Attach each child to it, and the parent records every call to every child in the order they occurred. One list comparison then asserts the sequence, and a per-child await check closes the gap where a coroutine was created but never awaited.
Prerequisites
- Python 3.8+ for
AsyncMock. pytest >= 8.0with an async runner.- The async mocking rules from patching async code and coroutines.
Solution
from unittest.mock import AsyncMock, MagicMock, call
async def test_order_is_persisted_before_the_event_is_published():
parent = MagicMock()
repo_save = AsyncMock()
bus_publish = AsyncMock()
# Children record into the parent's mock_calls in real time order.
parent.attach_mock(repo_save, "save")
parent.attach_mock(bus_publish, "publish")
service = OrderService(save=repo_save, publish=bus_publish)
await service.place(an_order(id="ord_1"))
# The sequence is the contract: write, then announce.
assert [name for name, _args, _kwargs in parent.mock_calls] == ["save", "publish"]
# And both coroutines were actually awaited, not just created.
repo_save.assert_awaited_once()
bus_publish.assert_awaited_once_with("order_created", {"order_id": "ord_1"})
Why this works
attach_mock makes a child mock report its calls to the parent as well as recording them itself. The parent's mock_calls is an ordered list, appended to at the moment each call is made, so it reflects the real interleaving of calls across every attached child. Because asyncio runs one coroutine at a time on a thread, the order in which calls are made is a faithful record of the order the code reached them.
The recorded entries are calls, not awaits. An AsyncMock child records the call when the coroutine is created, and the await separately in its own await_args_list. Checking both — order via the parent, completion via each child — is what makes the assertion airtight.
The parent itself is an ordinary MagicMock and never needs to be passed to the code under test; it exists purely as a recorder. The code receives the children, exactly as it would receive real collaborators, and has no idea it is being observed collectively. That keeps the technique non-invasive: no production code changes, no test-only hooks, just a different way of constructing the doubles the test was going to create anyway.
Edge cases and failure modes
- Ordering an implementation detail. Two independent reads can happen in either order. Asserting one makes the test brittle. Assert order only where the domain requires it.
- Concurrent awaits. Code that uses
gatheror a task group starts coroutines in one order and completes them in another.mock_callsrecords the start order. If completion order matters, give the doubles side effects that record completion. - Children created by attribute access.
parent.saveauto-created on aMagicMockis a plainMagicMock, not anAsyncMock. Create the async children explicitly and attach them. - Autospecced children.
create_autospecmocks can be attached too, and keep their signature checks. Attach them after creation, not by building them from the parent. - Resetting between phases.
parent.reset_mock()clears the combined record, which is useful when a test has a setup phase whose calls should not count.
Start order versus completion order
The parent's mock_calls records when each coroutine was created, which for sequential awaits is the same as when it ran. Concurrent code breaks that equivalence. Under asyncio.gather(save(), publish()) both coroutines are created before either runs, so mock_calls shows them in argument order regardless of which actually finished first — and asserting on it tests the order of the arguments to gather, not the behaviour of the system.
When completion order matters under concurrency, the doubles themselves have to record it. A side_effect that appends to a shared list when the coroutine finishes gives the test the real sequence.
import asyncio
from unittest.mock import AsyncMock
async def test_both_writes_complete_before_ack():
finished: list[str] = []
def recording(name, delay):
async def run(*args, **kwargs):
await asyncio.sleep(delay) # a real suspension point
finished.append(name)
return run
primary = AsyncMock(side_effect=recording("primary", 0.02))
replica = AsyncMock(side_effect=recording("replica", 0.01))
ack = AsyncMock(side_effect=recording("ack", 0))
await replicated_write(primary, replica, ack, payload={"id": 1})
# Replica finishes first, but ack must come after BOTH, whatever their order.
assert finished[-1] == "ack"
assert set(finished[:2]) == {"primary", "replica"}
The assertion is deliberately partial. It pins down the one ordering the contract requires — acknowledge only after both writes — and leaves the order of the two concurrent writes free, because the system does not promise anything about it. Over-specifying concurrent order is the fastest way to write a test that fails intermittently for no reason anyone can fix. The discipline is to write down, before writing the assertion, exactly which pairs of operations the contract orders — and then to assert those pairs and nothing more. Everything else about the sequence is free to vary, and the test should let it. Tests written that way stay green through refactors and fail only when a real guarantee is broken.
When order is the contract
The most common legitimate ordering requirement in async systems is the dual write: persist something, then tell the world about it. Publishing first means a consumer can receive an event for a record that does not exist yet — or never will, if the write then fails. Persisting first means that if publishing fails, the record exists without its announcement, which is recoverable with an outbox or a retry. The order is not a stylistic preference; it decides which failure mode the system has.
Tests of that requirement should assert order and test the failure between the steps. Make the save succeed and the publish raise, and assert the record exists and the error propagated; make the save raise, and assert that publish was never called at all. The second assertion — bus_publish.assert_not_awaited() — is often the more important one, because it is what proves no phantom event can escape when the write fails.
Other orderings worth pinning down in the same way include acquiring a lock before reading state it protects, checking authorisation before performing an action, and closing a stream before reporting completion. In each case the ordering encodes a safety property, and a test that asserts it is documenting that property as much as checking it.
A useful habit is to name such tests after the property rather than the mechanism — test_event_is_never_published_for_an_unsaved_order rather than test_save_called_before_publish. The first survives a refactor that replaces the direct publish with an outbox, because the property still holds and only the assertion's mechanics need updating; the second invites someone to delete it as obsolete. Tests named for guarantees tend to be kept, and tests named for implementation tend to be discarded at exactly the moment the implementation changes and the guarantee most needs checking.
When ordering requirements multiply across a service, it is worth listing them in one place — a short comment block or a design note — so each has exactly one test and none is asserted incidentally elsewhere.
Frequently Asked Questions
How do I check the order of awaits across two different mocks?
Attach both mocks to a common parent with parent.attach_mock(child, "name"). The parent's mock_calls then records every call to every child in the order they happened, so a single list comparison asserts the global sequence.
Does mock_calls distinguish a call from an await?
No. mock_calls records the call that created the coroutine. For AsyncMock children, pair the ordering check with await_count or assert_awaited on each child to confirm the coroutines were actually awaited, not just created.
When is asserting order a bad idea? When the order is an implementation detail rather than a requirement. Two independent reads can happen in either order; asserting a specific one makes the test fail on a harmless refactor. Assert order only where the domain demands it — write before notify, lock before read, commit before publish.
Related
- Patching Async Code & Coroutines — the rules for creating the async doubles.
- assert_called_with vs call_args_list — reading per-mock call histories.
- Patching an Async Context Manager — ordering enter, body and exit.
- Testing Cancellation and Cleanup Paths — ordering that must hold even when a task is cancelled.
← Back to Patching Async Code & Coroutines