Isolation & Contracts

Asserting Await Order with AsyncMock

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

Solution

Python
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"})
Per-mock records versus a shared parent record Two independent AsyncMocks each record their own awaits, so neither list reveals the relative order of save and publish. Attaching both to a shared parent produces one combined list in real time order, from which a single comparison asserts that save came before publish. Each mock knows its own history; the parent knows the order save.await_args_list [call(order)] publish.await_args_list [call("order_created", …)] which first? parent.mock_calls [call.save(order), call.publish("order_created", …)]
The per-mock lists answer "how was this called"; only the parent's list answers "in what order did things happen".

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 gather or a task group starts coroutines in one order and completes them in another. mock_calls records the start order. If completion order matters, give the doubles side effects that record completion.
  • Children created by attribute access. parent.save auto-created on a MagicMock is a plain MagicMock, not an AsyncMock. Create the async children explicitly and attach them.
  • Autospecced children. create_autospec mocks 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.

Python
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.

Creation order versus completion order under gather Under gather, the primary and replica coroutines are created in argument order but complete in whichever order their I/O finishes. mock_calls reflects creation order only. A side effect that records completion captures the real sequence, which lets the test assert that the acknowledgement follows both writes without constraining their relative order. Record the order you actually mean mock_calls (creation) primary, replica, ack always argument order says nothing about completion finished (completion) replica, primary, ack the real sequence assert ack is last, writes unordered
Sequential code can use either record. Concurrent code must use the completion record and assert only the orderings the contract promises.

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.

Why persist-then-publish is the safe order Two orders for a dual write. Publishing first risks a consumer receiving an event for a record that was never persisted if the write then fails, which is unrecoverable. Persisting first risks a record without its event if publishing fails, which an outbox or retry can recover. The tests assert the safe order and that no publish occurs when the save fails. The order decides which failure you can recover from publish, then save event sent · write fails consumers act on a record that does not exist not recoverable save, then publish write succeeds · publish fails record exists without event outbox or retry sends it later recoverable
Asserting the order is how a test encodes this reasoning; asserting that a failed save produces no publish is how it proves it.

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.

← Back to Patching Async Code & Coroutines