FastAPI Dependency Injection Testing Patterns

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()) == 2

Testing 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.redis

Testing 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 == 5

Best 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.

Read more

Start now free