Testing FastAPI Applications with Pytest: A Practical Guide

· 10 min read · Backend Development

Practical guide to testing FastAPI applications with Pytest including fixtures, database isolation, authentication testing, and factories.

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.