CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

79

Quality

99%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files
name:
pytest-asyncio-patterns
description:
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.
metadata:
{"keywords":"pytest, asyncio, async, fastapi, aiohttp, httpx, AsyncMock, event-loop, async-fixtures"}

pytest-asyncio-patterns

Overview

Per pytest-asyncio.readthedocs.io:

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

Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/pytest-asyncio-patterns badge