Skip to main content

TestClient Basics

FastAPI's TestClient wraps Starlette's test transport, letting you write fast, synchronous tests without running an actual server. It uses requests under the hood and supports the complete HTTP interface.

Learning Focus

By the end of this lesson you can: write pytest tests using TestClient, test GET/POST/DELETE endpoints, check status codes, response bodies, and headers, and organize tests with conftest.py.

Installation

install-test-deps.sh
pip install pytest httpx pytest-asyncio

Basic Test Structure

tests/test_main.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

def test_create_item():
response = client.post(
"/items/",
json={"name": "Widget", "price": 9.99},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Widget"
assert data["price"] == 9.99
assert "id" in data

def test_create_item_invalid():
response = client.post(
"/items/",
json={"name": "", "price": -1},
)
assert response.status_code == 422
errors = response.json()["detail"]
assert any(e["loc"][-1] == "name" for e in errors)

conftest.py — Shared Fixtures

tests/conftest.py
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

@pytest.fixture
def auth_headers(client):
"""Get a valid JWT token and return auth headers."""
response = client.post(
"/auth/token",
data={"username": "testuser", "password": "testpass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}

Testing Authentication

tests/test_auth.py
def test_login_success(client):
resp = client.post(
"/auth/token",
data={"username": "alice", "password": "correct"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"

def test_protected_route_without_token(client):
resp = client.get("/users/me")
assert resp.status_code == 401

def test_protected_route_with_token(client, auth_headers):
resp = client.get("/users/me", headers=auth_headers)
assert resp.status_code == 200

Testing Headers and Response Schema

tests/test_headers.py
def test_request_id_header(client):
resp = client.get("/", headers={"X-Request-ID": "test-id"})
assert resp.headers.get("X-Request-ID") == "test-id"

def test_response_schema(client):
resp = client.post("/items/", json={"name": "Test", "price": 5.0})
assert resp.status_code == 201
data = resp.json()
# Assert all required fields present
for field in ["id", "name", "price"]:
assert field in data, f"Missing field: {field}"

Testing Error Cases

tests/test_errors.py
import pytest

@pytest.mark.parametrize("item_id,expected_status", [
(1, 200),
(99999, 404),
(-1, 422),
])
def test_get_item_status_codes(client, item_id: int, expected_status: int):
resp = client.get(f"/items/{item_id}")
assert resp.status_code == expected_status

Common Pitfalls

PitfallCause / SymptomFix
App initializes DB on importDB not available in test environmentUse dependency overrides to mock DB
Tests share mutable stateModule-scoped client with in-memory DBUse function-scoped client or reset state between tests
Background tasks not testedTasks run but assertions miss themTestClient runs background tasks synchronously — assertions work
Auth token hardcodedTests break when password changesUse a fixture to get a fresh token per test suite run
SSL errors with TestClientCalling external services in testsMock external services with unittest.mock or pytest-httpx

Hands-On Practice

app/routers/calculator.py
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(prefix="/calc", tags=["calc"])

class CalcRequest(BaseModel):
a: float
b: float

@router.post("/add")
async def add(body: CalcRequest) -> dict:
return {"result": body.a + body.b}

@router.post("/divide")
async def divide(body: CalcRequest) -> dict:
if body.b == 0:
from fastapi import HTTPException
raise HTTPException(400, "Cannot divide by zero")
return {"result": body.a / body.b}
tests/test_calculator.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3.0),
(0, 5, 5.0),
(-3, 3, 0.0),
(1.5, 2.5, 4.0),
])
def test_add(a, b, expected):
resp = client.post("/calc/add", json={"a": a, "b": b})
assert resp.status_code == 200
assert resp.json()["result"] == expected

def test_divide_by_zero():
resp = client.post("/calc/divide", json={"a": 10, "b": 0})
assert resp.status_code == 400
assert "zero" in resp.json()["detail"].lower()
run-tests.sh
pytest tests/test_calculator.py -v

What's Next