Testing FastAPI with pytest: what developers should know—and honestly, this topic sits at the intersection of pragmatism and pain relief. When you're building APIs that handle real traffic, the confidence from solid tests is non-negotiable. We've all shipped endpoints that passed locally but crumbled under load, or watched auth logic behave differently in staging.
In this guide, we'll walk through the testing patterns we've refined across production e-commerce, SaaS, and ride-hailing systems. By the end, you'll know how to write fast, readable API tests that catch real bugs instead of false confidence.
TestClient vs httpx.AsyncClient: choosing the right tool
FastAPI ships with TestClient, which wraps your app and lets you make synchronous requests without spinning up a server. It's convenient, but it has a critical limitation: it doesn't actually run async code in an async context. For simple endpoints, this doesn't matter. For anything touching real async operations—database connections, external API calls, background tasks—you'll hit issues.
httpx.AsyncClient respects your async/await boundaries and runs your code the way production does. If your handlers use async def, dependencies run async queries, or middleware does async work, you should use AsyncClient. The tradeoff is that your test functions must be async too.
Here's the TestClient approach for a simple endpoint:
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}Now the async version with httpx:
import pytest
import httpx
from contextlib import asynccontextmanager
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
@pytest.fixture
async def async_client():
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_health(async_client):
response = await async_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}Notice we need pytest-asyncio installed and the @pytest.mark.asyncio marker. In a real system, we almost always choose the async path because it matches production behavior. TestClient is fine for trivial endpoints or when you're certain nothing async runs under the hood.
Overriding dependencies for databases and authentication
One of FastAPI's best features is the dependency injection system. This also makes testing elegant: you override dependencies to inject test doubles. Instead of hitting your real database or authentication service, you provide mocks or fixtures that return predictable data.
Let's say you have a user service and an endpoint that fetches a user:
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(db: Session = Depends(get_db)):
# In real flow, check JWT token, query database, etc.
user = db.query(User).first()
if not user:
raise HTTPException(status_code=401)
return user
@app.get("/profile")
def profile(user: User = Depends(get_current_user)):
return {"id": user.id, "email": user.email}To test this without a database, we override both get_db and get_current_user:
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock
@pytest.fixture
def mock_user():
return User(id=1, email="test@example.com")
@pytest.fixture
def client_with_auth(mock_user):
def override_get_db():
return MagicMock()
def override_get_current_user():
return mock_user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
client = TestClient(app)
yield client
app.dependency_overrides.clear()
def test_profile(client_with_auth, mock_user):
response = client_with_auth.get("/profile")
assert response.status_code == 200
assert response.json()["email"] == mock_user.emailThe key is app.dependency_overrides—a dict where keys are the original dependency functions and values are replacements. Always clear overrides in cleanup (using yield in fixtures) to avoid test pollution.
For more complex scenarios, you might override at multiple levels. If you have a service layer that calls the database, override the service instead of the database, so you still test your actual endpoint logic:
@pytest.fixture
def client_with_service_mock():
mock_service = MagicMock()
mock_service.get_user.return_value = User(id=1, email="test@example.com")
app.dependency_overrides[get_user_service] = lambda: mock_service
client = TestClient(app)
yield client
app.dependency_overrides.clear()Fixtures for clients and test data
A well-structured test suite leans heavily on fixtures. They eliminate boilerplate, make tests readable, and keep data consistent. We typically create fixtures for the test client, authenticated clients at different permission levels, and realistic test data.
Here's a fixture setup we use across projects:
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# In-memory SQLite for tests
SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_TEST_DATABASE_URL,
connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db():
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
yield db
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def client(db):
def override_get_db():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def admin_user(db):
user = User(email="admin@example.com", hashed_password="...", role="admin")
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def authenticated_client(client, admin_user):
# Create a token for the admin user and inject it
token = create_access_token(data={"sub": admin_user.email})
client.headers.update({"Authorization": f"Bearer {token}"})
return clientNow a test that uses this fixture stack is clean and focused:
def test_create_order(authenticated_client, admin_user, db):
response = authenticated_client.post(
"/orders",
json={"item_id": 1, "quantity": 2}
)
assert response.status_code == 201
order = db.query(Order).first()
assert order.user_id == admin_user.id
assert order.quantity == 2The fixture hierarchy is clear: db provides a fresh database, client wires it into the app, admin_user seeds data, and authenticated_client adds authorization headers. Each test only depends on what it needs.
Testing status codes and response bodies
Once you have a solid test client and fixtures, assertions are straightforward. But there are patterns that work better than others. Always check status codes first—they're your first signal that something broke. Then validate the response structure and data.
Here's a real-world example from an e-commerce API:
def test_checkout_success(authenticated_client, db):
# Setup: add items to cart
cart_response = authenticated_client.post(
"/cart/items",
json={"product_id": 1, "quantity": 2}
)
assert cart_response.status_code == 201
# Main test: checkout
response = authenticated_client.post("/checkout")
assert response.status_code == 200
data = response.json()
assert "order_id" in data
assert data["status"] == "completed"
assert data["total"] > 0
# Verify side effects
order = db.query(Order).filter_by(id=data["order_id"]).first()
assert order is not None
assert order.status == "completed"
def test_checkout_insufficient_stock(authenticated_client):
# Try to checkout with unavailable product
response = authenticated_client.post(
"/checkout",
json={"product_id": 999}
)
assert response.status_code == 400
data = response.json()
assert "detail" in data
assert "stock" in data["detail"].lower()Notice the pattern: arrange setup, act with a request, assert on status and response body, then verify side effects in the database. For error cases, check both the status code and error message—don't just assume a 400 is enough. Real bugs hide in bad error messaging.
When testing paginated endpoints or complex responses, use structured assertions:
def test_list_orders_pagination(authenticated_client, db):
# Seed 25 orders
for i in range(25):
order = Order(user_id=1, status="completed")
db.add(order)
db.commit()
# Test first page
response = authenticated_client.get("/orders?skip=0&limit=10")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 10
assert data["total"] == 25
assert data["skip"] == 0
assert data["limit"] == 10
# Test second page
response = authenticated_client.get("/orders?skip=10&limit=10")
data = response.json()
assert len(data["items"]) == 10
assert data["skip"] == 10Common mistakes
We've seen these patterns trip up teams repeatedly:
- Not clearing dependency overrides: If you override a dependency but don't clear it after the test, the next test sees the override. Use fixtures with
yieldto guarantee cleanup. - Using TestClient for async code: TestClient runs in a sync context, so async dependencies and middleware behave oddly. If your endpoint is
async def, useAsyncClient. - Testing only the happy path: Write tests for error cases, boundary conditions, and auth failures. A 200 response doesn't mean your endpoint is correct.
- Ignoring response structure: Check that responses match your Pydantic models. Test the presence and type of fields, not just values.
- Hard-coded test data: Use factories or fixtures to generate test data. Hard-coded IDs or timestamps cause flaky tests.
- Database pollution between tests: Use function-scoped fixtures that create and drop tables for each test. Shared state breeds mysterious failures.
Key takeaways
- Choose
TestClientfor sync endpoints and simple cases; usehttpx.AsyncClientwhen your code runs async (which is most of the time in modern FastAPI apps). - Lean on dependency injection overrides to swap out databases, services, and auth without mocking at the HTTP level.
- Build a fixture hierarchy: start with database setup, then clients, then authenticated variants. Each test pulls only what it needs.
- Assert on status codes, response bodies, and database side effects. Error cases deserve the same attention as success paths.
- Keep fixtures function-scoped and clean up aggressively to prevent test pollution and flaky failures.






Comments
Loading comments…