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.

Install with skills.sh (any agent)

npx skills add testland/qa --skill pytest-asyncio-patterns
View source

pytest-asyncio-patterns

Overview

Per pytest-asyncio.readthedocs.io (opens in new window):

pytest-asyncio runs async def test functions under pytest; without it, coroutine tests are collected but never awaited. It provides the @pytest.mark.asyncio marker (or an auto-mode that drops the marker), async-aware fixtures with configurable event-loop scoping, and works with FastAPI/Starlette (via httpx.AsyncClient) and aiohttp (via pytest-aiohttp).

Nearest neighbor: pytest-tests covers the full framework but treats async in one paragraph. This skill covers the asyncio path end to end: modes, loop scoping, async fixtures, AsyncMock, and framework 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

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.

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)

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

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"

asyncio_default_test_loop_scope defaults to function when unset.

Step 5 - Async fixtures

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()

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

AsyncMock (stdlib since Python 3.8) 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"

Patching an async import path (new_callable=AsyncMock), the full await-assertion table (assert_awaited_once_with, assert_awaited_with, assert_any_await, assert_not_awaited, await_count), and side_effect semantics: references/asyncmock-assertions.md.

Step 7 - Test FastAPI and aiohttp apps

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"}

Firing FastAPI lifespan events (asgi-lifespan), the aiohttp aiohttp_client fixture, and running tests on multiple backends with anyio: references/framework-clients.md.

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 (references/framework-clients.md) 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

AsyncMock await assertions and side_effect

View source (opens in new window)

AsyncMock await assertions and side_effect

Await-specific assertions and side_effect semantics for unittest.mock.AsyncMock (stdlib since Python 3.8). The SKILL.md spine keeps the primary AsyncMock example; the exhaustive assertion reference is here.

Source: docs.python.org AsyncMock (opens in new window).

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"

new_callable=AsyncMock is required so patch installs a coroutine-returning mock rather than a plain MagicMock.

Await-specific assertions

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)

The assert_called_* family checks that the mock was called, not that it was awaited; a coroutine mock can be called without being awaited, so prefer the assert_awaited_* family for async code.

side_effect on AsyncMock

side_effect on AsyncMock behaves the same as on a sync mock: a callable is invoked and its result returned; an exception class (or instance) is raised when the mock is awaited; an iterable returns successive values on each await.

Async framework client patterns

View source (opens in new window)

Async framework client patterns

Deeper framework-specific async test patterns. The SKILL.md spine keeps the minimal FastAPI AsyncClient example; FastAPI lifespan events, aiohttp, and anyio live here.

FastAPI lifespan events (asgi-lifespan)

AsyncClient does not fire lifespan events by default (per fastapi.tiangolo.com/advanced/async-tests (opens in new window)). 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

aiohttp apps (pytest-aiohttp)

Per docs.aiohttp.org/testing (opens in new window), 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"

aiohttp_client returns a TestClient that starts the server on a random port and shuts it down after the test.

anyio as an alternative

Per anyio.readthedocs.io/testing (opens in new window), 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

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

Related skills

doctest-tests

Configures and runs Python's stdlib doctest - embeds executable test cases in docstrings using `>>>` Python interactive prompt convention; supports `# doctest: +ELLIPSIS` / `+NORMALIZE_WHITESPACE` / `+SKIP` directives; integrates with pytest via `--doctest-modules` flag; runs as `python -m doctest module.py -v`. Use for self-documenting reference implementations + simple smoke-test coverage embedded in API docs.

nose2-tests

Configures and runs nose2 - successor to nose (the original Python test discovery library, end-of-life 2015) and an alternative to pytest's discovery model; supports plugin architecture, layers (per-test-class setUp/tearDown shared across modules), parameterized tests via `nose2.tools.params`, multi-process parallelism via mp plugin. Use when migrating from legacy nose1 codebases or when the team prefers nose2's plugin model over pytest.

pytest-tests

Configures and runs pytest - the de facto Python test framework with fixture-based dependency injection (`@pytest.fixture` with scopes module/session/function), parametrize for table-driven tests (`@pytest.mark.parametrize`), markers (`@pytest.mark.skip` / `xfail` / `slow`), `conftest.py` for shared fixtures, plugin ecosystem (pytest-cov, pytest-asyncio, pytest-mock, pytest-xdist), `--lf`/`--ff` for fail-loop, coverage gating. Use when working with Python and needing the modern test framework.

unittest-tests

Configures and runs Python's stdlib unittest - TestCase + setUp/tearDown lifecycle hooks, assertion catalog (assertEqual / assertRaises / assertIn / assertAlmostEqual), unittest.mock module (Mock / MagicMock / patch / patch.object / patch.dict), test discovery via `python -m unittest discover`, subTest for parametrized cases, expectedFailure decorator. Use when constrained to stdlib-only (no pip install) or migrating legacy unittest codebases.