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-patternspytest-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-aiohttpVerify the plugin is active:
pytest --co -q # should show no "PytestUnraisableExceptionWarning" about coroutinesStep 2 - Choose a mode
asyncio_mode has two practical values:
| Mode | Behavior |
|---|---|
strict (default) | Only tests marked @pytest.mark.asyncio are collected as async. Async fixtures must use @pytest_asyncio.fixture. |
auto | All 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:
| Scope | Loop lifetime |
|---|---|
function (default) | One loop per test function |
class | One loop shared across all tests in the class |
module | One loop shared across all tests in the file |
package | One loop per package (subdirectory); subpackages do not share with parents |
session | One 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 sessionScope 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-pattern | Problem | Fix |
|---|---|---|
@pytest.fixture for async fixture in strict mode | pytest-asyncio ignores it; fixture runs sync | Use @pytest_asyncio.fixture in strict mode |
MagicMock() for an async function | Awaiting it raises TypeError | Use AsyncMock() (stdlib since Python 3.8) |
assert_called_once_with on an AsyncMock | Checks calls, not awaits; passes even if mock was never awaited | Use assert_awaited_once_with |
scope="session" async fixture without matching loop scope | Fixture and test run in different loops; raises "attached to a different loop" error | Set asyncio_default_fixture_loop_scope = "session" or use loop_scope="session" on the test |
Forgetting asyncio_mode = "auto" in aiohttp tests | Tests collected but not run as async | Add asyncio_mode = "auto" to pyproject.toml (required by pytest-aiohttp) |
asyncio.run() inside a test body | Creates a nested event loop; raises RuntimeError in Python 3.10+ | Let pytest-asyncio manage the loop; just await directly |
Limitations
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
| Assertion | Meaning |
|---|---|
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_count | How 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-lifespanfrom 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 == 200aiohttp 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 == 42Parametrize backends:
# conftest.py
import pytest
@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
return request.paramanyio 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.