FastAPI Async Endpoint Testing with pytest-asyncio
FastAPI is built on top of Starlette and runs on an ASGI server. Most of its interesting behavior — middleware, database calls, background tasks — happens asynchronously. Testing this code properly requires pytest-asyncio and a solid understanding of how async test infrastructure works. This guide goes beyond the basics to cover async fixtures, concurrent request testing, and async context managers in test setups.
Setting Up pytest-asyncio
Install the required packages:
pip install pytest-asyncio httpx anyioConfigure pytest.ini or pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"Or per-file using a marker:
# conftest.py
import pytest
# Apply auto mode to all tests in the project
pytest_plugins = ['pytest_asyncio']The asyncio_mode = "auto" setting means all async def test functions are automatically treated as async tests — you don't need @pytest.mark.asyncio on every function.
anyio Mode vs asyncio Mode
pytest-asyncio supports two backends: asyncio (default) and anyio. FastAPI itself uses anyio under the hood, so using anyio mode can expose subtle bugs that asyncio mode masks.
# pyproject.toml — anyio backend
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.anyio]
backends = ["asyncio", "trio"] # test with both!# tests/test_anyio_mode.py
import pytest
import anyio
from httpx import AsyncClient, ASGITransport
from app.main import app
# This test runs under both asyncio AND trio backends
@pytest.mark.anyio
async def test_endpoint_works_on_both_backends():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
response = await client.get("/health")
assert response.status_code == 200
# Restrict to asyncio only when you need asyncio-specific behavior
@pytest.mark.asyncio
async def test_asyncio_specific():
import asyncio
loop = asyncio.get_event_loop()
assert loop is not NoneRunning with pytest --anyio-backends=asyncio,trio will run anyio-marked tests twice — once per backend. This catches issues where your code accidentally depends on asyncio-specific behavior.
Async Fixtures
Async fixtures work just like sync fixtures but use async def. They can await setup and teardown code:
# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.database import engine, Base
@pytest_asyncio.fixture(scope="session")
async def create_tables():
"""Create all tables once per test session."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture(scope="function")
async def async_client(create_tables):
"""Fresh async client per test function."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as client:
yield client
@pytest_asyncio.fixture
async def authenticated_async_client(async_client):
"""Client with auth token pre-injected."""
# Get a real token through the auth endpoint
response = await async_client.post("/auth/token", data={
"username": "testuser",
"password": "testpassword"
})
token = response.json()["access_token"]
async_client.headers["Authorization"] = f"Bearer {token}"
yield async_clientNotice @pytest_asyncio.fixture instead of @pytest.fixture — this is required for async fixtures. Using @pytest.fixture with an async def function will give you a coroutine object rather than the awaited result.
# tests/test_async_fixtures.py
async def test_authenticated_request(authenticated_async_client):
response = await authenticated_async_client.get("/users/me")
assert response.status_code == 200
assert "email" in response.json()
async def test_unauthenticated_request_rejected(async_client):
response = await async_client.get("/users/me")
assert response.status_code == 401Testing Async Middleware
Async middleware can modify requests and responses, add headers, log requests, or short-circuit processing. Testing it requires verifying both the middleware behavior and its effect on endpoints.
# app/middleware.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import time
import logging
logger = logging.getLogger(__name__)
class RequestTimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
response.headers["X-Process-Time"] = str(round(process_time * 1000, 2))
return response
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_requests: int = 100, window: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window = window
self._requests: dict = {}
async def dispatch(self, request: Request, call_next):
client_ip = request.client.host
now = time.time()
# Clean old entries
self._requests = {
k: v for k, v in self._requests.items()
if now - v["first_seen"] < self.window
}
entry = self._requests.get(client_ip, {"count": 0, "first_seen": now})
entry["count"] += 1
self._requests[client_ip] = entry
if entry["count"] > self.max_requests:
return Response(
content='{"detail": "Rate limit exceeded"}',
status_code=429,
media_type="application/json"
)
return await call_next(request)# tests/test_middleware.py
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import FastAPI
from app.middleware import RequestTimingMiddleware, RateLimitMiddleware
from app.routers import api_router
@pytest.fixture
def app_with_timing():
test_app = FastAPI()
test_app.add_middleware(RequestTimingMiddleware)
test_app.include_router(api_router)
return test_app
@pytest.fixture
def app_with_rate_limit():
test_app = FastAPI()
test_app.add_middleware(RateLimitMiddleware, max_requests=3, window=60)
test_app.include_router(api_router)
return test_app
async def test_timing_middleware_adds_header(app_with_timing):
async with AsyncClient(
transport=ASGITransport(app=app_with_timing),
base_url="http://test"
) as client:
response = await client.get("/health")
assert response.status_code == 200
assert "x-process-time" in response.headers
process_time = float(response.headers["x-process-time"])
assert 0 <= process_time < 5000 # Less than 5 seconds in ms
async def test_rate_limit_middleware_blocks_after_threshold(app_with_rate_limit):
async with AsyncClient(
transport=ASGITransport(app=app_with_rate_limit),
base_url="http://test"
) as client:
# First 3 requests should succeed
for i in range(3):
response = await client.get("/health")
assert response.status_code == 200, f"Request {i+1} should succeed"
# 4th request should be rate limited
response = await client.get("/health")
assert response.status_code == 429
assert "Rate limit exceeded" in response.json()["detail"]Async Database Calls in Tests
Testing endpoints that make async database calls requires an async test database session. The key pattern is using a transaction per test that gets rolled back after:
# tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.dependencies import get_db
from app.main import app
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:password@localhost:5432/test_db"
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Rolls back all changes after each test."""
connection = await engine.connect()
transaction = await connection.begin()
session_factory = sessionmaker(
bind=connection,
class_=AsyncSession,
expire_on_commit=False,
)
session = session_factory()
yield session
await session.close()
await transaction.rollback()
await connection.close()
@pytest_asyncio.fixture
async def client(db_session):
"""Client using the test session."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as c:
yield c
app.dependency_overrides.clear()# tests/test_database_endpoints.py
from app.models import User
async def test_create_and_retrieve_user(client, db_session):
# Create via API
response = await client.post("/users", json={
"email": "test@example.com",
"name": "Test User"
})
assert response.status_code == 201
user_id = response.json()["id"]
# Verify in database directly
result = await db_session.get(User, user_id)
assert result is not None
assert result.email == "test@example.com"
# Retrieve via API
response = await client.get(f"/users/{user_id}")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
async def test_database_changes_rolled_back_between_tests(client, db_session):
# This test runs with a clean database — previous test's data was rolled back
from sqlalchemy import select
from app.models import User
result = await db_session.execute(select(User))
users = result.scalars().all()
assert len(users) == 0Concurrent Request Testing
One of the most valuable things async testing enables is testing concurrent requests to verify your application handles parallelism correctly.
# tests/test_concurrent.py
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest_asyncio.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as c:
yield c
async def test_concurrent_requests_all_succeed(client):
"""Verify the app handles 50 concurrent requests without errors."""
tasks = [
client.get("/api/products")
for _ in range(50)
]
responses = await asyncio.gather(*tasks)
assert all(r.status_code == 200 for r in responses)
async def test_concurrent_writes_maintain_consistency(client):
"""Concurrent counter increments should all register."""
# Reset counter
await client.post("/counter/reset")
# Fire 20 concurrent increments
tasks = [client.post("/counter/increment") for _ in range(20)]
responses = await asyncio.gather(*tasks)
assert all(r.status_code == 200 for r in responses)
# Check final value
response = await client.get("/counter")
assert response.json()["value"] == 20
async def test_concurrent_requests_with_different_users(client):
"""Multiple users hitting the API simultaneously."""
async def user_flow(user_id: int):
# Each user creates a resource and retrieves it
create_resp = await client.post("/items", json={
"name": f"item-{user_id}",
"owner_id": user_id
})
assert create_resp.status_code == 201
item_id = create_resp.json()["id"]
get_resp = await client.get(f"/items/{item_id}")
assert get_resp.status_code == 200
assert get_resp.json()["owner_id"] == user_id
return get_resp.json()
results = await asyncio.gather(*[user_flow(i) for i in range(10)])
assert len(results) == 10
# Each user got their own item back
owner_ids = {r["owner_id"] for r in results}
assert owner_ids == set(range(10))
async def test_request_timeout_handling(client):
"""Endpoints that take too long should be cancelled properly."""
with pytest.raises(Exception): # TimeoutException or similar
await asyncio.wait_for(
client.get("/slow-endpoint"),
timeout=0.01 # 10ms timeout — will fail for slow endpoints
)
async def test_concurrent_requests_dont_share_state(client):
"""Verify no request-level state leaks between concurrent requests."""
async def make_request(request_id: int):
response = await client.post("/echo-context", json={"request_id": request_id})
return response.json()["echoed_id"]
results = await asyncio.gather(*[make_request(i) for i in range(30)])
# Each request should get back its own ID, not another request's
for i, result in enumerate(results):
assert result == iAsync Context Managers in Test Setup
Complex test setups often require async context managers — for example, spinning up a mock server, establishing a WebSocket connection, or managing async resources.
# tests/helpers.py
from contextlib import asynccontextmanager
import asyncio
from aiohttp import web
import aiohttp
@asynccontextmanager
async def mock_external_service(responses: dict):
"""Start a real HTTP server for testing external service calls."""
async def handler(request):
path = request.path
if path in responses:
data = responses[path]
return web.json_response(data)
return web.Response(status=404)
app = web.Application()
app.router.add_get("/{path_info:.*}", handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "localhost", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
base_url = f"http://localhost:{port}"
yield base_url
await runner.cleanup()# tests/test_external_calls.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.dependencies import get_external_service_url
from tests.helpers import mock_external_service
async def test_endpoint_calls_external_service():
external_data = {
"/api/users/42": {"id": 42, "name": "External User", "verified": True}
}
async with mock_external_service(external_data) as mock_url:
# Override the external service URL dependency
app.dependency_overrides[get_external_service_url] = lambda: mock_url
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as client:
response = await client.get("/proxy/users/42")
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["name"] == "External User"
async def test_websocket_endpoint():
from httpx_ws import aconnect_ws
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as client:
async with aconnect_ws("/ws/chat/room1", client) as ws:
await ws.send_text('{"type": "message", "content": "Hello"}')
data = await ws.receive_text()
import json
msg = json.loads(data)
assert msg["type"] == "echo"
assert msg["content"] == "Hello"AsyncExitStack for Multiple Async Context Managers
When your test setup involves multiple async resources, AsyncExitStack prevents nested context manager hell:
# tests/test_complex_setup.py
from contextlib import AsyncExitStack
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest_asyncio.fixture
async def complex_client(mock_redis, mock_s3):
async with AsyncExitStack() as stack:
# Each resource is properly cleaned up even if a later one fails
redis = await stack.enter_async_context(mock_redis.connect())
s3 = await stack.enter_async_context(mock_s3.session())
client = await stack.enter_async_context(
AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
)
app.state.redis = redis
app.state.s3 = s3
yield client
async def test_file_upload_stores_in_s3_and_caches_url(complex_client):
with open("tests/fixtures/sample.pdf", "rb") as f:
response = await complex_client.post(
"/documents/upload",
files={"file": ("sample.pdf", f, "application/pdf")}
)
assert response.status_code == 201
doc_id = response.json()["id"]
assert response.json()["url"].startswith("https://")
# Verify subsequent access is served from cache
response = await complex_client.get(f"/documents/{doc_id}")
assert response.headers.get("x-cache") == "HIT"Event Loop Configuration
For tests that create and consume async queues or streams, you may need to configure the event loop scope:
# conftest.py
import pytest
@pytest.fixture(scope="session")
def event_loop_policy():
"""Use uvloop for session-scoped tests if available."""
try:
import uvloop
return uvloop.EventLoopPolicy()
except ImportError:
import asyncio
return asyncio.DefaultEventLoopPolicy()The most common pitfall with pytest-asyncio is mixing sync and async fixtures — sync fixtures can't await things, and if you accidentally use @pytest.fixture on an async def, you'll get confusing errors. Stick to @pytest_asyncio.fixture for anything that uses await, and you'll avoid the majority of async testing headaches.