Files
prompts/docs/skills/pytesting/references/asyncio-testing.md
T
2026-06-21 22:55:58 -05:00

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.

  1. Confirm async plugin mode in pytest config (asyncio_mode).
  2. Keep async fixture loop scope predictable, defaulting to function unless there is a measured need to broaden it.
  3. Prefer one async testing model per lane (pytest-asyncio or AnyIO-style markers), and keep it consistent.
  4. Keep async fixtures small and isolate stateful resources to the narrowest useful scope.
  5. 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:

  1. Use httpx.AsyncClient with ASGITransport for async endpoint tests.
  2. Mark async tests with one consistent marker style for the suite.
  3. If app lifespan hooks matter, add LifespanManager support because AsyncClient alone 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:

  1. Keep async fixtures narrow (function scope by default).
  2. Keep one responsibility per fixture when possible.
  3. Prefer yield fixtures and pair each setup step with teardown in the same fixture.
  4. 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:

  1. Verify fixture and test loop scopes are compatible and explicit.
  2. Confirm async resource setup and teardown are owned by the same fixture context.
  3. Reduce fixture scope (module or session -> function) to test for loop/task ownership drift.
  4. Ensure the suite uses one primary async plugin model for the failing lane.
  5. 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:

Commands Worth Remembering

  • uv run --group test python -m pytest --collect-only -q
  • uv run --group test python -m pytest -m smoke tests/web -q -rs
  • uv run --group test python -m pytest -m integration -q
  • uv run --group test python -m pytest -q