Mocking External Services in FastAPI Tests
Key takeaways:
- Dependency overrides replace a collaborator;
MockTransportreplaces the socket. Pick by which layer you want tested. app.dependency_overridesis keyed by the callable, so it applies everywhere the dependency is declared, including nested.- Monkeypatching binds to an import path and breaks silently when the import moves.
- Always clear overrides in fixture teardown — the dict lives on the app and outlives the test.
- A double that never raises leaves your error handling untested; make the double fail too.
Your test suite either calls a live third-party API or fails in CI because it cannot. This page, part of Testing FastAPI Applications, compares the three ways to cut that dependency and shows real responses from each.
Choose by What You Want to Test
The three techniques are not interchangeable, because they cut the call chain at different points and therefore leave different amounts of code under test.
A dependency override replaces the object your endpoint asked for. Everything from the endpoint down is real; the client is a stand-in. Use it when the endpoint's logic is what you are testing — its branching, its error mapping, its response shape — and the upstream is incidental.
Monkeypatching replaces an attribute on a module. It works anywhere, including on code that never went through the dependency system, which is exactly why it is the fallback rather than the default: it binds to where a name is imported, not to what it is.
httpx.MockTransport replaces the transport underneath an httpx client. Your real client code runs — URL construction, headers, timeouts, raise_for_status(), response parsing — and only the socket is faked. Use it when the client code is the code you are testing, or when you want to assert on the exact request that would go out.
The rough rule: override when the upstream is a detail, MockTransport when the upstream call is the point, monkeypatch when neither seam exists.
Dependency Overrides
FastAPI keeps a dict on the app: app.dependency_overrides, mapping the original callable to a replacement. During resolution, solve_dependencies looks up each dependency there before calling it. Because the key is the callable object itself, the substitution reaches every declaration site — the path operation, router-level dependencies, and any sub-dependency several levels down.
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, HTTPException
class WeatherClient:
"""The production client. In a test it must never be constructed against the real host."""
def __init__(self, base_url: str) -> None:
self.base_url = base_url
async def forecast(self, city: str) -> dict[str, object]:
CALLS.append(f"REAL call to {self.base_url}/forecast?city={city}")
raise RuntimeError("this would open a socket to the real weather API")
class FakeWeatherClient:
"""The test double. Same shape, no I/O, and it records what was asked of it."""
def __init__(self) -> None:
self.seen: list[str] = []
async def forecast(self, city: str) -> dict[str, object]:
self.seen.append(city)
CALLS.append(f"fake forecast({city})")
if city == "nowhere":
raise httpx.HTTPStatusError("404", request=None, response=None)
return {"city": city, "temp_c": 21.5, "source": "fake"}
def get_weather_client() -> WeatherClient:
return WeatherClient("https://api.weather.example.com")
@app.get("/weather/{city}")
async def weather(
city: str,
client: Annotated[WeatherClient, Depends(get_weather_client)],
) -> dict[str, object]:
try:
return await client.forecast(city)
except httpx.HTTPStatusError:
raise HTTPException(status_code=502, detail=f"weather upstream has no city {city}")
fake = FakeWeatherClient()
app.dependency_overrides[get_weather_client] = lambda: fake
The real client raises if it is ever called, which turns "the override did not apply" from a silent network call into an immediate failure. That is worth doing in your own fakes: make the production path in a test environment loud.
Real output for a success and an upstream error:
$ GET /weather/berlin
200 OK
{
"city": "berlin",
"temp_c": 21.5,
"source": "fake"
}
$ GET /weather/nowhere
502 Bad Gateway
{
"detail": "weather upstream has no city nowhere"
}
The second response is the more valuable one. The double raised httpx.HTTPStatusError, and the endpoint's except branch mapped it to a 502 with a useful detail — error-mapping logic that would otherwise only run in production. A test double that cannot fail cannot test failure handling.
In pytest, the override belongs in a fixture with teardown, because dependency_overrides lives on the app object and persists between tests:
@pytest.fixture
def fake_weather(app):
fake = FakeWeatherClient()
app.dependency_overrides[get_weather_client] = lambda: fake
yield fake
app.dependency_overrides.pop(get_weather_client, None) # Restore, do not clear().
Popping the single key rather than calling clear() matters once you have more than one fixture installing overrides — clear() removes another fixture's override too, and the resulting failure appears in an unrelated test. The override mechanism itself is covered in overriding dependencies in tests, and it works best when the client was injected in the first place, which is an argument for the patterns in best practices for FastAPI dependency injection.
Monkeypatching, and Why It Is Third Choice
monkeypatch.setattr swaps an attribute for the duration of a test and restores it afterwards. The catch is which attribute:
# app/services/weather.py
from app.clients import fetch_forecast # Bound at import time.
async def summarise(city: str) -> str:
data = await fetch_forecast(city)
return f"{data['temp_c']}C"
Patching app.clients.fetch_forecast here does nothing, because app.services.weather already holds its own reference to the original function. You must patch app.services.weather.fetch_forecast — the name in the module that uses it. That rule ("patch where it is looked up, not where it is defined") is well known and still routinely gets it wrong after a refactor moves an import, at which point the patch silently stops applying and the test makes a real network call. In CI with no egress it fails confusingly; with egress it passes, slowly, against production.
Monkeypatching is still the right tool when there is no seam: a module-level client created at import time, a third-party SDK that constructs its own transport, a function called from code that never sees a dependency. When you do use it, patch the narrowest thing you can and assert the patch took effect.
httpx MockTransport
MockTransport takes a handler function that receives an httpx.Request and returns an httpx.Response. The client is otherwise entirely real.
def handler(request: httpx.Request) -> httpx.Response:
CALLS.append(f"MockTransport saw {request.method} {request.url}")
if request.url.path == "/v1/rates":
return httpx.Response(200, json={"USD": 1.0, "EUR": 0.92})
return httpx.Response(500, json={"error": "unmapped route"})
mock_transport = httpx.MockTransport(handler)
@app.get("/rates")
async def rates() -> dict[str, object]:
# Production builds this client without the transport argument; the test injects it.
async with httpx.AsyncClient(
transport=mock_transport, base_url="https://rates.example.com"
) as client:
response = await client.get("/v1/rates", headers={"accept": "application/json"})
response.raise_for_status()
return {"rates": response.json(), "upstream_status": response.status_code}
Real output for a stubbed route and an un-stubbed one:
$ GET /rates
200 OK
{
"rates": {
"USD": 1.0,
"EUR": 0.92
},
"upstream_status": 200
}
$ GET /unmapped
200 OK
{
"status": 500,
"body": {
"error": "unmapped route"
}
}
And the record of what the transport actually saw, which is what you assert on when the request itself is the contract:
$ GET /calls
200 OK
{
"calls": [
"fake forecast(berlin)",
"fake forecast(nowhere)",
"MockTransport saw GET https://rates.example.com/v1/rates",
"MockTransport saw GET https://rates.example.com/v1/unknown"
],
"cities_seen_by_fake": [
"berlin",
"nowhere"
]
}
Notice the fully-resolved URLs. base_url joining, path handling and query encoding all ran for real, so a bug in URL construction — the kind a hand-written fake would paper over — is visible here. The handler's fall-through returning a 500 rather than raising is a deliberate choice: an un-stubbed route answers loudly instead of hanging or escaping to the network. Making the fall-through raise AssertionError(f"unexpected request: {request.url}") is even stricter and often better in a real suite.
The injection point is the only awkward part. Production code should not know about MockTransport, so the client needs to come from somewhere replaceable — which brings you back to a dependency:
def get_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(base_url=settings.rates_url, timeout=5.0)
# In the test:
app.dependency_overrides[get_http_client] = lambda: httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="https://rates.example.com"
)
That combination — an override supplying a real client with a mock transport — is the strongest of the three. The endpoint is real, the client is real, and only the network is fake.
Verification
The assertions that catch mocking mistakes are about the call, not just the response:
def test_endpoint_calls_upstream_once(client, fake_weather):
client.get("/weather/berlin")
assert fake_weather.seen == ["berlin"] # Called once, with the right argument.
def test_no_real_network_calls(client, fake_weather):
client.get("/weather/berlin")
assert not any(call.startswith("REAL") for call in CALLS)
The second test is the guard rail. In a large suite it is worth enforcing globally with a session fixture that patches the socket module to raise, so any test that escapes to the network fails immediately with a clear message rather than being slow and flaky.
Keep at least one test that talks to the real service, run on a schedule rather than in every pipeline. Every mock encodes an assumption about the upstream's behaviour on the day you wrote it, and the failure mode of a stale mock is a green suite and a broken production integration.
Trade-offs and When Not To
Every mock is a claim about someone else's system. A hand-written double drifts from the real API as it evolves, and the drift is invisible until deployment. MockTransport reduces the surface of that claim to the response payload; a dependency override widens it to the whole client interface.
Over-mocking is the more common failure than under-mocking. A test that replaces the database, the cache, the HTTP client and the clock verifies that your mocks are wired together correctly. Prefer real in-memory substitutes where they exist — SQLite for a repository test, a real Redis in a container for cache behaviour — and reserve doubles for the boundaries you genuinely cannot cross in CI.
Finally, remember that overriding a dependency bypasses everything that dependency would have done, including validation and authentication. Overriding get_current_user for convenience means your endpoints are never tested with a real token path, and an authorisation bug there is exactly the kind of thing tests are supposed to catch. Override the client, not the guard, wherever you can.
FAQ
Why are dependency overrides usually better than monkeypatching in FastAPI? Because the override is keyed by the dependency callable rather than by an import path. FastAPI substitutes it everywhere that dependency is declared, including in nested dependencies, so a refactor that moves a module cannot silently stop the mock from applying.
When is httpx MockTransport the right tool? When the code under test is the HTTP client code itself: headers, retries, timeouts, status handling and response parsing. MockTransport intercepts at the transport layer, so all of that still runs and only the socket is replaced.
Do I need to reset dependency_overrides between tests? Yes. The dictionary lives on the app object, so an override installed by one test leaks into every test that follows. Clear it in fixture teardown, and prefer restoring the previous value over calling clear() if other fixtures also install overrides.
Should the test double match the real client's interface exactly? Close enough that the endpoint cannot tell them apart, including the exceptions it raises. A double that only implements the happy path lets error handling ship untested, which is usually the code most in need of a test.
Can I still get real behaviour with mocks in place? Only for the layer you did not replace. Keep at least one contract test that runs against the real service or a recorded contract, because every mocking strategy encodes your assumption about the upstream, and assumptions drift.
Related Reading
- Up to the topic: Testing FastAPI Applications.
- The override mechanism: Overriding dependencies in tests.
- Async test wiring: Testing async endpoints with pytest-asyncio.
- Choosing a client: TestClient vs httpx AsyncClient.
- Injectable design: Best practices for FastAPI dependency injection.