Async testing patterns with pytest-asyncio. Use when writing tests, mocking async code, testing database operations, or debugging test failures.
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
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
More by CJHarmath
View allComplex multi-step operations in React Native. Use when implementing flows with multiple async steps, state machine patterns, or debugging flow ordering issues.
High-performance PostgreSQL patterns. Use when optimizing queries, designing for scale, or debugging performance issues.
Performance optimization for React Native. Use when optimizing lists, preventing re-renders, memoizing components, or debugging performance issues in Expo/React Native apps.
Logging, error messages, and debugging patterns for React. Use when adding logging, designing error messages, debugging production issues, or improving code observability. Works for both React web and React Native.
