FastAPI Dependency Injection Testing Patterns
FastAPI's dependency injection system is one of its most powerful features — and one of the most important to test correctly. When you override dependencies in tests, you control exactly what your endpoints receive, making tests fast, deterministic, and free of external side effects. This guide covers every pattern you'll encounter when testing FastAPI applications that rely on DI.
Understanding app.dependency_overrides
The core mechanism for testing dependencies is app.dependency_overrides, a dictionary that maps original dependency callables to replacement callables. FastAPI checks this dictionary at request time, so overrides apply immediately without restarting the application.
# app/dependencies.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
user = db.query(User).filter(User.token == token).first()
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user# tests/test_users.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db, get_current_user
from unittest.mock import MagicMock
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def mock_db():
return MagicMock()
@pytest.fixture
def authenticated_client(mock_db):
fake_user = {"id": 1, "email": "test@example.com", "role": "admin"}
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_current_user] = lambda: fake_user
yield TestClient(app)
app.dependency_overrides.clear()
def test_get_profile_returns_user_data(authenticated_client):
response = authenticated_client.get("/users/me")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"Always clear dependency_overrides after each test. Leaving overrides in place will corrupt subsequent tests in the same session.
Testing with Fake Dependencies
A fake dependency is a real Python callable that returns predictable, controlled data. Unlike mocks, fakes actually implement the interface your code expects, making tests more robust.
# tests/fakes.py
from typing import Optional, List
from app.models import User, Item
class FakeUserRepository:
def __init__(self):
self.users: dict[int, User] = {}
self._next_id = 1
def create(self, email: str, password_hash: str) -> User:
user = User(id=self._next_id, email=email, password_hash=password_hash)
self.users[self._next_id] = user
self._next_id += 1
return user
def get_by_id(self, user_id: int) -> Optional[User]:
return self.users.get(user_id)
def get_by_email(self, email: str) -> Optional[User]:
return next((u for u in self.users.values() if u.email == email), None)
def list_all(self) -> List[User]:
return list(self.users.values())# tests/test_user_endpoints.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_user_repository
from tests.fakes import FakeUserRepository
@pytest.fixture
def fake_repo():
return FakeUserRepository()
@pytest.fixture
def client(fake_repo):
app.dependency_overrides[get_user_repository] = lambda: fake_repo
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_user_stores_in_repository(client, fake_repo):
response = client.post("/users", json={
"email": "newuser@example.com",
"password": "securepassword123"
})
assert response.status_code == 201
assert len(fake_repo.users) == 1
assert fake_repo.users[1].email == "newuser@example.com"
def test_get_nonexistent_user_returns_404(client):
response = client.get("/users/999")
assert response.status_code == 404
assert response.json()["detail"] == "User not found"
def test_list_users_returns_all(client, fake_repo):
fake_repo.create("alice@example.com", "hash1")
fake_repo.create("bob@example.com", "hash2")
response = client.get("/users")
assert response.status_code == 200
assert len(response.json()) == 2Testing Dependency Chains
FastAPI supports nested dependencies — a dependency that itself depends on other dependencies. Testing these chains requires understanding which level you want to override.
# app/dependencies.py
from fastapi import Depends, Header, HTTPException
from app.services import AuthService, PermissionService
def get_auth_service() -> AuthService:
return AuthService()
def get_permission_service(
auth: AuthService = Depends(get_auth_service)
) -> PermissionService:
return PermissionService(auth_service=auth)
def require_admin(
x_api_key: str = Header(...),
permissions: PermissionService = Depends(get_permission_service)
):
if not permissions.is_admin(x_api_key):
raise HTTPException(status_code=403, detail="Admin access required")
return True# tests/test_admin_endpoints.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_auth_service, get_permission_service, require_admin
class FakePermissionService:
def __init__(self, is_admin_result: bool = True):
self.is_admin_result = is_admin_result
def is_admin(self, api_key: str) -> bool:
return self.is_admin_result
@pytest.fixture
def admin_client():
# Override at the leaf level — no need to override get_auth_service too
app.dependency_overrides[get_permission_service] = lambda: FakePermissionService(is_admin_result=True)
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def non_admin_client():
app.dependency_overrides[get_permission_service] = lambda: FakePermissionService(is_admin_result=False)
yield TestClient(app)
app.dependency_overrides.clear()
def test_admin_endpoint_accessible_with_admin_role(admin_client):
response = admin_client.get("/admin/users", headers={"x-api-key": "any-key"})
assert response.status_code == 200
def test_admin_endpoint_blocked_without_admin_role(non_admin_client):
response = non_admin_client.get("/admin/users", headers={"x-api-key": "any-key"})
assert response.status_code == 403
assert "Admin access required" in response.json()["detail"]
def test_bypass_entire_chain():
# Sometimes you want to bypass the entire auth chain
app.dependency_overrides[require_admin] = lambda: True
client = TestClient(app)
response = client.get("/admin/users")
assert response.status_code == 200
app.dependency_overrides.clear()When overriding a dependency that's deep in a chain, FastAPI will use your override for all endpoints that transitively depend on it. This lets you override at the right level of abstraction for each test.
Testing Lifespan Dependencies
FastAPI 0.93+ uses the lifespan context manager for startup and shutdown logic. Testing resources initialized in lifespan requires a different approach.
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
import redis.asyncio as aioredis
redis_client: aioredis.Redis | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
global redis_client
redis_client = await aioredis.from_url("redis://localhost:6379")
app.state.redis = redis_client
yield
# Shutdown
await redis_client.close()
app = FastAPI(lifespan=lifespan)# tests/test_with_lifespan.py
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock
@pytest.fixture
def mock_redis():
redis = MagicMock()
redis.get = AsyncMock(return_value=b"cached_value")
redis.set = AsyncMock(return_value=True)
redis.delete = AsyncMock(return_value=1)
return redis
@pytest.fixture
def app_with_fake_redis(mock_redis):
@asynccontextmanager
async def fake_lifespan(app: FastAPI):
app.state.redis = mock_redis
yield
# Create a test-specific app instance
from app.routers import cache_router
test_app = FastAPI(lifespan=fake_lifespan)
test_app.include_router(cache_router)
return test_app
@pytest.fixture
def client(app_with_fake_redis):
with TestClient(app_with_fake_redis) as c:
yield c
def test_cache_hit_returns_cached_value(client, mock_redis):
mock_redis.get.return_value = b'{"id": 1, "name": "Alice"}'
response = client.get("/cache/user/1")
assert response.status_code == 200
assert response.json()["name"] == "Alice"
mock_redis.get.assert_called_once_with("user:1")
def test_cache_miss_fetches_from_db(client, mock_redis):
mock_redis.get.return_value = None
response = client.get("/cache/user/42")
assert response.status_code == 200
# Should have stored in cache after DB fetch
mock_redis.set.assert_called_once()Alternatively, use app.state directly with TestClient when you need simpler setups:
@pytest.fixture
def client_with_state(mock_redis):
from app.main import app
app.state.redis = mock_redis
with TestClient(app) as c:
yield c
# Clean up state
del app.state.redisTesting Background Task Dependencies
Background tasks in FastAPI run after the response is sent, making them tricky to test. The key is injecting a controllable BackgroundTasks instance or replacing the task function itself.
# app/routers/emails.py
from fastapi import APIRouter, BackgroundTasks, Depends
from app.services import EmailService
from app.dependencies import get_email_service
router = APIRouter()
def send_welcome_email_task(email: str, service: EmailService):
service.send_welcome(email)
@router.post("/users/register")
async def register_user(
user_data: UserCreate,
background_tasks: BackgroundTasks,
email_service: EmailService = Depends(get_email_service),
):
user = create_user(user_data)
background_tasks.add_task(
send_welcome_email_task,
email=user.email,
service=email_service,
)
return {"id": user.id, "email": user.email}# tests/test_background_tasks.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, call
from app.main import app
from app.dependencies import get_email_service
@pytest.fixture
def mock_email_service():
service = MagicMock()
service.send_welcome = MagicMock()
return service
@pytest.fixture
def client(mock_email_service):
app.dependency_overrides[get_email_service] = lambda: mock_email_service
# TestClient runs background tasks synchronously by default
yield TestClient(app)
app.dependency_overrides.clear()
def test_register_triggers_welcome_email(client, mock_email_service):
response = client.post("/users/register", json={
"email": "newuser@example.com",
"password": "password123"
})
assert response.status_code == 200
# TestClient executes background tasks before returning
mock_email_service.send_welcome.assert_called_once_with("newuser@example.com")
def test_register_returns_immediately_even_if_email_fails(mock_email_service):
mock_email_service.send_welcome.side_effect = Exception("SMTP error")
app.dependency_overrides[get_email_service] = lambda: mock_email_service
client = TestClient(app, raise_server_exceptions=False)
response = client.post("/users/register", json={
"email": "user@example.com",
"password": "password123"
})
# The endpoint itself should succeed even if background task fails
assert response.status_code == 200
app.dependency_overrides.clear()For more complex scenarios where you want to verify background tasks were queued without executing them:
# tests/test_background_task_queueing.py
from fastapi import BackgroundTasks
from unittest.mock import patch, MagicMock
def test_background_task_is_added_not_executed():
captured_tasks = []
class CapturingBackgroundTasks:
def add_task(self, func, *args, **kwargs):
captured_tasks.append({"func": func, "args": args, "kwargs": kwargs})
app.dependency_overrides[BackgroundTasks] = CapturingBackgroundTasks
with TestClient(app) as client:
response = client.post("/users/register", json={
"email": "user@example.com",
"password": "secret"
})
assert response.status_code == 200
assert len(captured_tasks) == 1
assert captured_tasks[0]["func"].__name__ == "send_welcome_email_task"
assert captured_tasks[0]["kwargs"]["email"] == "user@example.com"
app.dependency_overrides.clear()Scoped Dependency Overrides with pytest Fixtures
Using pytest's fixture scoping, you can set up overrides at the module, session, or function level for better performance and cleaner tests.
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db, get_settings, get_cache
@pytest.fixture(scope="session")
def fake_settings():
"""Session-scoped: expensive to create, safe to share."""
from app.config import Settings
return Settings(
database_url="sqlite:///:memory:",
redis_url="redis://localhost:6379/15", # test DB
secret_key="test-secret-key",
debug=True,
)
@pytest.fixture(scope="function")
def override_settings(fake_settings):
"""Function-scoped: ensures clean override per test."""
app.dependency_overrides[get_settings] = lambda: fake_settings
yield fake_settings
del app.dependency_overrides[get_settings]
@pytest.fixture(scope="function")
def client(override_settings, mock_db, mock_cache):
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_cache] = lambda: mock_cache
with TestClient(app) as c:
yield c
app.dependency_overrides.pop(get_db, None)
app.dependency_overrides.pop(get_cache, None)Testing Conditional Dependencies
Some dependencies behave differently based on environment or configuration. Here's how to test both branches:
# app/dependencies.py
from fastapi import Depends, Request
from app.config import get_settings
def get_rate_limiter(
request: Request,
settings = Depends(get_settings)
):
if settings.rate_limiting_enabled:
return RateLimiter(max_requests=100, window_seconds=60)
return NoOpRateLimiter()# tests/test_rate_limiting.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_rate_limiter
class FakeRateLimiter:
def __init__(self, should_limit: bool = False):
self.should_limit = should_limit
self.call_count = 0
def check(self, key: str) -> bool:
self.call_count += 1
return not self.should_limit
@pytest.fixture
def rate_limited_client():
limiter = FakeRateLimiter(should_limit=True)
app.dependency_overrides[get_rate_limiter] = lambda: limiter
yield TestClient(app), limiter
app.dependency_overrides.clear()
def test_rate_limited_request_returns_429(rate_limited_client):
client, limiter = rate_limited_client
response = client.get("/api/data")
assert response.status_code == 429
assert "Rate limit exceeded" in response.json()["detail"]
def test_rate_limiter_called_on_each_request(rate_limited_client):
client, limiter = rate_limited_client
limiter.should_limit = False # Allow requests
for _ in range(5):
client.get("/api/data")
assert limiter.call_count == 5Best Practices Summary
Always use fixtures for overrides — never set dependency_overrides in test functions without cleanup. A missed clear() call will silently break every test that runs after.
Override at the right level — if you want to test your business logic in isolation, override the infrastructure dependencies (get_db, get_redis). If you want to test your infrastructure code, override at a higher level.
Prefer fakes over mocks for repositories — fakes that implement the same interface as your real dependencies catch interface drift that mocks miss.
Test the unhappy path in dependencies — your endpoint tests should include cases where dependencies raise exceptions to verify your error handling.
Use TestClient as a context manager — with TestClient(app) as client: ensures proper startup/shutdown lifecycle, which matters when your lifespan dependencies initialize state.
FastAPI's dependency injection system was built with testability in mind. When you structure your overrides correctly, you get tests that are fast, isolated, and genuinely representative of how your application behaves under different conditions.