TestClient (from starlette.testclient) wraps your FastAPI app in a
requests-compatible interface. It runs the full ASGI stack synchronously
so you can test endpoints without starting a real server.
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/items/{id}")
async def get_item(id: int):
return {"id": id}
client = TestClient(app)
def test_get_item():
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {"id": 42}
Rule of thumb: create TestClient once per module (or as a pytest fixture)
rather than on every test — instantiation is cheap but consistent fixture
scope is cleaner.
Use TestClient as a context manager:
from fastapi.testclient import TestClient
from app.main import app
def test_with_lifespan():
with TestClient(app) as client:
# startup has run (DB connected, app.state populated)
response = client.get("/health")
assert response.status_code == 200
# shutdown has run (DB disconnected)
Without the with block, lifespan events don't run, and any code that depends
on app.state will fail.
Rule of thumb: always use with TestClient(app) as client: in tests that need
startup resources; plain TestClient(app) is fine for stateless routes.
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
def test_list_items(client):
resp = client.get("/items")
assert resp.status_code == 200
def test_create_item(client):
resp = client.post("/items", json={"name": "Widget", "price": 9.99})
assert resp.status_code == 201
scope="module" starts the app once per module, which is faster than per-test.
Use scope="function" if tests mutate state that must be reset.
Rule of thumb: use scope="module" for read-heavy test files; use scope="function"
for tests that write to a database or modify app.state.
Pass the json= keyword argument — TestClient serialises it and sets
Content-Type: application/json automatically:
def test_create_item(client):
response = client.post(
"/items",
json={"name": "Widget", "price": 9.99},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Widget"
assert "id" in data
For raw string bodies: data='{"name":"Widget"}' with headers={"Content-Type": "application/json"}.
Rule of thumb: always use json= (not data=) for JSON payloads — it handles
serialisation correctly and sets the content type header.
Pass headers= dict to the request method:
def test_authenticated(client):
token = create_test_token(user_id=1)
response = client.get(
"/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
Or set default headers on the client itself:
@pytest.fixture
def auth_client(client):
token = create_test_token(user_id=1)
client.headers.update({"Authorization": f"Bearer {token}"})
return client
Rule of thumb: create a separate auth_client fixture for tests that always
need auth headers — it avoids repeating the header on every request.
Pass params= dict:
def test_search(client):
response = client.get("/items", params={"q": "widget", "page": 2, "size": 10})
assert response.status_code == 200
# equivalent to GET /items?q=widget&page=2&size=10
For multi-value query params:
response = client.get("/items", params=[("tags", "python"), ("tags", "web")])
# GET /items?tags=python&tags=web
Rule of thumb: use params= dict for simple query strings; use a list of tuples
when the same key appears multiple times.
Pass data= (not json=) for URL-encoded form data:
def test_login_form(client):
response = client.post(
"/token",
data={"username": "alice", "password": "secret", "grant_type": "password"},
)
assert response.status_code == 200
assert "access_token" in response.json()
For multipart file upload:
def test_file_upload(client):
response = client.post(
"/upload",
files={"file": ("report.csv", b"id,name\n1,Alice", "text/csv")},
)
assert response.status_code == 200
Rule of thumb: use data= for form fields, files= for file uploads; never
mix json= with data= in the same request.
Send an intentionally malformed request and assert on the 422 status code and error detail structure:
def test_invalid_price(client):
response = client.post("/items", json={"name": "Widget", "price": "not-a-number"})
assert response.status_code == 422
errors = response.json()["detail"]
assert any(e["loc"] == ["body", "price"] for e in errors)
assert any(e["type"] == "float_parsing" for e in errors)
Check loc (where the error is) and type (what kind of error) rather than
the msg string — messages can change between Pydantic versions.
Rule of thumb: assert on detail[*].loc and detail[*].type for validation
errors — these are stable; msg wording changes between library versions.
TestClient re-raises server-side exceptions by default (raise_server_exceptions=True).
This turns a 500 response into a Python exception in the test — useful for
debugging but unhelpful when you're testing error handling.
# Test that a 500 is returned without raising
client = TestClient(app, raise_server_exceptions=False)
def test_internal_error():
response = client.get("/broken")
assert response.status_code == 500
Or use a context manager:
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/broken")
assert resp.status_code == 500
Rule of thumb: keep the default (True) for development tests so unhandled
exceptions surface immediately; set False when specifically testing error-handling middleware.
TestClient follows redirects by default (follow_redirects=True). To test
that a redirect is issued without following it:
def test_redirect(client):
response = client.get("/old-path", follow_redirects=False)
assert response.status_code == 301
assert response.headers["location"] == "/new-path"
To disable redirect following globally:
client = TestClient(app, follow_redirects=False)
Rule of thumb: disable follow_redirects when testing the redirect itself
(status code + Location header); keep it enabled when you care about the
final destination response.
Use app.dependency_overrides:
from app.main import app
from app.db import get_db
def fake_get_db():
db = FakeDatabase()
db.users = [{"id": 1, "name": "Alice"}]
yield db
app.dependency_overrides[get_db] = fake_get_db
client = TestClient(app)
def test_list_users():
resp = client.get("/users")
assert resp.status_code == 200
assert len(resp.json()) == 1
Always clean up after the test:
@pytest.fixture(autouse=True)
def reset_overrides():
yield
app.dependency_overrides.clear()
Rule of thumb: put app.dependency_overrides.clear() in an autouse fixture
teardown — stale overrides in one test silently corrupt subsequent tests.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.