Testing asyncio Code with pytest-asyncio¶
A team adds its first async endpoint and writes async def test_create_order(): next to the existing tests. Depending on the pytest version, that test either fails with "async def functions are not natively supported", or — on older setups — is skipped with a warning nobody reads and reported as a pass. After installing pytest-asyncio, the next problem arrives: a module-scoped fixture that creates an HTTP client works for the first test and breaks the second with an error about a different event loop. Then a test that forgot an await passes, and a test with a deadlock hangs the CI job for an hour. Each of these has a one-line fix once you know where it lives. This guide sets up pytest-asyncio 1.x deliberately: the mode, async fixtures with proper teardown, loop scopes that match, a hard limit on hanging tests, and configuration that turns unawaited coroutines into failures.
Prerequisites¶
- Python 3.11+, pytest 8+ and pytest-asyncio 1.x:
pip install pytest pytest-asyncio. The examples were run with pytest 9.1 and pytest-asyncio 1.4. - Optional:
pip install pytest-timeoutfor step 4. - The testing model from Testing Async Code, especially why fixtures must share the test's event loop.
1. Install the plugin and choose a mode¶
pytest cannot run coroutines by itself. Without a plugin, current pytest versions fail an async def test outright, which is the safe outcome; older combinations could skip it silently. pytest-asyncio adds the event loop handling and offers two modes: strict, where only tests marked @pytest.mark.asyncio and fixtures declared with @pytest_asyncio.fixture are treated as async, and auto, where every coroutine test and async fixture is handled automatically.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # "strict" for libraries / mixed async stacks
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
# test_orders.py
import asyncio
async def create_order(items: list[str]) -> dict:
await asyncio.sleep(0)
return {"status": "created", "count": len(items)}
async def test_create_order() -> None: # no marker needed in auto mode
order = await create_order(["sku-1", "sku-2"])
assert order == {"status": "created", "count": 2}
Pick strict mode when the codebase also uses another async framework's pytest plugin, such as trio or anyio, so plugins do not fight over the same tests. Pick auto mode for a pure-asyncio service, where markers on every test are noise. Setting both default loop scopes explicitly removes a configuration warning and documents the choice.
Verify: pytest -q reports the test as passed. Temporarily uninstall the plugin and the same test fails with an unsupported-async message — confirming the test really executes when the plugin is present.
2. Write async fixtures with teardown¶
Async fixtures use the same yield pattern as synchronous ones: setup before the yield, teardown after. The teardown runs on the same event loop even if the test fails, which is where connections, servers and background tasks get closed.
import asyncio
import pytest_asyncio
async def handle_echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
while line := await reader.readline():
writer.write(line)
await writer.drain()
writer.close()
@pytest_asyncio.fixture
async def echo_server():
server = await asyncio.start_server(handle_echo, "127.0.0.1", 0)
host, port = server.sockets[0].getsockname()[:2]
try:
yield host, port
finally:
server.close()
await server.wait_closed() # teardown awaits, on the same loop
async def test_echo_round_trip(echo_server) -> None:
host, port = echo_server
reader, writer = await asyncio.open_connection(host, port)
writer.write(b"ping\n")
await writer.drain()
assert await reader.readline() == b"ping\n"
writer.close()
await writer.wait_closed()
@pytest_asyncio.fixture works in both modes; in auto mode a plain @pytest.fixture on an async def works too, but the explicit decorator keeps the fixture portable if the project later switches to strict mode.
Verify: make the test fail deliberately with assert False; the server is still closed, and running the suite with -W error::ResourceWarning reports no unclosed sockets.
3. Share expensive fixtures with matching loop scopes¶
Creating a database pool or HTTP client for every test is slow, so it is tempting to widen the fixture's scope. Widening the fixture scope without also setting its loop scope — and the loop scope of the tests that use it — is the single most common pytest-asyncio failure.
import asyncio
import pytest
import pytest_asyncio
class Client:
"""Stand-in for any async client that binds to the loop it was created on."""
def __init__(self) -> None:
self.loop = asyncio.get_running_loop()
async def get(self) -> int:
assert asyncio.get_running_loop() is self.loop, "used on a different loop"
await asyncio.sleep(0)
return 1
@pytest_asyncio.fixture(scope="module") # broken: loop scope not set
async def client_wrong():
yield Client()
async def test_wrong(client_wrong) -> None:
assert await client_wrong.get() == 1 # ScopeMismatch or wrong-loop error
@pytest_asyncio.fixture(scope="module", loop_scope="module") # fixture lives on the module loop
async def client():
yield Client()
@pytest.mark.asyncio(loop_scope="module") # ...and so does every test using it
async def test_first(client) -> None:
assert await client.get() == 1
@pytest.mark.asyncio(loop_scope="module")
async def test_second(client) -> None:
assert await client.get() == 1
How the broken fixture fails depends on configuration. With asyncio_default_fixture_loop_scope = "function" set as in step 1, pytest-asyncio refuses it at setup with a ScopeMismatch error, because a module-scoped fixture cannot run on a loop that is torn down after every test — the best outcome. Without that setting, the fixture may be created on one loop and used on another, which the Client class above catches with its assertion; real clients fail less politely — RuntimeError: ... attached to a different loop, a Future that never completes, or Event loop is closed during teardown. To apply a loop scope to a whole module, set pytestmark = pytest.mark.asyncio(loop_scope="module") at the top of the file.
Verify: test_wrong errors — with ScopeMismatch under the step 1 configuration — while test_first and test_second pass and share one client instance — confirm by printing id(client) in both.
4. Put a hard limit on hanging tests¶
An async test that awaits something which never happens — a missing task_done(), an event nobody sets, a mock that never resolves — does not fail; it waits forever. Give every test a deadline so a hang becomes a failure with a name.
# pyproject.toml — pip install pytest-timeout
[tool.pytest.ini_options]
timeout = 10 # seconds per test, including fixture setup and teardown
import asyncio
import pytest
async def test_hangs_forever() -> None:
await asyncio.Event().wait() # nothing will ever set this
@pytest.mark.timeout(60) # a deliberate exception for a slow test
async def test_slow_migration() -> None:
await asyncio.sleep(0)
pytest-timeout interrupts the stuck test from outside the event loop, so it works even when the loop itself is blocked by synchronous code. Inside tests where the code under test has a deadline, also assert on it directly with asyncio.timeout(), because a pytest-timeout failure says only "too slow", not which await was stuck. Deadline behaviour itself is best tested in virtual time, as in controlling time in asyncio tests.
Verify: with --timeout=2, test_hangs_forever fails after about two seconds with Failed: Timeout (>2.0s) from pytest-timeout, and the rest of the suite continues.
5. Fail on unawaited coroutines and leaked tasks¶
A forgotten await is the async bug that tests are worst at catching: calling a coroutine function without awaiting it creates a coroutine object, does nothing, and emits a RuntimeWarning when the object is garbage-collected — after the test has already passed. Turn those warnings into errors.
[tool.pytest.ini_options]
filterwarnings = [
"error::RuntimeWarning",
"error::pytest.PytestUnraisableExceptionWarning",
]
import asyncio
async def save(record: dict) -> None:
await asyncio.sleep(0)
async def test_forgot_await() -> None:
save({"id": 1}) # bug: coroutine created, never awaited
await asyncio.sleep(0)
The "never awaited" warning is raised while the coroutine object is being finalised, where it cannot propagate normally, so pytest reports it as an unraisable exception; the second filter makes that a failure attributed to the test. Pair this with an autouse fixture that fails any test leaving tasks running — the leak detector in the Testing Async Code overview — and the two most common silent async bugs both become red builds. The runtime side of the same bug is covered in debugging unawaited coroutines in large codebases.
Verify: test_forgot_await now fails with PytestUnraisableExceptionWarning, and adding the missing await makes it pass.
Verification¶
The async test setup is sound when:
- Async tests demonstrably run: removing the plugin makes them fail, and coverage shows their lines executed.
- Fixtures clean up on failure: servers, clients and tasks created in fixtures are closed even when the test body fails.
- Loop scopes match: every fixture with a wider
scopedeclares a matchingloop_scope, and so does every test using it; no "different loop" errors appear when tests run in any order. - No test can hang CI: a global per-test timeout exists, with explicit, commented exceptions.
- Silent async bugs fail loudly: unawaited coroutines and leaked tasks produce test failures, not warnings in the log.
Pitfalls & edge cases¶
- Session-scoped loops and test isolation. A session loop lets one test's leftover tasks or cached futures affect another. Reserve wide loop scopes for read-only shared clients, never for queues, locks or mutable state.
- Custom event loop policies. Older suites override an
event_loopfixture to install uvloop or a custom loop; that fixture was removed in pytest-asyncio 1.0. Use theasyncio_event_loop_policyhook or aloop_factoryapproach supported by your plugin version instead. - Mixing
IsolatedAsyncioTestCaseand markers. Aunittestasync test case manages its own loop; do not add@pytest.mark.asyncioto its methods. - Blocking calls in fixtures. A synchronous database migration run inside an async fixture blocks the loop and can trip the fixture's own timeouts. Run it with
asyncio.to_thread()or in a synchronous session fixture before the loop starts. - Parallel test runners. With
pytest-xdist, each worker process has its own loops and session fixtures. Shared external resources, such as a test database, need per-worker names.
Frequently Asked Questions¶
What is the difference between strict and auto mode in pytest-asyncio?
In strict mode, only tests marked with @pytest.mark.asyncio and fixtures decorated with @pytest_asyncio.fixture are handled as async, which avoids conflicts with other async plugins such as trio or anyio. In auto mode, every coroutine test and async fixture is handled automatically, which suits projects that use only asyncio.
How do I share an async fixture across tests in a module?
Give the fixture both scope="module" and loop_scope="module", and run every test that uses it on the module loop with @pytest.mark.asyncio(loop_scope="module") or a module-level pytestmark. If the fixture and test loop scopes differ, the fixture's objects belong to a different event loop and fail with loop-related errors.
Why does my async pytest test pass even though I forgot an await?
Calling a coroutine function without awaiting it only creates a coroutine object, so no code runs and nothing fails. Python emits a RuntimeWarning when that object is garbage-collected. Add error::RuntimeWarning and error::pytest.PytestUnraisableExceptionWarning to pytest's filterwarnings so the warning fails the test.
How do I stop a hanging async test from blocking CI?
Install pytest-timeout and set a global timeout, for example timeout = 10 in the pytest configuration, overriding it per test with @pytest.mark.timeout when a slow test is intentional. It interrupts the test from outside the event loop, so it also catches loops blocked by synchronous code.
Related¶
- Testing Async Code — up to the topic overview for loop scopes, virtual time and race reproduction.
- Mocking async dependencies with AsyncMock — replacing the network-facing dependencies these fixtures would otherwise create.
- Resilience, Cancellation & Error Handling — the section overview for the behaviours worth testing.