Overriding Dependencies in FastAPI Tests

Key takeaways:

  • app.dependency_overrides is a plain dict keyed by the callable object the route declares — identity, not name.
  • An override keyed on the wrong callable fails silently. There is no warning, no error, just the real dependency running.
  • The dictionary lives on the app, so it outlives the test. Clear it in fixture teardown or tests contaminate each other.
  • Overriding a yield dependency works, and the replacement should also yield if you want its teardown exercised.
  • Overrides are application-wide. Per-route substitution needs a distinct provider or a per-test app.

Testing is the payoff for building the graph described in dependency injection strategies: every node in it is replaceable from the outside without touching the code under test.

The Problem This Solves

Your /me endpoint depends on get_current_user, which reads a header and validates a token, which needs a secret, which needs settings. To test the handler's actual logic you would otherwise need a valid token, a signing key, and probably a user row. Overrides let you cut the graph at exactly one node and hand the handler the value it needs.

The failure mode is what makes this worth a page. Overrides use dict lookup on a function object. When the key does not match, nothing complains — the test simply runs against production behaviour and fails with an error that looks like a bug in your code.

How dependency_overrides substitutes a node by identity The resolver looks up the declared callable in the overrides dictionary. A matching key returns the fake provider. A key that is a different object with the same name misses, and the real provider runs. Depends(get_user) overrides.get(callable) compared with is, not by name key matches fake provider runs key misses real provider runs, silently Nothing is logged on a miss — the test just exercises production code.
The override is a dictionary lookup on the function object. A near-identical function is a different key.

Why It Happens: One Dict, Keyed by Identity

app.dependency_overrides is an ordinary dictionary attribute on the FastAPI instance. During resolution, before FastAPI calls a provider it checks whether that provider appears as a key and, if so, calls the value instead. The lookup uses the callable's hash and equality, and for plain functions that is object identity.

That single design choice explains both the power and the sharp edge. It is the same identity rule that governs the per-request cache in dependency caching and use_cache: two functions with the same name and the same body are two different keys.

The dictionary is also not request-scoped or test-scoped. It is application state. If your client fixture is session-scoped — as most are, because constructing the app is slow — then an override written in the third test is still installed for the ninetieth.

Proving It: Before, After, and the Silent Miss

This app makes the whole cycle observable in one transcript. /me depends on a real get_current_user (which rejects requests without a token) and a real get_db (a yield dependency). Three control endpoints mutate app.dependency_overrides exactly as a test fixture would, and /me is called again after each mutation.

"""app.dependency_overrides: real before/after responses, yield overrides, and wrong-key misses."""
from collections.abc import AsyncGenerator
from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()


async def get_current_user(x_token: Annotated[str | None, Header()] = None) -> dict:
    """The real dependency: refuses to run without a token."""
    if x_token != "prod-secret":
        raise HTTPException(status_code=401, detail="Not authenticated")
    return {"id": 1, "name": "real-user", "source": "production"}


async def get_db() -> AsyncGenerator[dict, None]:
    session = {"engine": "postgres", "rows": ["real-row"]}
    try:
        yield session
    finally:
        session["closed"] = True


UserDep = Annotated[dict, Depends(get_current_user)]
DbDep = Annotated[dict, Depends(get_db)]


@app.get("/me")
async def me(user: UserDep, db: DbDep):
    return {"user": user, "rows": db["rows"]}


async def fake_user() -> dict:
    return {"id": 99, "name": "test-user", "source": "override"}


async def fake_db() -> AsyncGenerator[dict, None]:
    session = {"engine": "sqlite-memory", "rows": ["fixture-row"]}
    try:
        yield session
    finally:
        session["closed"] = True


# A callable that is NOT the one the endpoint declares — the classic silent no-op.
async def get_current_user_copy() -> dict:
    return {"id": 0, "name": "never-used", "source": "wrong-key"}


@app.post("/_control/override-correct")
async def override_correct():
    app.dependency_overrides[get_current_user] = fake_user
    app.dependency_overrides[get_db] = fake_db
    return {"overrides": sorted(fn.__name__ for fn in app.dependency_overrides)}


@app.post("/_control/override-wrong-key")
async def override_wrong_key():
    app.dependency_overrides.clear()
    app.dependency_overrides[get_current_user_copy] = fake_user
    return {"overrides": sorted(fn.__name__ for fn in app.dependency_overrides)}


@app.post("/_control/clear")
async def clear():
    app.dependency_overrides.clear()
    return {"overrides": sorted(fn.__name__ for fn in app.dependency_overrides)}

The transcript below is the harness's real output:

$ GET /me
401 Unauthorized
{
  "detail": "Not authenticated"
}

$ POST /_control/override-correct
200 OK
{
  "overrides": [
    "get_current_user",
    "get_db"
  ]
}

$ GET /me
200 OK
{
  "user": {
    "id": 99,
    "name": "test-user",
    "source": "override"
  },
  "rows": [
    "fixture-row"
  ]
}

$ POST /_control/override-wrong-key
200 OK
{
  "overrides": [
    "get_current_user_copy"
  ]
}

$ GET /me
401 Unauthorized
{
  "detail": "Not authenticated"
}

$ POST /_control/clear
200 OK
{
  "overrides": []
}

$ GET /me
401 Unauthorized
{
  "detail": "Not authenticated"
}

Before: 401 Not authenticated. The real dependency runs and rejects the request.

After the correct override: 200, with source: "override" and rows: ["fixture-row"]. Both nodes were replaced, including the yield dependency — the override generator's value flowed into the handler exactly like the original's.

