TestClient Basics
FastAPI's TestClient wraps Starlette's test transport, letting you write fast, synchronous tests without running an actual server. It uses requests under the hood and supports the complete HTTP interface.
Learning Focus
By the end of this lesson you can: write pytest tests using TestClient, test GET/POST/DELETE endpoints, check status codes, response bodies, and headers, and organize tests with conftest.py.
Installation
install-test-deps.sh
pip install pytest httpx pytest-asyncio
Basic Test Structure
tests/test_main.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_create_item():
response = client.post(
"/items/",
json={"name": "Widget", "price": 9.99},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Widget"
assert data["price"] == 9.99
assert "id" in data
def test_create_item_invalid():
response = client.post(
"/items/",
json={"name": "", "price": -1},
)
assert response.status_code == 422
errors = response.json()["detail"]
assert any(e["loc"][-1] == "name" for e in errors)
conftest.py — Shared Fixtures
tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
@pytest.fixture
def auth_headers(client):
"""Get a valid JWT token and return auth headers."""
response = client.post(
"/auth/token",
data={"username": "testuser", "password": "testpass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
Testing Authentication
tests/test_auth.py
def test_login_success(client):
resp = client.post(
"/auth/token",
data={"username": "alice", "password": "correct"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_protected_route_without_token(client):
resp = client.get("/users/me")
assert resp.status_code == 401
def test_protected_route_with_token(client, auth_headers):
resp = client.get("/users/me", headers=auth_headers)
assert resp.status_code == 200
Testing Headers and Response Schema
tests/test_headers.py
def test_request_id_header(client):
resp = client.get("/", headers={"X-Request-ID": "test-id"})
assert resp.headers.get("X-Request-ID") == "test-id"
def test_response_schema(client):
resp = client.post("/items/", json={"name": "Test", "price": 5.0})
assert resp.status_code == 201
data = resp.json()
# Assert all required fields present
for field in ["id", "name", "price"]:
assert field in data, f"Missing field: {field}"
Testing Error Cases
tests/test_errors.py
import pytest
@pytest.mark.parametrize("item_id,expected_status", [
(1, 200),
(99999, 404),
(-1, 422),
])
def test_get_item_status_codes(client, item_id: int, expected_status: int):
resp = client.get(f"/items/{item_id}")
assert resp.status_code == expected_status
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| App initializes DB on import | DB not available in test environment | Use dependency overrides to mock DB |
| Tests share mutable state | Module-scoped client with in-memory DB | Use function-scoped client or reset state between tests |
| Background tasks not tested | Tasks run but assertions miss them | TestClient runs background tasks synchronously — assertions work |
| Auth token hardcoded | Tests break when password changes | Use a fixture to get a fresh token per test suite run |
SSL errors with TestClient | Calling external services in tests | Mock external services with unittest.mock or pytest-httpx |
Hands-On Practice
app/routers/calculator.py
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/calc", tags=["calc"])
class CalcRequest(BaseModel):
a: float
b: float
@router.post("/add")
async def add(body: CalcRequest) -> dict:
return {"result": body.a + body.b}
@router.post("/divide")
async def divide(body: CalcRequest) -> dict:
if body.b == 0:
from fastapi import HTTPException
raise HTTPException(400, "Cannot divide by zero")
return {"result": body.a / body.b}
tests/test_calculator.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3.0),
(0, 5, 5.0),
(-3, 3, 0.0),
(1.5, 2.5, 4.0),
])
def test_add(a, b, expected):
resp = client.post("/calc/add", json={"a": a, "b": b})
assert resp.status_code == 200
assert resp.json()["result"] == expected
def test_divide_by_zero():
resp = client.post("/calc/divide", json={"a": 10, "b": 0})
assert resp.status_code == 400
assert "zero" in resp.json()["detail"].lower()
run-tests.sh
pytest tests/test_calculator.py -v