Testing endpoints with TestClient
TestClient — calling routes without a real server
Every exercise in the previous lesson assumed something never actually
explained: how do you test a FastAPI route without starting uvicorn
and making real network requests to it? TestClient is the answer —
it calls your app’s routes directly, in-process, with no real server
or network socket involved at all:
# main.py
from fastapi import Depends, FastAPI, HTTPException, Path
from pydantic import BaseModel
app = FastAPI()
class Agent(BaseModel):
id: int
name: str
model: str
agents_db = {
42: {"id": 42, "name": "research_agent", "model": "claude-sonnet"},
}
def get_agents_db():
return agents_db
@app.get("/agents/{agent_id}", response_model=Agent)
def get_agent(agent_id: int = Path(gt=0), db: dict = Depends(get_agents_db)):
if agent_id not in db:
raise HTTPException(status_code=404, detail=f"agent {agent_id} not found")
return db[agent_id]# test_main.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_get_agent_returns_expected_data():
response = client.get("/agents/42")
assert response.status_code == 200
assert response.json() == {"id": 42, "name": "research_agent", "model": "claude-sonnet"}click Run to see this pane's outputOne call to client.get("/agents/42") exercises everything wired
around get_agent, not just its own three lines: FastAPI’s router
matching /agents/{agent_id}, Path(gt=0) parsing "42" into a real
int and confirming it’s positive, Depends(get_agents_db) actually
being resolved and passed in, the route’s own lookup, and finally
response_model=Agent validating and shaping what comes back out —
five independently-testable pieces, all cooperating in that one
request, with .status_code and .json() available exactly as if a
real HTTP request had actually happened, no server ever running on a
port.
This is an integration test, not a unit test
Concept 1’s is_valid_tool_name tests
exercised one isolated function, with nothing else involved.
test_get_agent_returns_expected_data is doing something different in
kind, not just in size — it’s really five separate mechanisms, each of
which could break on its own, all needing to work together for this
one test to pass:
- routing actually matches
/agents/{agent_id}toget_agent Path(gt=0)really does convert and validate the path segmentDepends(get_agents_db)actually resolves and hands in the dictget_agent’s own lookup logic runs correctly against itresponse_model=Agentactually shapes and validates what comes back
This is an integration test — checking that multiple pieces
integrate correctly, rather than checking one piece in isolation. If
get_agents_db were swapped for a real database connection tomorrow,
or Path(gt=0) were accidentally removed, or Agent stopped declaring
model, this exact test would catch it — not because it targets that
specific piece, but because it exercises the whole path all at once.
Neither kind is “better” — they answer different questions. A unit test answers “does this one function behave correctly, on its own?” An integration test answers “does the whole path — routing, validation, dependency injection, the function, serialization — actually work together, the way a real caller would experience it?” A healthy test suite generally has many unit tests (fast, precise, easy to pinpoint a failure) and a smaller number of integration tests (slower, but catching problems that only show up when pieces interact — a route accidentally left off a router, a dependency wired up incorrectly).
Testing error responses the same way
TestClient handles error paths identically —
Lesson 9’s HTTPException/validation-driven 422s
show up exactly as real HTTP responses:
def test_get_agent_not_found_returns_404():
response = client.get("/agents/999")
assert response.status_code == 404def test_get_agent_invalid_id_returns_422():
response = client.get("/agents/not-a-number")
assert response.status_code == 422def test_get_agent_negative_id_returns_422():
response = client.get("/agents/-1")
assert response.status_code == 422That last one exercises Path(gt=0) specifically — -1 is a
perfectly valid integer, so this test only passes if the constraint is
actually being enforced, not just the type conversion.
click Run to see this pane's outputNothing new here beyond what’s already been covered —
response.status_code and response.json() work uniformly whether
the underlying route succeeded or failed, which is exactly why
systematically testing error paths (covered properly later in this
lesson) doesn’t require a different tool, just deliberately writing
tests for the failure cases too, not only the success case.
What does TestClient let you do that calling a route function directly, in plain Python, wouldn't?