After the wrong-key override: back to 401. This is the whole point. The dictionary contains one entry, get_current_user_copy, which has the same signature and returns a plausible user. The application reports the override as installed. And /me behaves as if you had written nothing at all. There is no warning, no log line, and no exception — just a test that fails with 401 and sends you looking for an authentication bug that does not exist.

After clearing: 401, the original behaviour restored. That final line is what your fixture teardown must guarantee.

The Fix: Fixtures That Cannot Leak

Two rules make overrides safe. Register them in a fixture, and clear them in that fixture's teardown so they are removed even when the test raises.

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient

from app.deps import get_current_user, get_db
from app.main import app


@pytest.fixture
def override():
    """Install overrides for one test and guarantee removal, even on failure."""
    def _install(**mapping):
        app.dependency_overrides.update(mapping)
    yield _install
    app.dependency_overrides.clear()      # teardown runs even if the test raised


@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c


def test_me_returns_the_injected_user(client, override):
    override(**{get_current_user: fake_user, get_db: fake_db})
    body = client.get("/me").json()
    assert body["user"]["name"] == "test-user"

clear() is a blunt instrument — it removes overrides another fixture installed too. If your suite has a base set of overrides that should always be present (a test database, a stubbed clock), snapshot and restore instead:

@pytest.fixture
def override():
    original = dict(app.dependency_overrides)
    yield app.dependency_overrides.update
    app.dependency_overrides.clear()
    app.dependency_overrides.update(original)   # restore the baseline, not empty

Import the exact object the route imports

The wrong-key failure is almost always an import problem. The route module does from app.deps import get_db; the test does from app.database import get_db, where app.database re-exports it through a wrapper, or app.deps re-imports it under a shim. Both names resolve to something callable, and only one of them is the object the route holds a reference to.

A cheap assertion turns the silent failure into a loud one:

def test_override_key_is_real(client, override):
    override(**{get_current_user: fake_user})
    # Assert the behaviour changed, not that the key is in the dict.
    assert client.get("/me").status_code != 401, "override key did not match the route's callable"

In practice the simplest guard is the one above: assert on the behaviour change, not on the dictionary. A test that only asserts get_current_user in app.dependency_overrides passes happily in the wrong-key case, as the transcript shows.

Overriding a yield Dependency

The transcript already proved this works, but the detail worth knowing is that the replacement is treated as an ordinary dependency: if it is a generator it joins the request's exit stack and gets the same LIFO teardown and the same exception delivery described in yield dependencies and cleanup order.

That matters when the thing you are testing is the teardown. A rollback-on-error test needs an override that actually yields:

async def recording_session():
    session = FakeSession()
    try:
        yield session
        session.events.append("commit")
    except Exception:
        session.events.append("rollback")
        raise
    finally:
        session.events.append("close")

Replacing that with a plain async def that returns a FakeSession would make the test pass while exercising none of the lifecycle.

Scoping: Overrides Are Application-Wide

There is no per-route override API. If /orders and /admin/orders both depend on get_current_user and you want a different fake for each, you have two options.

Distinct providers. Declare get_admin_user and get_current_user as separate callables. This is usually better design anyway — the two routes genuinely have different authorization requirements — and it makes the override targetable.

A fresh app per test. With the application factory pattern, create_app() returns a new instance with an empty overrides dict, so nothing can leak between tests at all. The cost is app construction time on every test, which is why most suites use a session-scoped app plus disciplined teardown, and reserve per-test apps for tests that change configuration or the middleware stack.

Trade-Offs and When Not To

An override is a mock with better ergonomics, and it has a mock's failure mode. If fake_user returns a shape the real get_current_user never produces, every test passes and production breaks. Type the fake with the same return annotation and, where the shape matters, build it from the same Pydantic model.

Over-overriding hollows out the test. Replacing the session, the user, the settings and the external client leaves a test that asserts your fakes agree with each other. Keep at least one integration-level test that runs the real graph against a real (test) database — the approach in testing with async database fixtures.

Overrides do not touch middleware. Anything registered as middleware runs unchanged in tests, which is exactly the asymmetry discussed in middleware vs dependencies. If a behaviour must be stubbable per test, a dependency is the better home for it.

Async client, same rules. Whether you drive the app with TestClient or httpx.AsyncClient makes no difference to overrides; both go through the same ASGI app and read the same dictionary. The choice between them is covered in TestClient vs httpx AsyncClient.

FAQ

Why does my dependency override have no effect? The override dictionary is keyed by the exact callable object the route declares. If the test imports the provider from a different module path, wraps it, or the route depends on a factory-produced closure, the key never matches and the real dependency runs with no warning at all.

Do I have to clear dependency_overrides between tests? Yes. The dictionary lives on the application object, which is usually module-scoped or session-scoped in a test suite, so an override set in one test leaks into every test that runs after it. Clear it in fixture teardown, never at the end of the test body.

Can I override a dependency that uses yield? Yes, and the replacement should also use yield if you want teardown to run. FastAPI treats the override exactly like a normal dependency, so a generator override gets the same setup, LIFO teardown and exception delivery as the original.

Should I override the dependency or patch the function it calls? Override the dependency. It is the documented seam, it is type-checked, and it replaces the node in the resolution graph rather than reaching inside an implementation. Reserve monkeypatching for third-party code that is not exposed as a dependency.

How do I override a dependency for only one route? You cannot scope an override to a route through dependency_overrides, which is application-wide. Either give the route its own distinct provider callable, or build a fresh app instance per test with an application factory.

Does an override still get its sub-dependencies injected? Yes. The replacement is resolved like any other dependency, so if it declares Depends(...) parameters they are satisfied normally — including from the per-request cache. A fake that itself needs settings can simply ask for them.