Skip to content

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

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.

One test function, one run per backend A flow of 4 stages. One test function, one run per backend @pytest.mark.anyio opt the test in anyio_backend fixture params: asyncio, trio plugin starts a runner per backend two results test[asyncio], test[trio] The parametrised fixture is what turns "backend-agnostic" from a claim into a test result.

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.

Fixture scope and the backend fixture A grid of 3 rows by 3 columns. Fixture scope and the backend fixture async fixture scope anyio_backend scope result function function fresh runner per test session session one runner per backend, shared session function pytest scope mismatch error The runner must live at least as long as the widest async fixture that depends on it.

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.

Which tests should run on every backend? A decision on What does the test exercise with 3 outcomes. Which tests should run on every backend? What does the test exercise? cancellation, timeouts, streams every backend where they differ an asyncio-only library asyncio only mark it explicitly pure logic, no awaits plain sync test no plugin needed Parametrise the tests where backend behaviour can differ; keep the rest cheap.

Verification

The test setup is sound when:

  • Every async test is marked, and asyncio_mode is strict if pytest-asyncio is also installed.
  • anyio_backend is 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 asyncio APIs 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.