Your FastAPI app has a clean dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. chain: endpoints depend on get_current_user, which depends on get_db and oauth2_scheme. In production, get_db connects to PostgreSQL and get_current_user decodes real JWTWhat is jwt?JSON Web Token - a self-contained, signed token that carries user data (like user ID and role). The server can verify it without a database lookup. tokens. In tests, you want none of that. You want a test database (or no database), a fake user, and fast execution. FastAPI's dependency_overrides system makes this possible without changing a single line of production code.
TestClient basics
FastAPI's TestClient (built on Starlette's, which is built on httpx) lets you send HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. requests to your app without starting a server:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}TestClient creates your app in-process. Requests do not go over the network, they are handled directly by your ASGI app. This makes tests fast and deterministic: no port conflicts, no network timeouts, no race conditions.
Overriding dependencies
The core mechanism is app.dependency_overrides, a dictionary that maps original dependencies to replacement functions:
from app.main import app
from app.dependencies import get_db, get_current_user
# Create a mock database dependency
def override_get_db():
test_db = TestSessionLocal()
try:
yield test_db
finally:
test_db.close()
# Create a mock auth dependency
def override_get_current_user():
return {"id": 1, "email": "test@example.com", "role": "user"}
# Tell FastAPI to use the mocks
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
client = TestClient(app)
def test_protected_endpoint():
response = client.get("/me")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"When the test calls client.get("/me"), FastAPI sees that get_current_user has an override. Instead of extracting a tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. and querying the database, it calls override_get_current_user and returns the fake user. The endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. runs with that fake user as if it were real.
This works because dependency_overrides uses the function object as the key. get_db is a specific function in memory, and FastAPI replaces it wherever it appears in the dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. graph, even in nested dependencies.
Setting up a test database
For integration tests that actually exercise database queries, you need a test database:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
# Use SQLite in-memory for fast tests
TEST_DATABASE_URL = "sqlite:///./test.db"
test_engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestSessionLocal = sessionmaker(bind=test_engine)
# Create all tables before tests run
Base.metadata.create_all(bind=test_engine)
def override_get_db():
db = TestSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_dbThis gives you a real database with real SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. queries, but isolated from your production data. Each test run starts fresh (you can drop and recreate tables between tests for full isolation).
sqlite://) are the fastest option, but they do not support all PostgreSQL features (JSON operators, array types, RETURNING clause behavior). If your queries use PostgreSQL-specific features, use a real PostgreSQL test database with a different database name.Using pytest fixtures
Hardcoding overrides at moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions. level works but is inflexible. Pytest fixtures give you setup/teardown lifecycle:
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db, get_current_user
@pytest.fixture
def test_db():
Base.metadata.create_all(bind=test_engine)
db = TestSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture
def client(test_db):
def override_get_db():
yield test_db
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = lambda: {
"id": 1, "email": "test@example.com", "role": "user"
}
yield TestClient(app)
# Cleanup: remove all overrides after the test
app.dependency_overrides = {}
def test_create_user(client):
response = client.post("/users", json={"name": "Alice", "email": "alice@test.com"})
assert response.status_code == 201
def test_list_users(client):
response = client.get("/users")
assert response.status_code == 200The client fixture sets up overrides before each test and clears them after. This prevents override leakage, a test that overrides get_current_user with an admin user does not affect the next test that expects a regular user.
The override cleanup trap
If you forget to reset app.dependency_overrides, your tests become order-dependent. Consider this scenario:
# test_admin.py
def test_admin_endpoint():
app.dependency_overrides[get_current_user] = lambda: {"role": "admin"}
response = client.get("/admin/users")
assert response.status_code == 200
# Forgot to clean up!
# test_regular.py (runs after test_admin.py)
def test_regular_user_cannot_access_admin():
# This test PASSES even though it should not -
# the admin override from the previous test is still active!
response = client.get("/admin/users")
assert response.status_code == 403 # FAILS: returns 200 because user is still adminThe second test fails because it inherits the admin override from the first test. Cleaning up overrides is not optional, it is a correctness requirement.
pytest test_admin.py) but fail when run together (pytest). If you see AI-generated tests with app.dependency_overrides and no cleanup, add app.dependency_overrides = {} in a fixture's teardown or in finally blocks.Testing different user roles
A common testing need is running the same endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. with different user roles:
@pytest.fixture
def admin_client():
app.dependency_overrides[get_current_user] = lambda: {
"id": 1, "role": "admin", "email": "admin@test.com"
}
yield TestClient(app)
app.dependency_overrides = {}
@pytest.fixture
def user_client():
app.dependency_overrides[get_current_user] = lambda: {
"id": 2, "role": "user", "email": "user@test.com"
}
yield TestClient(app)
app.dependency_overrides = {}
def test_admin_can_delete(admin_client):
response = admin_client.delete("/users/2")
assert response.status_code == 200
def test_user_cannot_delete(user_client):
response = user_client.delete("/users/2")
assert response.status_code == 403Each fixture creates a client with a specific user role. The tests are readable, the fixture name tells you what role is being tested.
Are you testing mocks or logic?
This is the most important question to ask about any test, and it is where AI-generated tests fail most often. Consider:
# AI-generated test
def test_get_user():
app.dependency_overrides[get_current_user] = lambda: {"id": 1, "name": "Alice"}
response = client.get("/me")
assert response.status_code == 200
assert response.json()["name"] == "Alice"What does this test actually prove? It proves that when you inject a user with name: "Alice", the endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. returns name: "Alice". That is testing the mockWhat is mock?A fake replacement for a real dependency in tests that records how it was called so you can verify interactions., not the logic. The test would pass even if the endpoint was just return current_user with zero business logic.
A meaningful test exercises real behavior:
# Meaningful test: actually creates data in the test DB and verifies behavior
def test_user_can_only_see_own_notes(client, test_db):
# Create notes for two different users
test_db.add(Note(id=1, user_id=1, content="User 1's note"))
test_db.add(Note(id=2, user_id=2, content="User 2's note"))
test_db.commit()
# Current user is user 1 (from the client fixture)
response = client.get("/notes")
notes = response.json()
# Should only see their own notes
assert len(notes) == 1
assert notes[0]["content"] == "User 1's note"This test creates real data, runs a real query, and verifies real filtering logic. If the endpoint has an IDORWhat is idor?Insecure Direct Object Reference - a vulnerability where an API lets any logged-in user access any resource by simply changing the ID in the URL. bug (returns all notes instead of the user's notes), this test catches it.
Quick reference: testing patterns
| What to test | Override strategy | What to verify |
|---|---|---|
| Endpoint returns correct data | Override get_db with test DB, seed data | Response matches expected data from DB |
| Auth blocks unauthenticated users | Do NOT override get_current_user | Response is 401 |
| Role-based access | Override with different roles | Admin gets 200, regular user gets 403 |
| Input validation | Override auth, send invalid payloads | Response is 422 with validation errors |
| Database writes | Override get_db, check DB after request | New row exists with correct values |
| Error handling | Override with a DB that raises exceptions | Response is 500 with error message |