Testland
Browse all skills & agents

pytest-asyncio-patterns

Configures and runs async Python tests with pytest-asyncio: installs the plugin, selects asyncio_mode (auto vs strict), scopes event loops (function/class/module/session), writes async fixtures with @pytest_asyncio.fixture, mocks coroutines with AsyncMock, and tests FastAPI (httpx.AsyncClient + ASGITransport) and aiohttp (aiohttp_client fixture) applications. Use when a Python project contains async def test_ functions, FastAPI/aiohttp endpoints, or any asyncio-based code that needs pytest integration. Do NOT use for general pytest fixture design, parametrize patterns, or conftest.py structure without an asyncio-specific problem (event-loop scoping, mode config, AsyncMock, ASGI client): use pytest-tests for those.

pytest-asyncio-patterns

Overview

Per pytest-asyncio.readthedocs.io:

pytest-asyncio is the standard plugin for running async def test functions under pytest. Without it, pytest collects but cannot execute coroutine tests (they return a coroutine object instead of running). The plugin provides:

  • A marker (@pytest.mark.asyncio) and an auto-mode that removes the need for the marker entirely.
  • Async-aware fixture infrastructure with configurable event-loop scoping.
  • Compatibility with FastAPI/Starlette ASGI apps via httpx.AsyncClient and with aiohttp apps via pytest-aiohttp.

Nearest neighbor differentiation: pytest-tests (the sibling skill) covers the full pytest framework but treats async in one paragraph (Step 8). This skill covers the asyncio integration path end to end: modes, loop scoping, async fixtures, AsyncMock, and framework-specific client patterns.

Step 1 - Install

pip install pytest-asyncio
# For FastAPI / httpx testing:
pip install httpx
# For aiohttp testing:
pip install pytest-aiohttp

Verify the plugin is active:

pytest --co -q   # should show no "PytestUnraisableExceptionWarning" about coroutines

Step 2 - Choose a mode

Per pytest-asyncio configuration docs, asyncio_mode has two practical values:

ModeBehavior
strict (default)Only tests marked @pytest.mark.asyncio are collected as async. Async fixtures must use @pytest_asyncio.fixture.
autoAll async def test_* functions are automatically treated as asyncio tests. @pytest.fixture works for async fixtures too.

Set in pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"

Or override per run: pytest --asyncio-mode=strict.

The CLI flag takes precedence over the config file when both are present (per configuration docs).

Recommendation: use auto for pure-asyncio projects; use strict when the project mixes async test libraries (e.g., pytest-trio alongside pytest-asyncio) to avoid mode conflicts.

Step 3 - Mark individual tests (strict mode)

Per pytest-asyncio.readthedocs.io:

import pytest

@pytest.mark.asyncio
async def test_fetch_returns_data():
    result = await fetch_data()
    assert result == {"status": "ok"}

Apply the marker at module level to avoid repeating it:

# test_api.py
import pytest

pytestmark = pytest.mark.asyncio

async def test_one():
    assert await compute() == 42

async def test_two():
    assert await status() == "ready"

In auto mode, neither the decorator nor pytestmark is required.

Step 4 - Event-loop scoping

Per pytest-asyncio marker reference, the loop_scope parameter controls how long an event loop lives:

ScopeLoop lifetime
function (default)One loop per test function
classOne loop shared across all tests in the class
moduleOne loop shared across all tests in the file
packageOne loop per package (subdirectory); subpackages do not share with parents
sessionOne loop for the entire test session

Function-scope (the default) provides the strongest isolation. Wider scopes are useful when spinning up a database connection or network server is expensive.

# Share a loop across all tests in a module
@pytest.mark.asyncio(loop_scope="module")
class TestDatabaseSuite:
    async def test_insert(self):
        await db.insert({"key": "val"})

    async def test_read(self):
        result = await db.get("key")
        assert result == "val"

Configure the default loop scope for all tests in pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "function"

Per configuration docs, asyncio_default_test_loop_scope defaults to function when unset.

Step 5 - Async fixtures

Per pytest-asyncio concepts:

In strict mode, async fixtures must use @pytest_asyncio.fixture (not @pytest.fixture). In auto mode, @pytest.fixture works for async fixtures too.

import pytest_asyncio

# Strict mode: explicit decorator required
@pytest_asyncio.fixture
async def db_pool():
    pool = await create_pool(dsn="postgresql://localhost/test")
    yield pool
    await pool.close()

# Auto mode: standard decorator works
@pytest.fixture
async def http_session():
    async with aiohttp.ClientSession() as session:
        yield session

Scope async fixtures the same way as sync fixtures:

@pytest_asyncio.fixture(scope="module")
async def app_server():
    server = await start_server(port=0)
    yield server
    await server.stop()

Per configuration docs, asyncio_default_fixture_loop_scope determines which event loop async fixtures run in; it defaults to matching the fixture's own scope.

Step 6 - Mock async functions with AsyncMock

Per docs.python.org AsyncMock (stdlib since Python 3.8):

AsyncMock makes a mock object behave as a coroutine function. MagicMock does not: calling it returns a coroutine object but inspect.iscoroutinefunction(MagicMock()) is False, which breaks code that checks type before awaiting.

from unittest.mock import AsyncMock, patch
import pytest

@pytest.mark.asyncio
async def test_service_calls_repository():
    mock_repo = AsyncMock()
    mock_repo.find_by_id.return_value = {"id": 1, "name": "Alice"}

    service = UserService(repo=mock_repo)
    result = await service.get_user(1)

    mock_repo.find_by_id.assert_awaited_once_with(1)
    assert result["name"] == "Alice"

Patch an async method on an import path:

@pytest.mark.asyncio
async def test_external_call():
    with patch("myapp.clients.redis.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = b"cached"
        result = await fetch_from_cache("key")
    mock_get.assert_awaited_once_with("key")
    assert result == b"cached"

Key await-specific assertions from docs.python.org:

AssertionMeaning
assert_awaited_once_with(*a, **kw)Awaited exactly once with these args
assert_awaited_with(*a, **kw)Last await had these args
assert_any_await(*a, **kw)Ever awaited with these args
assert_not_awaited()Never awaited
await_countHow many times awaited (attribute, not assertion)

side_effect on AsyncMock behaves per docs.python.org: a callable is invoked and its result returned; an exception class is raised when the mock is awaited; an iterable returns successive values.

Step 7 - Test FastAPI apps

Per fastapi.tiangolo.com/advanced/async-tests:

FastAPI is an ASGI framework. Async tests use httpx.AsyncClient with ASGITransport to drive the app in-process (no real TCP port needed).

import pytest
from httpx import ASGITransport, AsyncClient
from myapp.main import app

@pytest.mark.asyncio
async def test_read_root():
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        response = await client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "ok"}

AsyncClient does not fire lifespan events by default (per FastAPI docs). To trigger startup/shutdown handlers, use asgi-lifespan:

pip install asgi-lifespan
from asgi_lifespan import LifespanManager

@pytest_asyncio.fixture(scope="module")
async def live_app():
    async with LifespanManager(app) as manager:
        yield manager.app

@pytest.mark.asyncio(loop_scope="module")
async def test_with_lifespan(live_app):
    async with AsyncClient(
        transport=ASGITransport(app=live_app),
        base_url="http://test",
    ) as client:
        response = await client.get("/health")
    assert response.status_code == 200

Step 8 - Test aiohttp apps

Per docs.aiohttp.org/testing, the pytest-aiohttp plugin provides an aiohttp_client fixture that manages server startup and teardown:

pip install pytest-aiohttp
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
from aiohttp import web

async def hello(request):
    return web.Response(text="Hello, world")

async def test_hello(aiohttp_client):
    app = web.Application()
    app.router.add_get("/", hello)
    client = await aiohttp_client(app)
    resp = await client.get("/")
    assert resp.status == 200
    text = await resp.text()
    assert text == "Hello, world"

Per aiohttp testing docs, aiohttp_client returns a TestClient that starts the server on a random port and shuts it down after the test.

Step 9 - anyio as an alternative

Per anyio.readthedocs.io/testing, anyio ships its own pytest plugin that runs async tests on both asyncio and Trio backends. Use it when the codebase is written against anyio primitives or when multi-backend verification is needed.

pip install anyio[trio]
import pytest

@pytest.mark.anyio
async def test_anyio_style():
    result = await compute()
    assert result == 42

Parametrize backends:

# conftest.py
import pytest

@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
    return request.param

Per anyio docs, anyio conflicts with pytest-asyncio auto mode; when both plugins are present, set only one to auto.

Anti-patterns

Anti-patternProblemFix
@pytest.fixture for async fixture in strict modepytest-asyncio ignores it; fixture runs syncUse @pytest_asyncio.fixture in strict mode
MagicMock() for an async functionAwaiting it raises TypeErrorUse AsyncMock() (stdlib since Python 3.8)
assert_called_once_with on an AsyncMockChecks calls, not awaits; passes even if mock was never awaitedUse assert_awaited_once_with
scope="session" async fixture without matching loop scopeFixture and test run in different loops; raises "attached to a different loop" errorSet asyncio_default_fixture_loop_scope = "session" or use loop_scope="session" on the test
Forgetting asyncio_mode = "auto" in aiohttp testsTests collected but not run as asyncAdd asyncio_mode = "auto" to pyproject.toml (required by pytest-aiohttp)
asyncio.run() inside a test bodyCreates a nested event loop; raises RuntimeError in Python 3.10+Let pytest-asyncio manage the loop; just await directly

Limitations

  • pytest-asyncio does not support anyio-native primitives (TaskGroup, CancelScope) out of the box; use the anyio pytest plugin (Step 9) instead.
  • asyncio_mode = "auto" conflicts with anyio auto mode when both plugins are active in the same session.
  • The event_loop_policy fixture is deprecated in recent versions of pytest-asyncio; migrate to the pytest_asyncio_loop_factories hook.
  • AsyncClient + ASGITransport does not exercise real network routing; TCP-level integration tests still require a running server.

References