The most surprising thing about testing asyncio code with pytest is that you don’t actually need to await your tests yourself.
Let’s see this in action. Imagine you have a simple asynchronous function:
# my_module.py
import asyncio
async def fetch_data(url: str) -> str:
await asyncio.sleep(0.1) # Simulate network latency
return f"Data from {url}"
async def process_data(data: str) -> str:
await asyncio.sleep(0.05) # Simulate processing time
return f"Processed: {data}"
And you want to test it. Your first instinct might be to write something like this:
# test_my_module.py
import pytest
from my_module import fetch_data, process_data
@pytest.mark.asyncio
async def test_fetch_data():
result = await fetch_data("http://example.com")
assert result == "Data from http://example.com"
@pytest.mark.asyncio
async def test_process_data():
result = await process_data("Raw Data")
assert result == "Processed: Raw Data"
When you run pytest, you’ll see this output:
============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/your/project
plugins: asyncio-0.21.0
collected 2 items
test_my_module.py::test_fetch_data PASSED [ 50%]
test_my_module.py::test_process_data PASSED [100%]
============================== 2 passed in 0.35s ===============================
Notice that pytest handles the awaiting for you. The @pytest.mark.asyncio decorator is the key. It tells pytest-asyncio that this test function is an async function and needs to be run within an asyncio event loop. pytest-asyncio takes care of creating the loop, running your test function to completion, and then closing the loop.
This setup allows you to write your async tests in a way that feels natural, mirroring how you’d write your production async code. You don’t need to manually manage event loops or use helper functions to run your coroutines.
The core problem pytest-asyncio solves is bridging the gap between pytest’s synchronous test runner and the asynchronous nature of your code. pytest itself doesn’t natively understand async def functions as tests that need to be executed within an event loop. It would just see them as regular functions that happen to use await internally, and without an event loop to run them, they’d likely error out or behave unexpectedly.
pytest-asyncio provides a plugin that hooks into pytest’s collection and execution phases. When it encounters a test marked with @pytest.mark.asyncio, it intercepts it. Instead of pytest trying to call the function directly, pytest-asyncio takes over. It ensures an asyncio event loop is available for the test’s duration. This could be a new loop for each test, or it might reuse an existing one depending on configuration. Then, it schedules your async test function as a task within that loop and waits for it to complete.
Think of it like this: pytest is the conductor, and your tests are the musicians. pytest-asyncio is like a special stage manager for your async musicians. It ensures the right stage (the event loop) is set up, gets the musicians ready, and tells them when to play and when to stop, all while the conductor is just waiting for the piece to finish.
You can configure how pytest-asyncio manages event loops. For instance, you can set it to use a new loop for every test function by default, or to reuse a single loop for all tests within a module or session. This is often controlled via pytest.ini or pyproject.toml.
# pytest.ini
[pytest]
asyncio_mode = auto
The asyncio_mode = auto setting is the default and generally the best choice. It tries to be smart about loop creation, often creating a new loop for each test that needs one. Other options include strict (always creates a new loop) and once (reuses a single loop for the entire test session).
The underlying mechanism involves pytest fixtures. pytest-asyncio registers a fixture that pytest automatically uses when it sees the @pytest.mark.asyncio marker. This fixture is responsible for setting up and tearing down the event loop environment for your test.
A common point of confusion is when you have async fixtures themselves. pytest-asyncio also supports these beautifully.
# test_my_module.py
import pytest
import asyncio
from my_module import fetch_data
@pytest.fixture
async def sample_data():
print("\nSetting up fixture...")
await asyncio.sleep(0.01)
data = {"id": 1, "value": "test"}
yield data
print("\nTearing down fixture...")
@pytest.mark.asyncio
async def test_with_async_fixture(sample_data):
print("Running test...")
assert sample_data["id"] == 1
result = await fetch_data("http://another.com")
assert result == "Data from http://another.com"
When you run this, you’ll see the print statements interleaved with the test execution, demonstrating the fixture setup and teardown happening within the asyncio context managed by pytest-asyncio.
============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-7.4.0, pluggy-1.2.0
rootdir: /path/to/your/project
plugins: asyncio-0.21.0
collected 1 item
test_my_module.py
Setting up fixture...
Running test...
.
Tearing down fixture...
============================== 1 passed in 0.36s ===============================
The crucial detail is that the @pytest.mark.asyncio marker is applied to the test function itself, not necessarily to async fixtures. pytest-asyncio is smart enough to detect and run async fixtures when they are used by an @pytest.mark.asyncio-marked test.
Beyond just running async functions, pytest-asyncio also provides utilities for mocking asyncio operations, such as its own pytest.mark.mock_asyncio marker or the mocker.patch functionality that works seamlessly with asyncio.
The next hurdle you’ll likely encounter is managing complex asyncio patterns like tasks, futures, and explicit loop management within your tests when pytest-asyncio’s automatic handling isn’t quite enough.