pytest-asyncio lets pytest execute coroutines in a controlled event loop. Its purpose is not automatic parallelism, but testing async code without manually calling asyncio.run() in every case.
Write the first async test
python -m pip install pytest pytest-asyncio
import pytest
async def fetch_user(user_id: int) -> dict[str, object]:
return {"id": user_id, "active": True}
@pytest.mark.asyncio
async def test_fetch_user() -> None:
user = await fetch_user(7)
assert user == {"id": 7, "active": True}
Configure the asyncio mode in pyproject.toml or pytest settings. strict requires explicit management, while auto recognizes async tests. Pinning the choice prevents behavior from changing across environments.
Resources and deadlines
Async fixtures should open and close clients, connections, or servers within a suitable scope. Avoid sharing an event loop and mutable state without a reason. Test the application's timeout policy instead of wrapping every operation in an arbitrary large deadline.
Combine cases with pytest parametrize and manage dependencies with pytest fixtures. For groups of tasks, the asyncio.TaskGroup guide explains cancellation and exception propagation.
Common mistakes
Configure discovery explicitly
Put the selected mode in version-controlled configuration so local runs and CI agree:
[tool.pytest.ini_options]
asyncio_mode = "strict"
Strict mode is a good default when a repository uses more than one async testing plugin because pytest-asyncio only manages explicitly marked tests and fixtures. Auto mode reduces decoration in a project committed exclusively to asyncio. Neither choice makes tests concurrent. It only defines how pytest discovers and owns coroutine functions.
If pytest reports that an async function was skipped or returned a coroutine, check whether the plugin loaded, whether the marker is registered, and whether configuration was found from the current working directory. Run pytest --trace-config when plugin discovery differs between an editor and CI.
Build async fixtures with a clear lifetime
Use pytest_asyncio.fixture in strict mode and place cleanup after yield. The fixture must not return from inside an async with block before the test uses the resource.
import pytest_asyncio
import httpx
@pytest_asyncio.fixture
async def client():
async with httpx.AsyncClient(base_url="https://example.test") as value:
yield value
Choose function scope by default. Broader fixtures improve speed only when the resource is safe to share and its loop scope is compatible. A session-scoped database connection can leak transactions, background tasks, or mutable state across cases. If setup is expensive, reset state explicitly and document who owns shutdown.
Test exceptions and cancellation
An async API is defined by failure behavior as well as returned values. Use pytest.raises around the awaited expression:
@pytest.mark.asyncio
async def test_rejects_unknown_user() -> None:
with pytest.raises(LookupError, match="user"):
await load_user(-1)
For concurrent code, test which tasks are cancelled when one sibling fails and ensure cleanup executes. Do not assert exact scheduling order unless order is part of the contract. Event-loop timing varies by platform and load. Prefer synchronization primitives such as asyncio.Event over a short sleep that merely hopes another task has run.
Cancellation is not an ordinary success path. If production code catches BaseException or suppresses cancellation, a task may continue after its caller has given up. Include a focused test that cancels the task, awaits it, and verifies released locks, closed streams, and no committed partial state.
Make timeout tests deterministic
Test the timeout mechanism the application actually uses, such as asyncio.timeout() or a client's timeout option. A controlled fake can wait on an unset event, allowing the timeout to trigger without contacting a service. Give the test enough margin for a busy CI host, but keep the application deadline short through dependency injection.
Avoid asserting elapsed time to the millisecond. Assert the exception type, the translated domain error, and cleanup. If a retry policy is involved, inject a sleep function or retry clock so the suite does not spend real seconds waiting. The goal is to prove decisions, not benchmark the scheduler.
Detect leaked tasks
A test may pass while leaving a background task alive. That produces warnings at teardown and can mutate later tests. Production functions that create tasks should expose an ownership rule: await them, return a handle, or manage them in a TaskGroup. Tests should signal workers to stop and await their completion.
Warnings such as “Task was destroyed but it is pending” are defects, not harmless noise. Run the suite with warnings visible and consider treating relevant warnings as errors in CI. Also close async generators, clients, database pools, and temporary servers through fixtures even when an assertion fails.
Isolate I/O at the right boundary
Unit tests should replace the HTTP transport, repository, clock, or queue adapter, not patch low-level event-loop methods. A small fake implementing the same async protocol is often clearer than a deep mock. Use AsyncMock when call assertions matter, and set a specification so a renamed method fails promptly.
Keep a smaller integration layer with a real database or local server to verify adapters. Such tests still need deterministic setup, unique data, and teardown. Mark them separately if they require infrastructure, but do not let the unit suite silently reach the public internet.
Parameterize async behavior
pytest.mark.parametrize works with async tests exactly as it does with synchronous ones. It is useful for status values, boundary inputs, and exception mappings. Keep parameters descriptive with ids, especially when failures otherwise display only nested dictionaries.
Avoid combining every dimension into one enormous matrix. Separate contract categories so a failure explains whether parsing, timeout, cancellation, or persistence broke. Assertions should focus on outputs and effects visible to callers, while spy assertions are reserved for interaction that is itself part of the contract.
Keep the suite portable
Loop implementations and platform policies can expose assumptions that one developer machine hides. Do not rely on global loop state, implicit task ordering, or a socket port chosen by hand. Let the operating system allocate ports for local servers. Pin compatible versions of pytest and pytest-asyncio, and review migration notes before changing loop-scope defaults.
Run a focused test with pytest path/to/test_file.py -q, then the full suite to uncover state leakage. When failures occur only in CI, inspect warnings, plugin versions, pytest configuration root, and tasks remaining at teardown before adding retries. Retrying a race only makes the defect less visible.
Do not call time.sleep() in a coroutine because it blocks the loop. Use fakes or asyncio.sleep() when waiting is part of the behavior. Unit tests should not depend on live external services, and assertions should target visible outcomes rather than scheduler internals.
The official pytest-asyncio documentation, accessed July 22, 2026, covers modes, markers, fixtures, and loop scopes. Consult the docs matching your pinned version because loop policies evolve.