skip to content

With unittest.mock, how do you double an `async with` resource and assert it was exited?

level: seniorimportance: should knowfreq 40%

answer

  1. Opening it was never the part in doubt
  2. Assert on the release, not the acquisition
  3. The protocol methods are already async doubles
  4. __aexit__ must be awaited, not merely called
  5. __aexit__.await_args carries the exception triple

basics

~20 s

MagicMock and AsyncMock already expose aenter and aexit as AsyncMocks, so set aenter.return_value to the fake resource and assert aexit was awaited. Match the factory's shape: a synchronous acquire() needs a MagicMock, an awaited one needs an AsyncMock.

solid answer

~40 s

Any `MagicMock` or `AsyncMock` pre-configures `__aenter__`, `__aexit__` and `__anext__` as `AsyncMock` children, so `async with` over one works immediately and `__aexit__` returns `False`, letting exceptions propagate. Configure `manager.__aenter__.return_value` with the fake resource — make it an `AsyncMock` if the block awaits its methods. The shape must match the real call site: for `async with pool.acquire() as s`, `acquire` is synchronous and the manager is `pool.acquire.return_value`; doubling `pool` with an `AsyncMock` instead makes `acquire()` return a coroutine and raises `TypeError: 'coroutine' object does not support the asynchronous context manager protocol`. Then prove the release: `manager.__aexit__.assert_awaited_once()`, and read `__aexit__.await_args` to see which exception class the block exited with — that is the assertion that catches a resource left unclosed on an error path.

code

python · 15 lines
python
import asyncio
from unittest.mock import AsyncMock, MagicMock

async def import_records(pool, records):
    async with pool.acquire() as session:
        for record in records:
            await session.write(record)

pool = MagicMock()                                   # acquire() is sync here
pool.acquire.return_value.__aenter__.return_value = AsyncMock()
asyncio.run(import_records(pool, ["vase", "coin"]))

manager = pool.acquire.return_value
manager.__aexit__.assert_awaited_once()              # the session was released
print(manager.__aenter__.await_count, manager.__aexit__.await_count)   # 1 1

go deeper

for a junior

Know that async with needs __aenter__ and __aexit__, and that a MagicMock already provides both as async doubles, so you rarely have to build them by hand.

for a middle

Explain how to bind the entered resource through __aenter__.return_value, why the entered resource should be an AsyncMock when the block awaits its methods, and that __aexit__ returns False by default so exceptions propagate.

for a senior

Demonstrate the production instinct: assert the resource was released, on the error path as well as the happy one, and match the double's shape to whether the real factory is awaited. This is the test that catches a leak before it reaches an operator.

for a principal

Own the standard that resource-lifecycle claims are asserted through the protocol rather than through incidental methods, and decide where the team draws the line between doubling a managed resource and exercising it against a real one.

