backend / testing / pytest / 06_pytest_asyncio.md

pytest-asyncio

3 min read source

pytest-asyncio

The pytest plugin for async tests. Without it, async def test_foo() is collected as a coroutine and never awaited — silent pass.

Install + minimal use

pip install pytest-asyncio
import pytest

@pytest.mark.asyncio
async def test_async_thing():
    result = await fetch_data()
    assert result == "ok"

The marker tells pytest to run the coroutine inside an event loop.

Modes: auto vs strict

pytest-asyncio has two modes, set in pyproject.toml or pytest.ini:

[tool.pytest.ini_options]
asyncio_mode = "auto"    # treat all async def tests as asyncio
# or
asyncio_mode = "strict"  # require explicit @pytest.mark.asyncio (default)
  • strict (default in v0.21+): every async test needs the marker. Explicit, less magic.
  • auto: any async def test_* is auto-marked. Less typing, but masks if a test isn’t actually async.

For new projects, auto is fine and removes boilerplate. For mixed sync/async codebases, strict makes intent obvious.

Async fixtures

@pytest_asyncio.fixture
async def client():
    async with httpx.AsyncClient() as c:
        yield c

@pytest.mark.asyncio
async def test_get(client):
    r = await client.get("https://example.com")
    assert r.status_code == 200

In auto mode, @pytest.fixture works for async fixtures too. In strict mode, use @pytest_asyncio.fixture to be explicit.

Event loop scope

By default, every test gets a fresh event loop. Cross-test state (connection pools, asyncio Tasks) doesn’t bleed.

To share a loop across a module/session (needed for session-scoped async fixtures):

# conftest.py
import pytest

@pytest.fixture(scope="session")
def event_loop_policy():
    return asyncio.DefaultEventLoopPolicy()

Or in v0.23+:

[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"  # or session, module, class

A common error chain:

  1. Session-scoped async fixture creates a Connection.
  2. Each test creates a new event loop.
  3. The Connection is bound to the first loop; subsequent tests fail with “loop is closed” or “got Future attached to a different loop.”

Fix: scope the loop the same as the fixture, or make the fixture function-scoped.

Mocking async functions

from unittest.mock import AsyncMock

@pytest.mark.asyncio
async def test_with_mock():
    mock_fetch = AsyncMock(return_value={"ok": True})
    result = await mock_fetch()
    assert result == {"ok": True}
    mock_fetch.assert_awaited_once()

AsyncMock returns coroutines. Use assert_awaited_once() (not assert_called_once()) for awaitables.

In MagicMock(spec=SomeAsyncClass), methods declared async are automatically AsyncMock instances (Python 3.8+).

@patch("myapp.api.fetch_data", new_callable=AsyncMock)
async def test_call(mock_fetch):
    mock_fetch.return_value = {"ok": True}
    ...

See 05_mock_vs_magicmock_patch.md.

Testing async generators

async def stream():
    for i in range(3):
        yield i

@pytest.mark.asyncio
async def test_stream():
    items = [x async for x in stream()]
    assert items == [0, 1, 2]

Or collect manually:

gen = stream()
assert await gen.__anext__() == 0

Timeout protection

Long-hanging async tests are awful. Use pytest-timeout or asyncio.wait_for:

@pytest.mark.asyncio
async def test_with_timeout():
    async with asyncio.timeout(2.0):  # 3.11+
        await slow_thing()

Or globally:

[tool.pytest.ini_options]
timeout = 30  # via pytest-timeout

Sync + async tests in one project

Both work side by side. Tests not marked async run synchronously; fixtures detect their own type.

def test_sync():
    assert 1 + 1 == 2

@pytest.mark.asyncio
async def test_async():
    await asyncio.sleep(0)

Common pitfalls

  • Forgot the markerasync def test_x() runs but doesn’t await anything. The test “passes” because the coroutine is created but never executed. Symptoms: warnings about unawaited coroutines.
  • Loop closed errors — fixture loop scope mismatch. See above.
  • Mixing asyncio and anyio/triopytest-asyncio is asyncio-specific. For trio/anyio, use pytest-anyio.
  • AsyncMock confused with MagicMock — calling a MagicMock() returns a Mock, not a coroutine. await it and you get TypeError: object MagicMock can't be used in 'await' expression.
  • Tasks leaking across tests — fire-and-forget asyncio.create_task(...) whose lifetime exceeds the test. Track them; cancel in teardown.

Interview angle

  • Q: “How do you test an async function in pytest?” — pytest-asyncio plugin, mark with @pytest.mark.asyncio or auto mode.
  • Q: “Difference between auto and strict modes?” — auto = no marker needed; strict = explicit.
  • Follow-up: “What’s AsyncMock and when do you need it?” — for mocking async functions; returns awaitable.
  • Follow-up: “Why does the test fail with ‘loop is closed’?” — session-scoped fixture bound to the wrong loop; align scopes.

See 04_async_concurrency/05_async_python_coroutines.md for asyncio fundamentals.