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"}
pytest
click Run to see this pane's output

One 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} to get_agent
  • Path(gt=0) really does convert and validate the path segment
  • Depends(get_agents_db) actually resolves and hands in the dict
  • get_agent’s own lookup logic runs correctly against it
  • response_model=Agent actually 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 == 404
def test_get_agent_invalid_id_returns_422():
    response = client.get("/agents/not-a-number")
    assert response.status_code == 422
def test_get_agent_negative_id_returns_422():
    response = client.get("/agents/-1")
    assert response.status_code == 422

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

pytest
click Run to see this pane's output

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

Check your understanding
1/4

What does TestClient let you do that calling a route function directly, in plain Python, wouldn't?