Use pytest-asyncio or IsolatedAsyncioTestCase to run coroutines, fake slow dependencies, and control the clock
Async code needs an event loop to run, so a plain test function cannot just await. The modern options are pytest-asyncio with @pytest.mark.asyncio, which gives you an async test function and a loop per test, or unittest.IsolatedAsyncioTestCase if you are staying in the stdlib. Both give a fresh event loop and clean up pending tasks, which matters because leftover tasks across tests cause flakiness. For dependencies, use AsyncMock to fake coroutine collaborators and assert with assert_awaited_once_with. For timing, avoid real sleeps: patch asyncio.sleep or use a library like aioresponses or a faker clock, because real sleeps make the suite slow and nondeterministic. Test cancellation and timeouts explicitly, because those paths are the ones that break in production.
pytest-asyncio with @pytest.mark.asyncio or asyncio_mode = auto is the common setup.
unittest.IsolatedAsyncioTestCase is the stdlib option and works with the standard runner.
AsyncMock (3.8+) is required to mock coroutine functions. A regular Mock returns a coroutine that is never awaited and the test will warn or fail.
Use asyncio.wait_for to test timeouts, and assert on asyncio.TimeoutError or TimeoutError in 3.11+.
Test task cancellation by cancelling and asserting on the cleanup path.
Trade-off: loop-per-test is more isolated but slower than a shared loop. Loop-per-test is the safer default.
Common mistake: leaving background tasks running after the test, which leaks into other tests and causes flaky failures.
Common mistake: patching time.sleep when the code awaits asyncio.sleep. Patch the right function.
Version note: asyncio.TimeoutError was unified with built-in TimeoutError in 3.11, which simplifies exception assertions.
0-2 years experience
2-5 years experience
5-8 years experience
8+ years experience