Testing FastAPI Applications with Pytest: A Practical Guide
Untested code is broken code you have not discovered yet. In a FastAPI application, this means API endpoints returning wrong data, database queries with edge-case bugs, and authentication bypasses you will not find until production.
Project Structure
app/
main.py
models.py
schemas.py
crud.py
routers/
users.py
posts.py
tests/
conftest.py
test_users.py
test_posts.py
test_auth.py
Test Configuration
# conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base, get_db
TEST_DATABASE_URL = "postgresql://test:test@localhost:5432/test_db"
engine = create_engine(TEST_DATABASE_URL)
TestingSessionLocal = sessionmaker(bind=engine)
@pytest.fixture(autouse=True)
def setup_database():
"""Create tables before each test, drop after."""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def db():
"""Database session for each test."""
session = TestingSessionLocal()
try:
yield session
finally:
session.rollback()
session.close()
@pytest.fixture
def client(db):
"""FastAPI test client with database override."""
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Key design: each test gets a fresh database. No test depends on another test's data. Tests can run in any order.
Testing Endpoints
# test_users.py
def test_create_user(client):
response = client.post("/users", json={
"email": "test@example.com",
"password": "securePassword123",
"name": "Test User"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert data["name"] == "Test User"
assert "password" not in data # Password should never be in response
assert "id" in data
def test_create_user_duplicate_email(client):
# Create first user
client.post("/users", json={
"email": "test@example.com",
"password": "password123",
"name": "User 1"
})
# Try to create second user with same email
response = client.post("/users", json={
"email": "test@example.com",
"password": "password456",
"name": "User 2"
})
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
def test_get_user_not_found(client):
response = client.get("/users/nonexistent-uuid")
assert response.status_code == 404
Testing Authentication
# test_auth.py
@pytest.fixture
def auth_headers(client):
"""Create a user and return auth headers."""
client.post("/users", json={
"email": "auth@example.com",
"password": "password123",
"name": "Auth User"
})
response = client.post("/auth/login", data={
"username": "auth@example.com",
"password": "password123"
})
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_protected_endpoint(client, auth_headers):
response = client.get("/users/me", headers=auth_headers)
assert response.status_code == 200
assert response.json()["email"] == "auth@example.com"
def test_protected_endpoint_no_token(client):
response = client.get("/users/me")
assert response.status_code == 401
def test_protected_endpoint_invalid_token(client):
response = client.get("/users/me", headers={
"Authorization": "Bearer invalid_token"
})
assert response.status_code == 401
Testing with Factories
For complex test data, use factories:
# tests/factories.py
from app.models import User, Post
def create_test_user(db, **overrides):
defaults = {
"email": f"user_{uuid4().hex[:8]}@test.com",
"name": "Test User",
"hashed_password": hash_password("password123"),
}
defaults.update(overrides)
user = User(**defaults)
db.add(user)
db.commit()
db.refresh(user)
return user
def create_test_post(db, author=None, **overrides):
if not author:
author = create_test_user(db)
defaults = {
"title": "Test Post",
"slug": f"test-post-{uuid4().hex[:8]}",
"content": "Test content",
"published": True,
"author_id": author.id,
}
defaults.update(overrides)
post = Post(**defaults)
db.add(post)
db.commit()
db.refresh(post)
return post
Running Tests
# Run all tests
pytest -v
# Run with coverage
pytest --cov=app --cov-report=html
# Run specific file
pytest tests/test_users.py -v
# Run matching pattern
pytest -k "test_create" -v
Takeaways
Test the behavior, not the implementation. Each test should answer one question: "Does this endpoint do what it should?" Use fixtures to keep tests clean and factories to keep test data manageable. Every endpoint needs at least three tests: happy path, error case, and authentication check.
See also: Building Authentication in FastAPI for the auth system being tested here.