Testing Async Code with the AnyIO pytest Plugin¶
AnyIO ships a pytest plugin — no extra package — that runs async def tests and fixtures, and can run every test once per backend. For a library that claims to be backend-agnostic, that is the only honest test: code that passes on asyncio and has never run on trio is asyncio code. In a small suite with three tests and a parametrised backend fixture, pytest collected and passed 6 tests — each on asyncio and trio — including an async fixture that started a real TCP echo server in a task group, in 0.12–0.41 s. The same suite passed with pytest-asyncio installed alongside. This guide sets it up and covers the fixture rules that trip people up.
Prerequisites¶
- Python 3.11+,
pip install anyio trio pytest— the plugin is registered by AnyIO itself. - Backend-neutral code, from writing backend-agnostic code with AnyIO.
- General async testing, from Testing Async Code.
1. Mark tests and choose backends¶
Tests run under AnyIO when they carry the anyio marker. The backend comes from a fixture named anyio_backend; parametrise it to run each test on several backends:
# conftest.py
import pytest
@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
return request.param
# test_echo.py
import anyio
import pytest
import sniffio
pytestmark = pytest.mark.anyio # every test in this module
async def test_backend_name(anyio_backend):
assert sniffio.current_async_library() == anyio_backend
async def test_timeout_fast():
with anyio.fail_after(1):
await anyio.sleep(0.01)
Without the parametrised fixture, the default backend is asyncio only. With it, test ids get a suffix — test_timeout_fast[asyncio], test_timeout_fast[trio] — so a failure says which backend broke. To pass backend options, return a tuple: ("asyncio", {"use_uvloop": True}).
Verify: pytest -q collects twice as many tests as there are test functions.
2. Write async fixtures that hold resources¶
Async fixtures work when the test (or any fixture it uses) depends on anyio_backend, directly or indirectly. A fixture can run a server for the duration of a test with a task group, yielding inside it:
import anyio
import pytest
@pytest.fixture
async def server_port(anyio_backend):
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=0)
port = listener.extra(anyio.abc.SocketAttribute.local_port)
async def handle(stream):
async with stream:
await stream.send(await stream.receive()) # echo one message
async with anyio.create_task_group() as tg:
tg.start_soon(listener.serve, handle)
yield port
tg.cancel_scope.cancel() # stop the server after the test
async def test_echo(server_port):
async with await anyio.connect_tcp("127.0.0.1", server_port) as s:
await s.send(b"ping")
assert await s.receive() == b"ping"
The plugin runs the fixture's setup, the test, and the teardown on the same runner, so objects created in the fixture are valid in the test — the property that per-test event loops in other plugins sometimes break. If the server needs to be fully listening before the test proceeds, start it with await tg.start(...) as in waiting for readiness with AnyIO TaskGroup.start; here the listener is created before yielding, so it is already accepting.
Verify: the echo test passes on both backends and leaves no listening socket behind.
3. Share expensive fixtures across tests carefully¶
Module- and session-scoped async fixtures need a backend fixture of the same or wider scope, because the runner must outlive every test that uses the fixture:
@pytest.fixture(scope="session", params=["asyncio", "trio"])
def anyio_backend(request):
return request.param
@pytest.fixture(scope="session")
async def db_pool(anyio_backend):
pool = await create_pool(DSN)
yield pool
await pool.close()
With a session-scoped backend, AnyIO keeps one runner per backend alive across the whole session, so db_pool is created once per backend and reused. Mixing scopes — a session-scoped async fixture with a function-scoped anyio_backend — fails with a scope mismatch error from pytest, which is the right outcome: the alternative would be using a pool on a loop it was not created on. The asyncio-only equivalent of this decision is in choosing event loop scopes in pytest-asyncio.
Verify: add a print to the pool fixture; it runs once per backend per session, not once per test.
4. Coexist with pytest-asyncio¶
Projects migrating from pure asyncio often have both plugins installed. They coexist: pytest-asyncio handles tests marked asyncio (or all async tests in its auto mode), AnyIO handles tests marked anyio. Verified: the six-test suite above passed identically with pytest-asyncio 1.4 loaded and with it disabled via -p no:asyncio.
The conflict to avoid is pytest-asyncio's auto mode, which claims every unmarked async test and fixture. In a codebase moving to AnyIO, use strict mode so each test is explicit:
# pytest.ini
[pytest]
asyncio_mode = strict
markers =
anyio: run under AnyIO
Then migrate tests one module at a time by switching pytestmark = pytest.mark.asyncio to pytest.mark.anyio. A test that passes on asyncio but fails on trio has found real backend-specific behaviour — often an asyncio. call that slipped into supposedly neutral code.
Verify: run the suite with and without -p no:asyncio; the results are identical.
5. Test cancellation and timing on both backends¶
The most valuable tests to run on both backends are the ones about cancellation, because that is where the backends differ most, as covered in level vs edge cancellation in AnyIO and asyncio:
async def test_cleanup_runs_when_cancelled():
cleaned = anyio.Event()
async def worker():
try:
await anyio.sleep_forever()
finally:
with anyio.CancelScope(shield=True):
await anyio.sleep(0.01) # an awaited cleanup step
cleaned.set()
async with anyio.create_task_group() as tg:
tg.start_soon(worker)
await anyio.sleep(0.01)
tg.cancel_scope.cancel()
assert cleaned.is_set()
Remove the shield and the test fails on both backends' AnyIO semantics — the cleanup await is cancelled. That is the kind of regression a single-backend asyncio test with asyncio.CancelledError habits would never catch. For timing-sensitive tests, prefer fail_after around the operation over asserting wall-clock durations, which flake on shared CI runners.
Verify: the cleanup test passes on both backends, and fails on both when the shield is removed.
Verification¶
The test setup is sound when:
- Every async test is marked, and
asyncio_modeis strict if pytest-asyncio is also installed. anyio_backendis parametrised over the backends you claim to support.- Async fixture scopes match the backend fixture's scope.
- Cancellation tests run on every backend.
Diagnostic Hook: in CI, report pass rates per backend suffix ([asyncio], [trio]) separately. A test that fails only on one backend is a portability bug, and a growing list of tests skipped on one backend is the library quietly becoming single-backend.
Pitfalls & edge cases¶
- Forgetting the marker. Unmarked async tests are either skipped with a warning or claimed by another plugin.
- Using
asyncioAPIs in tests meant to be neutral. They fail on trio; use AnyIO equivalents. - Wide-scoped fixtures with a narrow backend fixture. pytest raises a scope mismatch; widen the backend fixture.
- Asserting exact timings. Shared runners make these flaky on every backend.
Frequently Asked Questions¶
How do I run pytest tests with AnyIO?
Mark async tests with @pytest.mark.anyio. AnyIO's own pytest plugin runs them; no extra package is needed. By default they run on asyncio.
How do I run each test on both asyncio and trio?
Define an anyio_backend fixture parametrised with ["asyncio", "trio"] in conftest.py. Every test marked anyio then runs once per backend, with the backend name in the test id.
Can I use the AnyIO plugin and pytest-asyncio together?
Yes. Each handles tests carrying its own marker. Set pytest-asyncio to strict mode so it does not claim unmarked tests intended for AnyIO.
How do I share an async fixture across a whole test session with AnyIO?
Make the anyio_backend fixture session-scoped as well. AnyIO keeps one runner per backend for the session, so the fixture's objects stay valid in every test.
Related¶
- AnyIO & Trio Interop — up to the topic overview.
- Testing asyncio code with pytest-asyncio — the asyncio-only plugin compared.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.