python.
python6 min read

FastAPI Dependency Injection: Designing for Testability

Practical patterns for FastAPI Depends(), sub-dependencies, and dependency_overrides — so your routes stay thin and your pytest fixtures stay sane.

FastAPI Dependency Injection: Designing for Testability

Most FastAPI tutorials introduce Depends() as a way to read a header or pull a database session. That works for a hello-world, but the moment you try to write a unit test against a route that depends on a database, an HTTP client, and a feature flag, the seams start showing. You either spin up real Postgres in CI (slow), or you patch psycopg at the import site (brittle), or you wrap everything in a Service class and bypass Depends entirely (defeats the point).

There is a third path that the framework was designed for: treat dependencies as composable functions, override them at the app level for tests, and never import a concrete adapter inside a route handler. This article walks through how to structure those dependencies so the same code runs in production against real infrastructure and in pytest against in-memory fakes \u2014 without monkeypatching.

Why Depends() is more than syntactic sugar

A FastAPI dependency is just a callable. The framework resolves it lazily per request, caches the result for the lifetime of that request, and supports both sync and async callables interchangeably. Three properties matter for testability:

  1. Resolution is lazy. The dependency function is not called at app startup; it is called when a request triggers a route that needs it. That gives you a hook to swap implementations.
  2. Resolution is cached per request. If three different functions in the same request all Depends(get_db), you get one session, not three. This matters for transaction scoping in tests.
  3. Resolution traverses sub-dependencies. A dependency can itself declare Depends(...) parameters, and the framework walks the graph for you.

The official docs cover the basics at https://fastapi.tiangolo.com/tutorial/dependencies/ \u2014 what they do not stress is that the shape of your dependency graph is what determines whether your tests are 50ms or 5s.

A bad dependency, and why it hurts

Here is a route that looks reasonable on first read:

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .db import SessionLocal
from .external import StripeClient

router = APIRouter()

def get_db() -> Session:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@router.post("/charge")
def charge(amount: int, db: Session = Depends(get_db)):
    stripe = StripeClient(api_key=os.environ["STRIPE_KEY"])
    customer = db.query(Customer).first()
    return stripe.charge(customer.id, amount)

Two problems. First, StripeClient is constructed inside the route, so a unit test cannot replace it without monkeypatching the import. Second, get_db is bound to SessionLocal, which means every test needs a real database connection or a global SessionLocal = MockSession() swap \u2014 which corrupts state across the test session.

Lift every external boundary into a dependency

Rewrite the same route so every external collaborator enters through Depends():

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from .deps import get_db, get_stripe_client
from .external import StripeClient

router = APIRouter()

@router.post("/charge")
def charge(
    amount: int,
    db: Session = Depends(get_db),
    stripe: StripeClient = Depends(get_stripe_client),
):
    customer = db.query(Customer).first()
    return stripe.charge(customer.id, amount)

Now the route is purely a function of its parameters. The deps.py module owns the wiring:

import os
from typing import Iterator
from fastapi import Depends
from sqlalchemy.orm import Session

from .db import SessionLocal
from .external import StripeClient

def get_db() -> Iterator[Session]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def get_settings() -> dict:
    return {"stripe_key": os.environ["STRIPE_KEY"]}

def get_stripe_client(settings: dict = Depends(get_settings)) -> StripeClient:
    return StripeClient(api_key=settings["stripe_key"])

Notice that get_stripe_client itself uses Depends(get_settings). That is a sub-dependency, and it is the unlock for testability: in tests, you can override get_settings to return a fake config without touching get_stripe_client at all.

Use app.dependency_overrides instead of monkeypatching

pytest fixtures plus dependency_overrides give you a clean injection point. The dictionary lives on the FastAPI app instance and lets you swap any callable in the dependency graph.

import pytest
from fastapi.testclient import TestClient
from myapp.main import app
from myapp.deps import get_db, get_stripe_client

class FakeStripe:
    def __init__(self):
        self.charges = []
    def charge(self, customer_id: str, amount: int):
        self.charges.append((customer_id, amount))
        return {"id": "ch_test", "amount": amount}

@pytest.fixture
def fake_stripe():
    return FakeStripe()

@pytest.fixture
def client(fake_stripe, db_session):
    app.dependency_overrides[get_db] = lambda: db_session
    app.dependency_overrides[get_stripe_client] = lambda: fake_stripe
    yield TestClient(app)
    app.dependency_overrides.clear()

def test_charge_records_amount(client, fake_stripe):
    response = client.post("/charge", json={"amount": 500})
    assert response.status_code == 200
    assert fake_stripe.charges == [("cust_1", 500)]

The override is a function with the same return shape \u2014 no Depends() magic in the override itself, since the test fixture has already supplied the concrete values. Clearing dependency_overrides in the teardown is non-negotiable, otherwise the overrides bleed into the next test and you spend an hour debugging why the integration suite passes alone but fails in the full run.

When to override the leaf, when to override the root

A common mistake is to override every leaf in the graph individually. If get_stripe_client depends on get_settings, and get_settings depends on get_secret_loader, you do not need to override all three. Override the highest-level dependency that produces the value the route consumes. In the example above, overriding get_stripe_client short-circuits everything below it \u2014 get_settings is never called during the test.

Override the leaf only when you want to test the composition \u2014 for example, "given fake settings, does get_stripe_client build the right client?" That is a unit test on the dependency itself, not on the route.

The rule of thumb: override at the boundary you care about. Routes care about the final injected value; dependency unit tests care about the wiring.

yield dependencies and resource cleanup

The generator pattern in get_db is FastAPI's way of expressing setup-then-teardown. The framework runs everything before the yield before the route, then everything after yield once the response is sent. That is fine for a database session, but it has a sharp edge in tests: if your override does not also yield, the cleanup branch never runs.

Compare:

def override_get_db():
    return mock_session

def override_get_db_correct():
    yield mock_session

Use the second form. It costs one extra line and matches the contract of the original. For pytest, a generator fixture is even cleaner because pytest itself manages the teardown:

@pytest.fixture
def db_session():
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.rollback()
        session.close()

The rollback() is the trick that makes a single Postgres database serve thousands of tests without state leakage \u2014 it is roughly 3\u00d7 faster than recreating the schema per test, and it composes cleanly with dependency_overrides.

When to reach for a class-based dependency

Function dependencies cover 90% of cases. The other 10% is when you need a dependency to hold state that is constructed once per app, not per request \u2014 a connection pool, an HTTP client with retry config, a cached settings object. FastAPI supports class instances as dependencies via Depends(MyClass), but the more explicit pattern is to construct the singleton at app startup (in lifespan) and expose it through a tiny accessor:

from contextlib import asynccontextmanager
import httpx

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(timeout=10.0)
    yield
    await app.state.http.aclose()

def get_http(request: Request) -> httpx.AsyncClient:
    return request.app.state.http

get_http is now overridable like any other dependency, and the underlying client is reused across requests. See the lifespan docs at https://fastapi.tiangolo.com/advanced/events/ for the full pattern.

Closing the loop

The shape that makes routes testable is small: lift every external boundary into Depends(), let sub-dependencies handle composition, and override at the level the test actually cares about. Done well, your route handlers become pure functions of their arguments, and your test suite runs in milliseconds because nothing real is constructed unless you ask for it.

References: