FastAPI Testing API with Pytest
Untested code is broken code you have not discovered yet. FastAPI includes a test client that sends real HTTP requests to your app without starting a server. You write test functions with pytest, and they run your entire API stack — routing, validation, dependencies — in memory.
Install Testing Tools
pip install pytest httpx
HTTPx powers the TestClient. Pytest is the test runner that finds and executes test functions.
Your First Test
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
def hello():
return {"message": "Hello"}
# test_main.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_hello():
response = client.get("/hello")
assert response.status_code == 200
assert response.json() == {"message": "Hello"}
Run tests: pytest test_main.py Output: test_main.py::test_hello PASSED [100%]
Testing POST with a Body
@app.post("/users", status_code=201)
def create_user(name: str, email: str):
return {"id": 1, "name": name, "email": email}
def test_create_user():
response = client.post("/users", json={"name": "Meera", "email": "m@example.com"})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Meera"
assert "id" in data
Testing Error Cases
@app.get("/users/{user_id}")
def get_user(user_id: int):
if user_id != 1:
raise HTTPException(404, detail="Not found")
return {"id": 1}
def test_user_not_found():
response = client.get("/users/999")
assert response.status_code == 404
assert response.json()["detail"] == "Not found"
def test_invalid_id_type():
response = client.get("/users/abc")
assert response.status_code == 422 ← validation error
Fixtures — Shared Setup for Multiple Tests
Pytest fixtures create reusable setup code that runs before each test:
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
return TestClient(app)
def test_hello(client):
response = client.get("/hello")
assert response.status_code == 200
def test_create_user(client):
response = client.post("/users", json={"name": "Raj"})
assert response.status_code == 201
Overriding the Database Dependency for Tests
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base, get_db
from main import app
TEST_DB_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
TestSessionLocal = sessionmaker(bind=engine)
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine) ← create tables
yield
Base.metadata.drop_all(bind=engine) ← drop tables after test
def override_get_db():
db = TestSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
Each test: gets a fresh test database runs against it database dropped clean afterward next test starts with an empty database
Testing with Auth Headers
def test_protected_route():
token = "valid-jwt-token-here"
response = client.get(
"/me",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
def test_protected_without_token():
response = client.get("/me") ← no auth header
assert response.status_code == 401
Test Naming and Organization
project/
├── main.py
├── routers/
│ ├── users.py
│ └── products.py
└── tests/
├── __init__.py
├── test_users.py
└── test_products.py
Naming rules:
File: test_*.py or *_test.py
Function: def test_*():
Class: class Test*:
Key Points
- Use
TestClient(app)from FastAPI to send requests without starting a real server. - Assert on
response.status_codeandresponse.json()to verify behavior. - Use pytest fixtures to share setup and teardown logic across multiple tests.
- Override dependencies with
app.dependency_overridesto swap the real database for a test one. - Test both success and failure paths — missing fields, wrong types, not-found errors.