`async with` requires an object with `__aenter__` and `__aexit__` that are **awaitable**. Doubling one is mostly a matter of matching the real object's shape, and then asserting on the exit — which is the assertion teams forget, and the reason a resource leak survives a green suite. ### What the mock library gives you for free Both `MagicMock` and `AsyncMock` pre-configure the async magic methods: on any such double, `__aenter__`, `__aexit__` and `__anext__` are themselves `AsyncMock` instances (Python 3.8 and later). So `async with MagicMock() as thing:` works with no setup at all, `__aenter__` returns a fresh child mock, and `__aexit__` returns `False`, meaning an exception raised inside the block propagates rather than being swallowed. Two knobs cover almost every test: * `manager.__aenter__.return_value = <the fake resource>` decides what the `as` name is bound to. Without it you get an auto-created child, which is fine when the block only passes it around and wrong when the block awaits methods on it — a child of a `MagicMock` is a `MagicMock`, so make the entered resource an `AsyncMock` if the code awaits its methods. * `manager.__aexit__.return_value = True` makes the double swallow exceptions. Set it only when the real manager does, because a stray `True` turns a failing test green. ### Match the shape of the acquisition, not the word "async" The trap that costs the most time: is the factory synchronous or asynchronous? * `async with pool.acquire() as session:` — `acquire` is a **synchronous** call returning an async context manager. Double `pool` with a `MagicMock` and configure `pool.acquire.return_value.__aenter__.return_value`. * `async with await pool.acquire() as session:` — `acquire` is a coroutine function, so an `AsyncMock` is right, and the manager is its `return_value`. Get it backwards and the failure is a very specific `TypeError`: `'coroutine' object does not support the asynchronous context manager protocol`, because `AsyncMock.acquire()` handed `async with` a coroutine rather than a manager. When the real object comes from a library, the fastest way to be sure is to check whether the production call site awaits the factory. ### Asserting the exit Consider a catalogue importer that opens a session, writes records and is expected to release the session whatever happens. An 11-person team is losing connections in production and cannot see why: the unit test asserts the writes, so it never notices that a validation error on one record escapes before the session is released. Two assertions close that hole permanently: * `manager.__aexit__.assert_awaited_once()` — the block was exited, exactly once. Note it must be *awaited*, not merely called; `assert_called_once` on `__aexit__` would pass even for a coroutine that was dropped. * `manager.__aexit__.await_args[0][0]` — the exception **class** passed to the exit, or `None` for a clean exit. Asserting it is `ValueError` proves the importer left the block on the error path and still released the resource; asserting it is `None` proves the happy path. This is a genuinely stronger claim than "the resource was closed" written as an assertion on a `close()` double, because it verifies the language-level protocol the real object relies on. Where the manager is produced by an `@asynccontextmanager`-decorated generator in production code, prefer patching the factory that returns it, since the decorated object's protocol methods live on the helper object rather than on the generator function. ### Failure modes worth naming * Asserting only `__aenter__` — proves the resource was opened, which is the half that was never in doubt. * Reusing one double for several `async with` blocks: `pool.acquire.return_value` is the *same* manager every time, so `await_count` accumulates across blocks. Assert the count you expect rather than "once" when the code enters twice. * Setting `__aexit__.return_value` truthy by accident (for example by configuring the parent mock with a blanket `return_value`), which suppresses exceptions inside the block. ### The neighbouring protocol `async for` is doubled the same way and often appears in the same test. A `MagicMock` exposes `__aiter__` and an `__anext__` that is itself an `AsyncMock`, and the shortest way to make a double iterable is `stream.__aiter__.return_value = iter([record_a, record_b])`, which the mock adapts to the asynchronous protocol. A cursor that is both an async context manager and an async iterator therefore needs both configured, and forgetting the iteration side is why a block that should have processed rows quietly processes none. ### Version notes `__aenter__`, `__aexit__` and `__anext__` have been pre-configured as `AsyncMock` children on `MagicMock` since Python 3.8, and the behaviour is unchanged on 3.14.

  • Why is asserting on __aexit__ stronger than asserting a close() double was called?
    `async with` guarantees release through the protocol, so `__aexit__` is the method the language actually invokes on every path, including exceptions and cancellation. Asserting it was awaited proves the block was left cleanly, and `await_args` even tells you which exception class it was left with. A `close()` assertion only proves someone called a method you happened to choose.
  • What does __aexit__ returning True do to a test, and when is setting it correct?
    It swallows the exception raised inside the block, so a failing operation looks successful and the test may pass for the wrong reason. Set it only when the real manager genuinely suppresses that exception; otherwise leave the default False. A blanket `return_value` configured on a parent mock is a common way to set it truthy by accident.
  • A double raises `'coroutine' object does not support the asynchronous context manager protocol`. What is wrong?
    The factory was doubled as async when the real one is synchronous, so the call returned a coroutine where `async with` expected a manager. Check whether production code writes `async with pool.acquire()` or `async with await pool.acquire()`: the first wants a MagicMock factory, the second an AsyncMock whose return_value is the manager.

saying these in an interview costs you the question

  • Asserts only that __aenter__ ran, never the exit
  • Uses assert_called_once on __aexit__ instead of assert_awaited_once
  • Doubles a synchronous acquire() with an AsyncMock
  • Leaves the entered resource a MagicMock the block awaits
  • Sets __aexit__ truthy and silently swallows failures
  • Expects a fresh manager from each acquire() on one double

context