pytest with fixture-based dependency injection and async support.
Configuration (pyproject.toml)
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
asyncio_mode = "strict"
addopts = "--cov=src --cov-report=term-missing --no-header -q"asyncio_mode = "strict"— requires explicit@pytest.mark.asyncioon async tests. Prevents accidental async tests (forgetting toawait) from silently passing.--cov=src --cov-report=term-missing— show coverage with uncovered line numbers.
| Plugin | Purpose |
|---|---|
pytest-asyncio |
Run async test functions and fixtures |
pytest-cov |
Measure code coverage |
httpx |
Async HTTP client used as FastAPI test client |
asyncpg |
Async Postgres driver (same as production) |
Alternatives to pytest: unittest (stdlib, class-based, more verbose), nose2 (legacy).
Test Fixtures (conftest.py)
TEST_DATABASE_URL = "postgresql+asyncpg://acme@localhost:5432/acme_test"
engine = create_async_engine(TEST_DATABASE_URL)Uses a separate Postgres database (acme_test), created by docker/init-test-db.sql on first container startup:
- Same engine as dev and production — no dialect mismatches
- Each test gets a fresh, isolated schema via
create_all/drop_all
@pytest_asyncio.fixture
async def db() -> AsyncIterator[AsyncSession]:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) # Create tables
async with async_session() as session:
yield session # Test runs here
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) # Drop tablesEach test gets a clean database: tables created before, dropped after. engine.begin() provides a transaction for DDL operations (CREATE TABLE, DROP TABLE).
@pytest_asyncio.fixture
async def client(db: AsyncSession) -> AsyncIterator[AsyncClient]:
async def override_get_db() -> AsyncIterator[AsyncSession]:
yield db
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
app.dependency_overrides.clear()app.dependency_overrides — FastAPI's dependency injection override. Replaces the real get_db (which connects to the dev database) with a function that yields the test database session.
ASGITransport(app=app) — calls the FastAPI app directly, in-process. No network, no server. httpx sends a request, Starlette routes it, your endpoint runs, and the response comes back — all in the same Python process.
FastAPI's TestClient uses requests (sync-only). For async endpoints with async database calls, you need an async test client. httpx provides AsyncClient with ASGITransport for direct ASGI app calls.
@pytest.mark.asyncio # Required with asyncio_mode = "strict"
async def test_health_returns_ok(client: AsyncClient) -> None:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}@pytest.mark.asyncio— marks this as an async test. Without it, pytest would try to call the coroutine synchronously and the test would silently pass without running.client— theAsyncClientfixture from conftest.py. pytest injects it automatically.