| name | py-testing-async |
| description | Async testing patterns with pytest-asyncio. Use when writing tests, mocking async code, testing database operations, or debugging test failures. |
Python Async Testing
Problem Statement
Async testing requires specific patterns. pytest-asyncio has modes that affect behavior. Database tests need isolation. Mocking async functions differs from sync. Get these wrong and tests are flaky or don't catch bugs.
Pattern: pytest-asyncio Configuration
Problem: Pytest needs configuration for async tests.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "strict" # Requires explicit @pytest.mark.asyncio
# OR
asyncio_mode = "auto" # All async tests run automatically
import pytest
# With asyncio_mode = "strict" (this codebase)
@pytest.mark.asyncio
async def test_something():
result = await some_async_function()
assert result == expected
# Without the marker = test won't run as async!
Pattern: Async Fixtures
Problem: Fixtures that provide async resources need specific handling.
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
# ✅ CORRECT: Async fixture for session
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session
await session.rollback() # Clean up after test
# ✅ CORRECT: Async fixture for test data
@pytest.fixture
async def test_user(session: AsyncSession) -> User:
user = User(email="test@example.com", hashed_password="...")
session.add(user)
await session.commit()
await session.refresh(user)
return user
# ✅ CORRECT: Using async fixtures
@pytest.mark.asyncio
async def test_get_user(session: AsyncSession, test_user: User):
result = await session.execute(
select(User).where(User.id == test_user.id)
)
user = result.scalar_one()
assert user.email == "test@example.com"
Pattern: Database Test Isolation
Problem: Tests polluting each other's database state.
# ✅ CORRECT: Transaction rollback per test
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
# Start a transaction
async with session.begin():
yield session
# Rollback happens automatically when we exit
# ✅ CORRECT: Nested transactions for complex tests
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
await session.begin()
yield session
await session.rollback()
# Alternative: Use separate test database
# conftest.py
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def test_engine():
# Use SQLite for tests, PostgreSQL for prod
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
await engine.dispose()
Pattern: Mocking Async Functions
Problem: Regular Mock doesn't work with async functions.
from unittest.mock import AsyncMock, patch
# ✅ CORRECT: AsyncMock for async functions
@pytest.mark.asyncio
async def test_with_mocked_service():
mock_service = AsyncMock()
mock_service.get_user.return_value = User(id=uuid4(), email="test@example.com")
result = await mock_service.get_user(user_id)
assert result.email == "test@example.com"
mock_service.get_user.assert_called_once_with(user_id)
# ✅ CORRECT: Patching async functions
@pytest.mark.asyncio
@patch("app.services.user_service.send_email", new_callable=AsyncMock)
async def test_user_creation_sends_email(mock_send_email: AsyncMock, session: AsyncSession):
mock_send_email.return_value = True
user = await create_user(email="new@example.com", session=session)
mock_send_email.assert_called_once_with(user.email, "Welcome!")
# ✅ CORRECT: AsyncMock with side_effect
mock_service = AsyncMock()
mock_service.get_user.side_effect = [
User(id=uuid4(), email="first@example.com"),
User(id=uuid4(), email="second@example.com"),
]
# First call returns first user, second call returns second user
Pattern: HTTP Client Testing
Problem: Testing FastAPI endpoints with async client.
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
@pytest.mark.asyncio
async def test_get_users(client: AsyncClient):
response = await client.get("/api/users")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_create_assessment(client: AsyncClient, auth_headers: dict):
response = await client.post(
"/api/assessments",
json={"title": "Test Assessment", "skill_areas": ["fundamentals"]},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test Assessment"
# ✅ CORRECT: Auth fixture
@pytest.fixture
async def auth_headers(test_user: User) -> dict:
token = create_access_token(user_id=test_user.id)
return {"Authorization": f"Bearer {token}"}
Pattern: Testing Service Functions
@pytest.mark.asyncio
async def test_calculate_rating(session: AsyncSession, test_user: User):
# Arrange: Create test data
assessment = Assessment(user_id=test_user.id, title="Test")
session.add(assessment)
await session.commit()
answers = [
UserAnswer(user_id=test_user.id, question_id=q_id, value=4)
for q_id in question_ids
]
session.add_all(answers)
await session.commit()
# Act: Call the service
result = await calculate_rating(assessment.id, session)
# Assert: Check the result
assert result.rating >= 1.0
assert result.rating <= 5.5
assert result.confidence > 0
@pytest.mark.asyncio
async def test_calculate_rating_no_answers(session: AsyncSession, test_user: User):
assessment = Assessment(user_id=test_user.id, title="Empty")
session.add(assessment)
await session.commit()
# Should raise or return specific result
with pytest.raises(ValueError, match="No answers found"):
await calculate_rating(assessment.id, session)
Pattern: Testing Multi-Step Flows
Same principle as frontend - test entire flows, not just units:
@pytest.mark.asyncio
async def test_complete_assessment_flow(session: AsyncSession, test_user: User):
"""Test full assessment flow: create -> answer -> submit -> results."""
# Step 1: Create assessment
assessment = await create_assessment(
user_id=test_user.id,
data=AssessmentCreate(title="Full Flow Test", skill_areas=["fundamentals"]),
session=session,
)
assert assessment.id is not None
# Step 2: Answer questions
questions = await get_assessment_questions(assessment.id, session)
for question in questions:
await submit_answer(
user_id=test_user.id,
question_id=question.id,
value=4,
session=session,
)
# Step 3: Submit assessment
result = await submit_assessment(assessment.id, session)
assert result.status == "completed"
# Step 4: Verify results
rating = await get_assessment_rating(assessment.id, session)
assert rating is not None
assert rating.skill_area == "fundamentals"
Pattern: Fixture Scopes
Problem: Understanding when fixtures are recreated.
# function (default) - recreated for each test
@pytest.fixture
async def session():
... # New session per test
# class - shared within test class
@pytest.fixture(scope="class")
async def shared_data():
... # Created once per test class
# module - shared within test file
@pytest.fixture(scope="module")
async def module_setup():
... # Created once per file
# session - shared across entire test run
@pytest.fixture(scope="session")
async def database():
... # Created once, used by all tests
Best practices:
- Database sessions:
functionscope (isolation) - Test data:
functionscope (clean state) - Database engine:
sessionscope (expensive to create)
Pattern: Testing Error Cases
@pytest.mark.asyncio
async def test_get_nonexistent_user(session: AsyncSession):
fake_id = uuid4()
with pytest.raises(HTTPException) as exc_info:
await get_user_or_404(fake_id, session)
assert exc_info.value.status_code == 404
assert str(fake_id) in exc_info.value.detail
@pytest.mark.asyncio
async def test_duplicate_email_rejected(session: AsyncSession, test_user: User):
with pytest.raises(IntegrityError):
duplicate = User(email=test_user.email, hashed_password="...")
session.add(duplicate)
await session.commit()
Pattern: Parameterized Tests
@pytest.mark.asyncio
@pytest.mark.parametrize("skill_area,expected_min,expected_max", [
("fundamentals", 1.0, 5.5),
("advanced", 1.0, 5.5),
("strategy", 1.0, 5.5),
])
async def test_rating_ranges(
skill_area: str,
expected_min: float,
expected_max: float,
session: AsyncSession,
):
rating = await calculate_rating_for_area(skill_area, session)
assert expected_min <= rating <= expected_max
@pytest.mark.asyncio
@pytest.mark.parametrize("invalid_input", [
{"title": ""}, # Empty title
{"title": "x" * 201}, # Too long
{"skill_areas": []}, # Empty areas
])
async def test_assessment_validation(invalid_input: dict, client: AsyncClient):
response = await client.post("/api/assessments", json=invalid_input)
assert response.status_code == 422
Common Issues
| Issue | Likely Cause | Solution |
|---|---|---|
| "coroutine was never awaited" | Missing await in test |
Add await |
| Test not running async | Missing @pytest.mark.asyncio |
Add marker or use asyncio_mode = "auto" |
| Tests polluting each other | Missing rollback | Use transaction fixture with rollback |
| "Event loop is closed" | Fixture scope mismatch | Check scope on async fixtures |
| Mock not working | Using Mock instead of AsyncMock |
Use AsyncMock for async |
Test Commands
# Run all tests
uv run pytest
# Verbose output
uv run pytest -v
# Specific file
uv run pytest tests/test_assessments.py
# Specific test
uv run pytest tests/test_assessments.py::test_create_assessment
# With coverage
uv run pytest --cov=app --cov-report=html
# Stop on first failure
uv run pytest -x
# Show print output
uv run pytest -s