Python Pytest Async Testing: Complete Guide to pytest-asyncio 2026
The Pain Points of Async Testing
You have built a beautiful async service with asyncio, and you are ready to write tests — only to discover that standard pytest test functions cannot run async def. You try manually calling asyncio.run(), but async initialization inside fixtures becomes a problem. You attempt to mock an async function, but unittest.mock.patch tells you that coroutine objects are not callable. You write parameterized tests, only to find that event loop conflicts cause all test cases to fail. The complexity of async testing far exceeds that of synchronous testing, and misconfiguration can render your entire test suite inoperable. This article systematically covers pytest-asyncio configuration, patterns, and best practices to help you thoroughly solve Python async testing challenges.
Core Concepts
| Concept | Description |
|---|---|
| pytest-asyncio | A pytest plugin for async testing that automatically creates event loops for marked test functions |
| @pytest.mark.asyncio | Marks a test function as async; pytest-asyncio automatically executes it within an event loop |
| async fixture | A fixture defined with async def, supporting async resource initialization and cleanup |
| event_loop fixture | A built-in pytest-asyncio fixture providing an event loop instance for testing |
| asyncio_mode | Configuration option controlling pytest-asyncio behavior mode (auto/strict) |
| AsyncMock | An async mock object from unittest.mock used to replace async functions |
| scope | Fixture scope controlling the lifecycle of async fixtures (function/session) |
Problem Analysis: 5 Key Challenges in Async Testing
- Event Loop Management Chaos: Each test requires an independent event loop. Loop reuse or conflicts can cause state pollution and unpredictable failures.
- Difficulty Writing Async Fixtures: Initialization and cleanup of async resources like database connections and HTTP clients require special handling.
- Abnormal Async Mock Behavior: Standard Mock objects cannot properly simulate coroutine functions — their return values are coroutine objects rather than actual results.
- Uncertain Test Execution Order: When async tests run concurrently, tests sharing state may fail due to varying execution orders.
- Parametrize and Async Conflicts: Combining
@pytest.mark.parametrizewith@pytest.mark.asynciocan cause issues with marker ordering and scope.
Pattern 1: pytest-asyncio Basic Setup and Configuration
Installation and Configuration
pip install pytest pytest-asyncio
pyproject.toml Configuration
[tool.pytest.ini_options]
asyncio_mode = "auto"
Basic Async Tests
# test_basic.py
import asyncio
import pytest
# Method 1: auto mode — no manual marking needed
async def test_auto_mode():
"""When asyncio_mode = "auto", all async def tests are automatically recognized"""
await asyncio.sleep(0.1)
result = await fetch_data()
assert result == "expected"
# Method 2: strict mode — explicit marking required
@pytest.mark.asyncio
async def test_explicit_mark():
"""When asyncio_mode = "strict", you must use @pytest.mark.asyncio"""
await asyncio.sleep(0.1)
assert True
async def fetch_data():
await asyncio.sleep(0.01)
return "expected"
# Method 3: Specify event loop scope
@pytest.mark.asyncio(loop_scope="session")
async def test_session_loop():
"""Use a session-level event loop shared across tests"""
await asyncio.sleep(0.01)
assert True
conftest.py Global Configuration
# conftest.py
import asyncio
import pytest
@pytest.fixture(scope="session")
def event_loop_policy():
"""Custom event loop policy"""
return asyncio.DefaultEventLoopPolicy()
@pytest.fixture(scope="session")
def loop_scope():
"""Set event loop scope to session"""
return "session"
Pattern 2: Async Fixture Patterns with Dependency Injection
Basic Async Fixtures
# conftest.py
import asyncio
import pytest
from typing import AsyncGenerator
@pytest.fixture
async def db_connection() -> AsyncGenerator:
"""Async database connection fixture"""
conn = await create_connection("postgresql://localhost/test")
yield conn
await conn.close()
@pytest.fixture
async def http_client() -> AsyncGenerator:
"""Async HTTP client fixture"""
import httpx
async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
yield client
async def create_connection(dsn: str):
"""Simulate async database connection"""
await asyncio.sleep(0.01)
return {"dsn": dsn, "status": "connected"}
# test_fixtures.py
@pytest.mark.asyncio
async def test_with_db(db_connection):
"""Test using async fixture"""
assert db_connection["status"] == "connected"
result = await db_query(db_connection, "SELECT 1")
assert result is not None
@pytest.mark.asyncio
async def test_with_http_client(http_client):
"""Test using async HTTP client fixture"""
response = await http_client.get("/api/health")
assert response.status_code == 200
async def db_query(conn, sql):
await asyncio.sleep(0.01)
return {"sql": sql, "result": True}
Fixture Dependency Chain
# conftest.py
@pytest.fixture
async def app():
"""Application instance fixture"""
app = await create_app()
yield app
await app.shutdown()
@pytest.fixture
async def db_connection(app):
"""Database connection depending on app fixture"""
conn = await app.get_db()
yield conn
await conn.close()
@pytest.fixture
async def user_service(db_connection):
"""User service depending on db_connection"""
return UserService(db_connection)
# test_service.py
@pytest.mark.asyncio
async def test_create_user(user_service):
user = await user_service.create("test@example.com", "password123")
assert user.email == "test@example.com"
class UserService:
def __init__(self, db):
self.db = db
async def create(self, email, password):
await asyncio.sleep(0.01)
return type("User", (), {"email": email})()
async def create_app():
await asyncio.sleep(0.01)
return type("App", (), {
"get_db": lambda self: create_connection("postgresql://localhost/test"),
"shutdown": lambda self: asyncio.sleep(0.01)
})()
Pattern 3: Mocking Async Functions and External APIs
AsyncMock Basics
# test_mock.py
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
import httpx
async def fetch_user(user_id: int):
"""Function under test: fetch user from external API"""
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
return response.json()
@pytest.mark.asyncio
async def test_fetch_user_mocked():
"""Replace async function with AsyncMock"""
mock_get = AsyncMock(return_value={"id": 1, "name": "Alice"})
with patch("httpx.AsyncClient.get", mock_get):
result = await fetch_user(1)
assert result == {"id": 1, "name": "Alice"}
mock_get.assert_called_once()
@pytest.mark.asyncio
async def test_fetch_user_side_effect():
"""Use side_effect to simulate different return values for multiple calls"""
mock_get = AsyncMock(side_effect=[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
])
with patch("httpx.AsyncClient.get", mock_get):
result1 = await fetch_user(1)
result2 = await fetch_user(2)
assert result1["name"] == "Alice"
assert result2["name"] == "Bob"
Mocking Async Context Managers
@pytest.mark.asyncio
async def test_mock_async_context_manager():
"""Mock async context manager"""
mock_client = AsyncMock()
mock_client.get.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "ok"}
)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("httpx.AsyncClient", return_value=mock_client):
async with httpx.AsyncClient() as client:
response = await client.get("/health")
assert response.status_code == 200
Mocking Async Exceptions
@pytest.mark.asyncio
async def test_fetch_user_timeout():
"""Test async timeout exception handling"""
mock_get = AsyncMock(side_effect=httpx.TimeoutException("Request timeout"))
with patch("httpx.AsyncClient.get", mock_get):
with pytest.raises(httpx.TimeoutException):
await fetch_user(1)
@pytest.mark.asyncio
async def test_fetch_user_retry():
"""Test async retry logic"""
call_count = 0
async def failing_then_success(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise httpx.TimeoutException("timeout")
return MagicMock(status_code=200, json=lambda: {"id": 1})
mock_get = AsyncMock(side_effect=failing_then_success)
with patch("httpx.AsyncClient.get", mock_get):
result = await fetch_with_retry(1, max_retries=3)
assert result["id"] == 1
async def fetch_with_retry(user_id, max_retries=3):
for attempt in range(max_retries):
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
return response.json()
except httpx.TimeoutException:
if attempt == max_retries - 1:
raise
await asyncio.sleep(0.1 * (attempt + 1))
Pattern 4: Testing Async Context Managers and Generators
Async Context Manager Tests
# test_context_manager.py
import asyncio
import pytest
class AsyncDatabasePool:
def __init__(self, dsn):
self.dsn = dsn
self._connections = []
async def __aenter__(self):
await asyncio.sleep(0.01)
self._pool = {"status": "open", "size": 10}
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await asyncio.sleep(0.01)
self._pool = None
async def acquire(self):
await asyncio.sleep(0.01)
if self._pool is None:
raise RuntimeError("Pool is closed")
return {"id": len(self._connections), "pool": self.dsn}
@pytest.mark.asyncio
async def test_async_context_manager():
"""Test async context manager entry and exit"""
pool = AsyncDatabasePool("postgresql://localhost/test")
async with pool as p:
assert p._pool["status"] == "open"
conn = await p.acquire()
assert conn["id"] == 0
assert pool._pool is None
@pytest.mark.asyncio
async def test_async_context_manager_exception():
"""Test cleanup when async context manager raises an exception"""
pool = AsyncDatabasePool("postgresql://localhost/test")
with pytest.raises(ValueError):
async with pool as p:
raise ValueError("test error")
assert pool._pool is None
Async Generator Tests
# test_async_generator.py
async def async_stream_data(count: int):
"""Async generator: simulate streaming data"""
for i in range(count):
await asyncio.sleep(0.01)
yield {"index": i, "data": f"item_{i}"}
@pytest.mark.asyncio
async def test_async_generator():
"""Test async generator output"""
results = []
async for item in async_stream_data(3):
results.append(item)
assert len(results) == 3
assert results[0] == {"index": 0, "data": "item_0"}
assert results[2] == {"index": 2, "data": "item_2"}
@pytest.mark.asyncio
async def test_async_generator_with_anext():
"""Test async generator using anext"""
gen = async_stream_data(2)
first = await anext(gen)
assert first["index"] == 0
second = await anext(gen)
assert second["index"] == 1
with pytest.raises(StopAsyncIteration):
await anext(gen)
Pattern 5: Parameterized Async Tests and Test Organization
Parameterized Async Tests
# test_parametrize.py
import pytest
async def compute(x: int, y: int) -> int:
await asyncio.sleep(0.01)
return x + y
@pytest.mark.asyncio
@pytest.mark.parametrize("x, y, expected", [
(1, 2, 3),
(10, 20, 30),
(-1, 1, 0),
(100, 200, 300),
])
async def test_compute_parametrize(x, y, expected):
"""Parameterized async test"""
result = await compute(x, y)
assert result == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("input_data", [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35},
], ids=["alice", "bob", "charlie"])
async def test_create_user_parametrize(input_data):
"""Parameterized async test with custom IDs"""
result = await create_user(input_data)
assert result["name"] == input_data["name"]
async def create_user(data):
await asyncio.sleep(0.01)
return {**data, "id": 1}
Test Class Organization
# test_user_api.py
import pytest
@pytest.mark.asyncio
class TestUserAPI:
"""Async test class organization"""
async def test_list_users(self):
users = await list_users()
assert len(users) > 0
async def test_get_user(self):
user = await get_user(1)
assert user["id"] == 1
async def test_create_user(self):
user = await create_user({"name": "test"})
assert user["name"] == "test"
@pytest.mark.parametrize("user_id", [1, 2, 3])
async def test_get_user_parametrize(self, user_id):
user = await get_user(user_id)
assert user["id"] == user_id
async def list_users():
await asyncio.sleep(0.01)
return [{"id": 1}, {"id": 2}]
async def get_user(user_id):
await asyncio.sleep(0.01)
return {"id": user_id}
async def create_user(data):
await asyncio.sleep(0.01)
return {**data, "id": 99}
Pitfall Guide
Pitfall 1: Forgetting to Mark Async Tests
❌ Wrong:
# In strict mode, forgetting @pytest.mark.asyncio
async def test_something():
result = await fetch_data()
assert result is not None
# Error: coroutine was never awaited
✅ Correct:
@pytest.mark.asyncio
async def test_something():
result = await fetch_data()
assert result is not None
Pitfall 2: Returning a Coroutine from a Sync Fixture
❌ Wrong:
@pytest.fixture
def db_connection():
return create_connection("postgresql://localhost/test")
# Returns a coroutine object, not a connection
✅ Correct:
@pytest.fixture
async def db_connection():
conn = await create_connection("postgresql://localhost/test")
yield conn
await conn.close()
Pitfall 3: Using Standard Mock for Async Functions
❌ Wrong:
from unittest.mock import MagicMock
mock_fetch = MagicMock(return_value={"id": 1})
# Calling mock_fetch() returns a MagicMock, not a coroutine
result = await mock_fetch() # TypeError
✅ Correct:
from unittest.mock import AsyncMock
mock_fetch = AsyncMock(return_value={"id": 1})
result = await mock_fetch()
assert result == {"id": 1}
Pitfall 4: Using time.sleep in Async Tests
❌ Wrong:
@pytest.mark.asyncio
async def test_with_delay():
time.sleep(1) # Blocks the event loop!
result = await fetch_data()
assert result is not None
✅ Correct:
@pytest.mark.asyncio
async def test_with_delay():
await asyncio.sleep(1) # Non-blocking wait
result = await fetch_data()
assert result is not None
Pitfall 5: Wrong Parametrize Marker Order
❌ Wrong:
@pytest.mark.asyncio
@pytest.mark.parametrize("x", [1, 2, 3])
# parametrize after asyncio may cause the marker to not take effect
async def test_value(x):
assert x > 0
✅ Correct:
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.asyncio
# parametrize on the outer layer, asyncio on the inner layer
async def test_value(x):
assert x > 0
Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | coroutine was never awaited |
Async function not executed within an event loop | Add @pytest.mark.asyncio or set asyncio_mode = "auto" |
| 2 | RuntimeError: no running event loop |
Used await in a synchronous context |
Ensure the test function is async def and properly marked |
| 3 | Fixture "xxx" is async but test is not |
Synchronous test using async fixture | Change test to async def and add @pytest.mark.asyncio |
| 4 | TypeError: object MagicMock can't be used in await expression |
Using standard Mock instead of async function | Use AsyncMock to replace async functions |
| 5 | DeprecationWarning: The 'event_loop' fixture is deprecated |
Using legacy event_loop fixture | Upgrade pytest-asyncio to 0.23+, use loop_scope parameter |
| 6 | assert False on asyncio_mode="strict" |
Async test not marked in strict mode | Add @pytest.mark.asyncio or switch to auto mode |
| 7 | RuntimeError: Event loop is closed |
Event loop closed after test ends | Check fixture scope; avoid session-level fixtures with function-level loops |
| 8 | pluggy.PluggyTeardownError |
Async fixture cleanup failed | Ensure cleanup code after yield is also async; use async with |
| 9 | TimeoutError in async test |
Async test timed out | Use @pytest.mark.timeout or asyncio.wait_for to control timeout |
| 10 | AssertionError: expected coroutine, got MagicMock |
Mock return type mismatch | Use AsyncMock(return_value=...) to ensure coroutine return |
Advanced Optimization
1. Use Session-Level Event Loop for Better Performance
# conftest.py
@pytest.fixture(scope="session")
def loop_scope():
return "session"
Multiple tests share a single event loop, avoiding the overhead of repeated creation and destruction — especially beneficial for projects with many async tests.
2. Use aioresponses to Mock HTTP Requests
from aioresponses import aioresponses
@pytest.mark.asyncio
async def test_with_aioresponses():
with aioresponses() as m:
m.get("https://api.example.com/users/1", payload={"id": 1, "name": "Alice"})
result = await fetch_user(1)
assert result["name"] == "Alice"
3. Custom Async Test Timeout
@pytest.mark.asyncio
async def test_with_custom_timeout():
async with asyncio.timeout(2.0):
result = await slow_operation()
assert result is not None
async def slow_operation():
await asyncio.sleep(0.5)
return "done"
4. Parallel Execution of Async Tests
pip install pytest-xdist
pytest -n auto # Automatic parallel execution
Combined with pytest-xdist, async tests can run in parallel across multiple workers, significantly reducing test time.
5. Use Hypothesis for Async Property-Based Testing
from hypothesis import given, strategies as st
@pytest.mark.asyncio
@given(st.integers(min_value=0, max_value=100))
async def test_async_property(x):
result = await compute(x, x)
assert result == x * 2
async def compute(a, b):
await asyncio.sleep(0.001)
return a + b
Comparison
| Feature | pytest-asyncio | asyncio.run() | unittest.IsolatedAsyncioTestCase | Manual Event Loop |
|---|---|---|---|---|
| Configuration complexity | Low (install and use) | No configuration needed | Medium (requires class inheritance) | High (manual management) |
| Fixture support | Full support | Not supported | Limited support | Not supported |
| Parameterized tests | Full support | Manual wrapping needed | Limited support | Manual wrapping needed |
| Mock support | AsyncMock integration | Manual handling | Manual handling | Manual handling |
| Test isolation | Automatic | Manual | Automatic | Manual |
| Parallel execution | Supports xdist | Not supported | Not supported | Not supported |
| Community ecosystem | Rich | Python built-in | Python built-in | None |
| Recommended for | Production async testing | Simple script validation | unittest project migration | Not recommended |
Related Tools
In your Python pytest async testing practice, these ToolsKu tools can help:
- JSON Formatter — Format JSON data returned by async APIs to quickly troubleshoot response structure issues
- Hash Calculator — Generate key names for async caching, implement request deduplication and cache validation
- cURL to Code — Convert cURL commands to Python async HTTP request code
pytest-asyncio is the de facto standard for Python async testing. Master its configuration patterns, async fixture authoring, AsyncMock usage, and parameterized test organization, and you will be able to build stable, efficient, production-grade async test suites. Remember: the core of async testing is correctly managing event loop lifecycles, avoiding synchronous blocking, and leveraging AsyncMock effectively.
Try these browser-local tools — no sign-up required →