Python pytest asyncio: Writing Async Tests
python pytest asyncio and async tests: Write and run async tests with pytest and asyncio: install pytest-asyncio, use async fixtures, control event loop scope, and fix...
Why pytest Does Not Run async def Tests Directly
pytest's test runner is synchronous. When you define a test as async def test_..., pytest sees a coroutine function and calls it, but never schedules the coroutine on an event loop. The result is a warning about a coroutine that was never awaited, and the test body never executes. This is why python pytest asyncio and async tests requires an extra plugin instead of working out of the box.
The fix is pytest-asyncio, a plugin that wraps each async test in an event loop and awaits the coroutine. Once installed, pytest discovers, runs, and reports async tests the same way it handles synchronous ones.
Installing and Configuring pytest-asyncio
Install the plugin with pip:
pip install pytest-asyncio
Then declare the mode in pyproject.toml:
[tool.pytest.ini_options] asyncio_mode = "strict"
In strict mode, every async test must carry the @pytest.mark.asyncio marker. In auto mode, pytest-asyncio runs any async def test_* function without the marker. Strict mode is the default and keeps intent explicit, which matters when a test file mixes synchronous and asynchronous tests.
Writing an Async Test Function
An async test looks like a normal pytest test except that it is a coroutine function and awaits real asynchronous work:
import asyncio import pytest async def fetch_value(delay: float) -> str: await asyncio.sleep(delay) return "ok" @pytest.mark.asyncio async def test_fetch_value_returns_ok(): result = await fetch_value(0.01) assert result == "ok"
The marker tells the plugin to create an event loop for this test, run the coroutine, and propagate any exception as a test failure. Without the marker in strict mode, pytest treats the function as a plain test and never awaits the coroutine.
Async Fixtures
Fixtures can also be coroutine functions. pytest-asyncio runs them on the same event loop as the test that requests them:
import asyncio import pytest class Server: async def start(self): self.value = 0 async def stop(self): self.value = -1 @pytest.fixture async def server(): srv = Server() await srv.start() yield srv await srv.stop() @pytest.mark.asyncio async def test_server_starts(server): assert server.value == 0
The fixture is awaited before the test body runs, and teardown code after yield is awaited after the test finishes. This keeps setup and cleanup asynchronous without leaking resources across tests.
strict vs auto Mode
| Mode | Marker required | Best fit |
|---|---|---|
| strict | Yes | Mixed sync/async suites where explicit intent matters |
| auto | No | Files that are entirely async and want less boilerplate |
Auto mode is convenient but can hide mistakes. If a coroutine function is accidentally defined without an internal await, auto mode still runs it, and the missing await only surfaces as a runtime behavior difference. Strict mode forces you to declare async intent, which makes review easier.
Event Loop Scope and Test Isolation
By default, pytest-asyncio creates a new event loop for each test. This isolation prevents state from leaking between tests, but it also means loop-scoped resources do not persist across tests. If a fixture opens a connection that must be reused, scope the fixture to the module or session and be aware that the loop it runs on may differ from the test's loop.
When you need a shared event loop, configure the loop scope explicitly:
[tool.pytest.ini_options] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "session"
This is a tradeoff: session-scoped loops reduce setup cost but increase the chance that one test's failure leaves the loop in an unusable state. Function-scoped loops are safer for most suites.
Common Failure Modes
The most frequent error is an async def test_... without the marker in strict mode. pytest reports the test as passed or warns about an unawaited coroutine, and the body never executes:
# strict mode: this body never runs async def test_missing_marker(): assert False # this assertion is never evaluated
The fix is adding @pytest.mark.asyncio or switching to auto mode.
Another common issue is awaiting a coroutine inside a synchronous test. A synchronous test cannot use await; it must call asyncio.run(...) or delegate to an async helper. Mixing the two styles in one file is a frequent source of confusion.
A third failure appears when a fixture returns a coroutine instead of awaiting it. The fixture body must await its own asynchronous calls; otherwise the test receives a coroutine object and the assertion fails in a way that is hard to read.
Running Async Tests in CI
Async tests behave like any other pytest test in CI, but two operational details matter. First, set a timeout for the whole run so a hanging await does not stall the pipeline; pytest-timeout is a common way to enforce this. Second, be careful with parallelism: plugins that run tests in separate processes, such as pytest-xdist, work with pytest-asyncio, but each worker gets its own event loop, so loop-scoped state is not shared across workers.
If a test depends on real network I/O, prefer mocking or a local test server so the suite stays deterministic. Async code makes concurrent requests easy, which is useful for load-style tests, but those belong in a separate suite with explicit timeouts rather than the default unit test run.