5.0 KiB
AsyncIO Testing Patterns (Pytest, FastAPI, AnyIO)
!!! info "Primary sources" - pytest-asyncio configuration - pytest-asyncio concepts - pytest-asyncio fixture loop scope how-to - pytest-asyncio default fixture loop scope how-to - AnyIO cancellation and cancel-scope safety - FastAPI async tests
Agent Quick Path
Use this reference when tests involve asynchronous fixtures, HTTP clients, task groups, or teardown failures.
- Confirm async plugin mode in pytest config (
asyncio_mode). - Keep async fixture loop scope predictable, defaulting to
functionunless there is a measured need to broaden it. - Prefer one async testing model per lane (pytest-asyncio or AnyIO-style markers), and keep it consistent.
- Keep async fixtures small and isolate stateful resources to the narrowest useful scope.
- If teardown errors mention cancel scopes or task groups, validate that setup and teardown run in the same task context.
Baseline Configuration
Recommended defaults for most projects using pytest-asyncio:
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Why:
- Strict mode is safer for multi-plugin environments, but auto mode is often simpler when the suite is primarily asyncio-based.
- Function loop scope minimizes cross-test coupling and avoids many lifecycle surprises.
If a fixture or test needs broader loop sharing, make it explicit instead of changing suite-wide defaults:
import pytest
import pytest_asyncio
@pytest_asyncio.fixture(loop_scope="module")
async def shared_resource():
...
@pytest.mark.asyncio(loop_scope="module")
async def test_uses_shared_loop(shared_resource):
...
FastAPI Endpoint Test Patterns
Use FastAPI's async testing guidance as the default:
- Use
httpx.AsyncClientwithASGITransportfor async endpoint tests. - Mark async tests with one consistent marker style for the suite.
- If app lifespan hooks matter, add LifespanManager support because
AsyncClientalone does not trigger lifespan events.
Example:
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.asyncio
async def test_healthz(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/healthz")
assert response.status_code == 200
Fixture Design For Async Reliability
Apply these patterns first:
- Keep async fixtures narrow (
functionscope by default). - Keep one responsibility per fixture when possible.
- Prefer yield fixtures and pair each setup step with teardown in the same fixture.
- Avoid mixing many independent event-loop lifecycles in one fixture chain.
When using transports that manage internal task groups (for example, streaming clients), avoid patterns that risk splitting lifecycle across different task contexts.
Troubleshooting Cancel-Scope Teardown Failures
When you see errors like Attempted to exit cancel scope in a different task than it was entered in, treat it as an async lifecycle-ownership issue first.
Checklist:
- Verify fixture and test loop scopes are compatible and explicit.
- Confirm async resource setup and teardown are owned by the same fixture context.
- Reduce fixture scope (
moduleorsession->function) to test for loop/task ownership drift. - Ensure the suite uses one primary async plugin model for the failing lane.
- Re-run with focused selection and skip reasons to isolate first failing fixture:
uv run --group test python -m pytest -m smoke tests/web -q -rs
Relevant references:
- Avoiding cancel scope stack corruption
- pytest-asyncio configuration
- pytest fixture teardown behavior
Commands Worth Remembering
uv run --group test python -m pytest --collect-only -quv run --group test python -m pytest -m smoke tests/web -q -rsuv run --group test python -m pytest -m integration -quv run --group test python -m pytest -q