Testing Async Applications
Testing async code feels different from regular Python testing. You’re dealing with timing issues, concurrent operations, and the challenge of mocking async dependencies. The good news? Once you understand the patterns, async testing becomes straightforward.
Basic Async Testing
Start with pytest-asyncio for async test support:
import pytest
import asyncio
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_simple_async_function():
async def fetch_data():
await asyncio.sleep(0.1)
return {"status": "success"}
result = await fetch_data()
assert result["status"] == "success"
The @pytest.mark.asyncio
decorator tells pytest to run this test in an async context. Without it, you’d get errors about coroutines not being awaited.
Testing concurrent operations requires careful setup:
@pytest.mark.asyncio
async def test_concurrent_operations():
async def worker(worker_id, delay):
await asyncio.sleep(delay)
return f"worker-{worker_id}"
tasks = [worker(1, 0.1), worker(2, 0.05), worker(3, 0.15)]
results = await asyncio.gather(*tasks)
assert len(results) == 3
assert all("worker-" in result for result in results)
This test verifies that multiple async operations run concurrently and all complete successfully.
Mocking Async Dependencies
Mock external async services effectively:
class UserService:
def __init__(self, api_client):
self.api_client = api_client
async def get_user_profile(self, user_id: str):
try:
user_data = await self.api_client.fetch_user_data(user_id)
return {
"id": user_data["id"],
"name": user_data["name"],
"profile_complete": bool(user_data.get("name"))
}
except Exception as e:
return {"error": str(e)}
This service depends on an external API client. In tests, we need to mock this dependency to avoid making real API calls.
Here’s how to mock the async dependency:
@pytest.mark.asyncio
async def test_user_service_success():
# Mock the API client
mock_api_client = AsyncMock()
mock_api_client.fetch_user_data.return_value = {
"id": "123",
"name": "John Doe"
}
service = UserService(mock_api_client)
profile = await service.get_user_profile("123")
assert profile["id"] == "123"
assert profile["profile_complete"] is True
mock_api_client.fetch_user_data.assert_called_once_with("123")
@pytest.mark.asyncio
async def test_user_service_error_handling():
mock_api_client = AsyncMock()
mock_api_client.fetch_user_data.side_effect = Exception("API timeout")
service = UserService(mock_api_client)
profile = await service.get_user_profile("123")
assert "error" in profile
assert "API timeout" in profile["error"]
Testing FastAPI Endpoints
Test async web endpoints with httpx:
from fastapi import FastAPI
from httpx import AsyncClient
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: str):
await asyncio.sleep(0.1) # Simulate database query
return {"id": user_id, "name": f"User {user_id}"}
@pytest.mark.asyncio
async def test_get_user_endpoint():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/users/123")
assert response.status_code == 200
data = response.json()
assert data["id"] == "123"
assert data["name"] == "User 123"
Testing Concurrent Behavior
Test race conditions and concurrent operations:
class AsyncCounter:
def __init__(self):
self.value = 0
self.lock = asyncio.Lock()
async def safe_increment(self):
async with self.lock:
current = self.value
await asyncio.sleep(0.001)
self.value = current + 1
async def unsafe_increment(self):
current = self.value
await asyncio.sleep(0.001)
self.value = current + 1
@pytest.mark.asyncio
async def test_concurrent_safe_increment():
counter = AsyncCounter()
# Run 100 concurrent increments
tasks = [counter.safe_increment() for _ in range(100)]
await asyncio.gather(*tasks)
assert counter.value == 100
@pytest.mark.asyncio
async def test_concurrent_unsafe_increment():
counter = AsyncCounter()
tasks = [counter.unsafe_increment() for _ in range(100)]
await asyncio.gather(*tasks)
# Will likely be less than 100 due to race conditions
assert counter.value < 100
Performance Testing
Test performance characteristics:
import time
@pytest.mark.asyncio
async def test_response_time():
async def api_operation():
await asyncio.sleep(0.1)
return "success"
start_time = time.time()
result = await api_operation()
response_time = time.time() - start_time
assert result == "success"
assert response_time < 0.2 # Should complete within 200ms
@pytest.mark.asyncio
async def test_throughput():
async def process_request(request_id):
await asyncio.sleep(0.05)
return f"processed_{request_id}"
start_time = time.time()
tasks = [process_request(i) for i in range(100)]
results = await asyncio.gather(*tasks)
total_time = time.time() - start_time
throughput = len(results) / total_time
assert throughput > 500 # Should handle 500+ requests per second
Integration Testing
Test complete async workflows:
@pytest.mark.asyncio
async def test_user_registration_workflow():
# Mock dependencies
mock_database = AsyncMock()
mock_email_service = AsyncMock()
mock_database.create_user.return_value = {"id": "user_123"}
mock_database.get_user.return_value = {"id": "user_123", "name": "John"}
async def register_user(user_data):
user = await mock_database.create_user(user_data)
await mock_email_service.send_welcome_email(user["id"])
return await mock_database.get_user(user["id"])
result = await register_user({"name": "John", "email": "[email protected]"})
assert result["id"] == "user_123"
mock_database.create_user.assert_called_once()
mock_email_service.send_welcome_email.assert_called_once_with("user_123")
Common Testing Pitfalls
Avoid these async testing mistakes:
1. Forgetting @pytest.mark.asyncio
# Wrong - will fail
async def test_async_function():
result = await some_async_function()
# Correct
@pytest.mark.asyncio
async def test_async_function():
result = await some_async_function()
2. Using regular Mock instead of AsyncMock
# Wrong
mock_service = Mock()
# Correct
mock_service = AsyncMock()
3. Not testing error scenarios Always test both success and failure cases.
Summary
Essential async testing strategies:
- Use
@pytest.mark.asyncio
for async test functions - Mock async dependencies with
AsyncMock
- Test concurrent behavior and race conditions
- Measure performance characteristics
- Test complete workflows end-to-end
- Always test both success and error scenarios
Key tools: pytest-asyncio
, httpx.AsyncClient
, AsyncMock
, and aioresponses
.
In Part 24, we’ll conclude with best practices and future considerations.