Testing FastAPI Apps with TestClient and Pytest Fixtures
Build a robust test suite for FastAPI with TestClient, pytest fixtures, dependency overrides, and async testing patterns.
What you'll learn
- ✓Using TestClient for synchronous endpoint testing
- ✓Building reusable pytest fixtures for FastAPI
- ✓Overriding dependencies and testing with databases
Prerequisites
- •Basic FastAPI knowledge
- •Familiarity with pytest
- •Understanding of dependency injection in FastAPI
Why Test FastAPI Applications?
FastAPI’s type system and Pydantic validation catch many errors at development time, but they cannot catch business logic bugs, integration issues, or edge cases in your data flow. A solid test suite gives you confidence to refactor, deploy, and extend your API without regressions.
FastAPI provides TestClient (built on httpx) for testing endpoints without starting a real server.
Getting Started with TestClient
# app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.get("/items/{item_id}")
async def get_item(item_id: int, q: str = None):
result = {"item_id": item_id}
if q:
result["query"] = q
return result
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
def test_get_item():
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {"item_id": 42}
def test_get_item_with_query():
response = client.get("/items/42?q=search")
assert response.status_code == 200
assert response.json() == {"item_id": 42, "query": "search"}
def test_get_item_invalid_id():
response = client.get("/items/not-a-number")
assert response.status_code == 422 # Validation error
TestClient sends real HTTP requests to your app in-process. No server startup needed, and tests run fast.
Pytest Fixtures for Clean Test Setup
Fixtures let you share setup logic across tests without repeating code.
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""Provides a fresh test client for each test."""
with TestClient(app) as c:
yield c
@pytest.fixture
def auth_headers():
"""Provides authentication headers for protected endpoints."""
return {"Authorization": "Bearer test-token-123"}
@pytest.fixture
def sample_item():
"""Provides sample item data for creation tests."""
return {
"name": "Test Item",
"price": 29.99,
"description": "A test item for unit tests",
"category": "testing"
}
# tests/test_items.py
def test_create_item(client, sample_item, auth_headers):
response = client.post(
"/items",
json=sample_item,
headers=auth_headers
)
assert response.status_code == 201
data = response.json()
assert data["name"] == sample_item["name"]
assert "id" in data
def test_create_item_missing_name(client, auth_headers):
response = client.post(
"/items",
json={"price": 10.0},
headers=auth_headers
)
assert response.status_code == 422
Overriding Dependencies
FastAPI’s dependency_overrides dictionary lets you swap any dependency for testing. This is how you avoid hitting real databases, APIs, or authentication services in tests.
# app/dependencies.py
from fastapi import Depends, HTTPException, Header
async def get_current_user(authorization: str = Header(...)):
token = authorization.replace("Bearer ", "")
user = await verify_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user
async def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# tests/conftest.py
from app.dependencies import get_current_user, get_db
from app.main import app
class FakeUser:
def __init__(self, id=1, username="testuser", is_admin=False):
self.id = id
self.username = username
self.is_admin = is_admin
@pytest.fixture
def client():
def override_user():
return FakeUser()
def override_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_current_user] = override_user
app.dependency_overrides[get_db] = override_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def admin_client():
def override_admin():
return FakeUser(id=99, username="admin", is_admin=True)
app.dependency_overrides[get_current_user] = override_admin
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Now every test using the client fixture gets a fake user and test database automatically.
Testing with a Real Test Database
For integration tests, use a real database with test data that gets cleaned up between tests.
# tests/conftest.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.dependencies import get_db
from app.main import app
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(bind=engine)
@pytest.fixture(scope="session", autouse=True)
def create_tables():
"""Create all tables once for the test session."""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def db_session():
"""Provides a transactional database session that rolls back."""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def client(db_session):
def override_db():
yield db_session
app.dependency_overrides[get_db] = override_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Each test gets a database session wrapped in a transaction that rolls back at the end. Tests never pollute each other’s data.
Async Testing with httpx
If your endpoints use async database operations or you need to test async code directly, use httpx.AsyncClient with pytest-asyncio.
# pip install httpx pytest-asyncio
import pytest
import httpx
from app.main import app
@pytest.fixture
async def async_client():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
@pytest.mark.anyio
async def test_async_endpoint(async_client):
response = await async_client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
@pytest.mark.anyio
async def test_async_create_and_fetch(async_client):
# Create
create_response = await async_client.post(
"/items",
json={"name": "Async Item", "price": 15.0}
)
assert create_response.status_code == 201
item_id = create_response.json()["id"]
# Fetch
get_response = await async_client.get(f"/items/{item_id}")
assert get_response.status_code == 200
assert get_response.json()["name"] == "Async Item"
Testing WebSocket Endpoints
TestClient supports WebSocket testing with a context manager.
def test_websocket_echo(client):
with client.websocket_connect("/ws") as ws:
ws.send_text("hello")
data = ws.receive_text()
assert data == "Echo: hello"
def test_websocket_json(client):
with client.websocket_connect("/ws/data") as ws:
ws.send_json({"action": "subscribe", "channel": "news"})
response = ws.receive_json()
assert response["status"] == "subscribed"
assert response["channel"] == "news"
def test_websocket_disconnect():
client = TestClient(app)
with client.websocket_connect("/ws/chat/test?username=tester") as ws:
ws.send_text("hello room")
data = ws.receive_text()
assert "hello room" in data
# Connection closed automatically when exiting context
Testing Error Responses and Edge Cases
Cover the unhappy paths — they are where bugs hide.
def test_not_found(client):
response = client.get("/items/99999")
assert response.status_code == 404
assert response.json()["detail"] == "Item not found"
def test_duplicate_creation(client, sample_item):
# First creation succeeds
client.post("/items", json=sample_item)
# Duplicate should fail
response = client.post("/items", json=sample_item)
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
def test_unauthorized_access():
client = TestClient(app) # No dependency override
response = client.get(
"/admin/settings",
headers={"Authorization": "Bearer invalid-token"}
)
assert response.status_code == 401
@pytest.mark.parametrize("invalid_price", [-1, 0, "abc", None])
def test_invalid_prices(client, invalid_price):
response = client.post(
"/items",
json={"name": "Test", "price": invalid_price}
)
assert response.status_code == 422
Parametrized tests are especially useful for validation testing. One test function covers multiple invalid inputs.
Structuring Your Test Suite
tests/
conftest.py # Shared fixtures
test_health.py # Health and status endpoints
test_items.py # Item CRUD tests
test_auth.py # Authentication and authorization
test_websockets.py # WebSocket endpoint tests
test_integration.py # Full workflow integration tests
factories.py # Test data factories
# tests/factories.py
from app.models import Item, User
def make_item(**overrides):
defaults = {
"name": "Default Item",
"price": 19.99,
"description": "Default description",
"category": "general"
}
defaults.update(overrides)
return Item(**defaults)
def make_user(**overrides):
defaults = {
"username": "testuser",
"email": "test@example.com",
"is_admin": False
}
defaults.update(overrides)
return User(**defaults)
Key Takeaways
FastAPI testing revolves around three core tools: TestClient for synchronous endpoint tests, dependency_overrides for isolating tests from real services, and pytest fixtures for sharing setup logic. Use transactional database sessions that roll back to keep tests independent. Cover both happy paths and error cases, and use parametrized tests for validation logic. Start with unit-level endpoint tests and add integration tests for complete workflows.
Related articles
- FastAPI FastAPI Testing with pytest
Write fast, reliable tests for FastAPI apps using TestClient, pytest fixtures, dependency overrides, and a separate test database.
- Python Python Testing with pytest: Fixtures, Marks, and Plugins
Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.
- Python Advanced pytest Patterns for Real-World Projects
Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.
- Testing Pytest Basics: Writing Your First Python Tests
A practical introduction to pytest — installation, test discovery, the assert statement, parametrize, fixtures, and the command-line flags you will use every day.