FastAPI_Pytest_TDDHelper
SKILL.md
FastAPI Pytest TDD Helper
High-performance TDD blueprint for FastAPI projects.
Core Principles
| Principle | Implementation |
|---|---|
| Speed | AsyncClient over TestClient (~20% faster) |
| Isolation | Transaction rollback, not schema recreation |
| TDD | Red-Green-Refactor cycle strictly |
| Validation | Pydantic models, not just status codes |
Quick Start
1. Install Dependencies
pip install pytest pytest-asyncio httpx aiosqlite pytest-cov
2. Configure pytest (pyproject.toml)
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = ["-v", "--tb=short", "-x"]
3. Create conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.main import app
from app.database import Base, get_db
@pytest.fixture(scope="session")
async def async_engine():
engine = create_async_engine("sqlite+aiosqlite:///./test.db", poolclass=NullPool)
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)
@pytest.fixture(scope="function")
async def db_session(async_engine):
async_session = async_sessionmaker(async_engine, class_=AsyncSession)
async with async_session() as session:
async with session.begin():
yield session
await session.rollback() # Fast isolation!
@pytest.fixture
async def client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
TDD Workflow: Red-Green-Refactor
Step 1: RED - Write Failing Test
from pydantic import BaseModel
class ItemResponse(BaseModel):
id: int
name: str
price: float
async def test_create_item(client):
response = await client.post("/items/", json={"name": "Widget", "price": 10.0})
assert response.status_code == 201
item = ItemResponse(**response.json()) # Validate shape!
assert item.name == "Widget"
Run: pytest -x (fails - endpoint doesn't exist)
Step 2: GREEN - Minimal Implementation
@app.post("/items/", status_code=201, response_model=ItemResponse)
async def create_item(item: ItemCreate, db: Session = Depends(get_db)):
db_item = Item(**item.model_dump())
db.add(db_item)
db.commit()
return db_item
Run: pytest -x (passes)
Step 3: REFACTOR - Optimize
Improve code quality, run tests to verify nothing breaks.
Reference Documentation
| Task | Reference |
|---|---|
| conftest.py patterns, fixture scopes | references/conftest-patterns.md |
| Red-Green-Refactor examples | references/tdd-workflow.md |
| pyproject.toml, parallel execution | references/pytest-optimization.md |
| Response validation with Pydantic | references/pydantic-validation.md |
| CRUD tests, mocking, overrides | references/testing-patterns.md |
Assets
| Template | Description |
|---|---|
| assets/conftest_template.py | Complete conftest.py ready to customize |
| assets/pyproject_template.toml | Optimized pytest configuration |
Performance Decisions
Why AsyncClient Over TestClient
# AVOID: Sync-to-async bridge overhead
from fastapi.testclient import TestClient
client = TestClient(app)
# USE: Native async, ~20% faster
from httpx import AsyncClient, ASGITransport
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/")
Why Transaction Rollback Over Schema Recreation
| Approach | 100 tests | 1000 tests |
|---|---|---|
| Schema recreation | ~60s | ~600s |
| Transaction rollback | ~5s | ~50s |
# FAST: Rollback at end of each test
async with session.begin():
yield session
await session.rollback()
Fixture Scoping Strategy
| Scope | Use For | Example |
|---|---|---|
session |
Expensive setup | DB engine, app instance |
function |
Test isolation | DB session with rollback |
Common Commands
# Run all tests
pytest
# Run with coverage
pytest --cov=app --cov-report=term-missing
# Run specific test
pytest tests/test_items.py::test_create_item -v
# Run excluding slow tests
pytest -m "not slow"
# Parallel execution
pytest -n auto
# Stop on first failure (TDD mode)
pytest -x
# Run failed tests first
pytest --ff
Parametrize Pattern
@pytest.mark.parametrize("name,price,status", [
("Valid", 10.0, 201),
("", 10.0, 422), # Empty name
("Item", -5.0, 422), # Negative price
])
async def test_create_item_validation(client, name, price, status):
response = await client.post("/items/", json={"name": name, "price": price})
assert response.status_code == status
Factory Fixture Pattern
@pytest.fixture
def item_factory(db_session):
async def _create(name="Item", price=10.0, **kwargs):
item = Item(name=name, price=price, **kwargs)
db_session.add(item)
await db_session.flush()
return item
return _create
async def test_get_item(client, item_factory):
item = await item_factory(name="Widget")
response = await client.get(f"/items/{item.id}")
assert response.json()["name"] == "Widget"