Technical
Testing FastAPI Endpoints Without a Database
The fastest test is the one that does not touch the database. The slowest test is the one that spins up a Postgres container for each run. FastAPI's dependency injection makes it easy to swap the database for a fake, so your tests run in milliseconds and still verify the real behavior.
The Dependency Injection Pattern
In FastAPI, dependencies are functions that return the things routes need. Database access is typically a dependency:
def get_db():
return DynamoDB()
@router.get('/posts/{id}')
async def get_post(id: str, db=Depends(get_db)):
return db.get_item(id)The route does not import the database directly. It asks FastAPI for it via Depends. This is the seam we exploit for testing.
Overriding for Tests
FastAPI lets you override dependencies in tests:
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_db
class FakeDB:
def __init__(self):
self.store = {}
def get_item(self, id):
return self.store.get(id)
def get_fake_db():
return FakeDB()
app.dependency_overrides[get_db] = get_fake_db
client = TestClient(app)
def test_get_post():
response = client.get('/posts/123')
assert response.status_code == 404No Postgres, no DynamoDB, no Docker. The route runs exactly as it does in production, but the database is an in-memory dict.
What You Gain
- Speed: tests run in milliseconds
- Determinism: no shared state between test runs
- Parallelism: tests run in parallel without conflicts
- CI simplicity: no database containers in the CI config
My full test suite for a typical API runs in under 2 seconds. Fast enough to run on every save.
What You Lose
You do not verify SQL queries, migrations, or database-specific behavior. Those need integration tests that hit a real database. But you run those rarely (on PR, on deploy), not on every save.
The ratio I aim for: 100 unit tests per 10 integration tests. Most code paths are covered by the fast unit tests; the integration tests verify the database-specific layer.
Fake vs Mock
A fake is a working implementation (the in-memory dict above). A mock is a stub that returns preset values. I prefer fakes. Fakes catch bugs that mocks cannot, because they actually behave like the real thing.
If I mock db.get_item to return a hardcoded value, I might write a test that passes even though my real code has a logic error in how it uses the returned value. A fake forces the test to exercise the real code paths.
Factory Functions
I build a make_test_client() factory that sets up the fake DB with optional seed data:
def make_test_client(seed=None):
db = FakeDB()
if seed:
db.store = seed
app.dependency_overrides[get_db] = lambda: db
return TestClient(app), dbTests pass in their own seed data. Each test is isolated.
The Payoff
I can refactor with confidence because the test suite runs every time I save. Slow tests get skipped. Fast tests run constantly. The feedback loop is measured in seconds, not minutes.
See the FastAPI dependency override documentation for the complete pattern.
RELATED READING
The Consulting Shift I Am Making In Year Two
After a year of writing and building, my consulting practice is changing shape. Shorter engagements. Sharper outcomes.
ReadThe Frontend Shift: Shipping Less JavaScript In Year Two
A year ago I reached for Next.js for everything. This year I often reach for nothing.
ReadThe Serverless Lesson I Would Write On A Sticky Note
After a year of shipping serverless projects, one rule explains most of the wins and all of the losses.
Read