TestClient wraps your FastAPI app in a fake HTTP server powered by requests (actually httpx under the hood). You can call .get(), .post(), etc. exactly like a real HTTP client — no network needed.
Install the extras first: pip install httpx pytest. FastAPI ships TestClient from starlette.testclient.
# main.py — our tiny app to test from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): name: str price: float @app.get("/ping") def ping(): return {"msg": "pong"} @app.post("/items", status_code=201) def create_item(item: Item): return {"id": 1, "name": item.name, "price": item.price}
from fastapi.testclient import TestClient from main import app # Create a client — no server needed! client = TestClient(app) def test_ping(): response = client.get("/ping") assert response.status_code == 200 assert response.json() == {"msg": "pong"} def test_create_item(): payload = {"name": "Laptop", "price": 999.99} response = client.post("/items", json=payload) assert response.status_code == 201 data = response.json() assert data["name"] == "Laptop" assert data["price"] == 999.99 def test_create_item_missing_field(): # No "price" — Pydantic should reject this response = client.post("/items", json={"name": "Ghost"}) assert response.status_code == 422 # Unprocessable Entity
pytest -v from your project root. pytest auto-discovers any file named test_*.py or *_test.py.# Path params response = client.get("/items/42") # Query params response = client.get("/items", params={"skip": 0, "limit": 10}) # Request headers (e.g. auth token) response = client.get( "/protected", headers={"Authorization": "Bearer my-token"} ) # Multipart form data (file upload) with open("photo.jpg", "rb") as f: response = client.post("/upload", files={"file": ("photo.jpg", f, "image/jpeg")})
Real apps use dependencies — database sessions, auth checks, external API clients. In tests you don't want to hit a real DB or a real external service. FastAPI lets you swap any dependency with a fake via app.dependency_overrides.
app.dependency_overrides = {} in a teardown or finally block.# main.py — real dependency from fastapi import FastAPI, Depends app = FastAPI() def get_db(): # normally connects to a real PostgreSQL DB db = RealDatabase() try: yield db finally: db.close() @app.get("/users/{user_id}") def get_user(user_id: int, db=Depends(get_db)): return db.query_user(user_id) # ──────────────────────────────────────────── # test_users.py — override with a fake DB from fastapi.testclient import TestClient from main import app, get_db class FakeDB: def query_user(self, user_id: int): return {"id": user_id, "name": "Test User"} def close(self): pass def override_get_db(): yield FakeDB() app.dependency_overrides[get_db] = override_get_db # 👈 swap! client = TestClient(app) def test_get_user(): response = client.get("/users/5") assert response.status_code == 200 assert response.json() == {"id": 5, "name": "Test User"} # Clean up after all tests in this file def teardown_module(module): app.dependency_overrides = {}
from main import app, get_current_user # Return a fake logged-in user without any JWT validation def fake_current_user(): return {"id": 1, "username": "alice", "role": "admin"} app.dependency_overrides[get_current_user] = fake_current_user def test_admin_endpoint(): response = client.get("/admin/stats") assert response.status_code == 200 # passes even without a real token!
A fixture is a reusable piece of setup/teardown code. Instead of creating a TestClient at module level (which is shared across all tests), use a fixture to get a fresh client per test — or share it efficiently with a scope.
Runs setup & teardown around each test. Safest — no state bleed.
Runs once per test file. Good for expensive setup (e.g. DB schema creation).
Runs once for the entire test run. Best for a shared test database.
Once per test class. Useful when grouping related tests in a class.
# conftest.py — pytest picks this up automatically import pytest from fastapi.testclient import TestClient from main import app, get_db # ─── In-memory SQLite for tests ─────────────────── from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from database import Base TEST_DB_URL = "sqlite:///./test.db" engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False}) TestSessionLocal = sessionmaker(bind=engine) @pytest.fixture(scope="session", autouse=True) def setup_database(): Base.metadata.create_all(bind=engine) # create tables once yield Base.metadata.drop_all(bind=engine) # clean up after whole run @pytest.fixture() # fresh DB session per test def db_session(): session = TestSessionLocal() try: yield session session.rollback() # undo any changes after each test finally: session.close() @pytest.fixture() def client(db_session): def override_db(): yield db_session app.dependency_overrides[get_db] = override_db with TestClient(app) as c: yield c app.dependency_overrides = {} # clean up override
# Fixtures are injected by name — pytest handles it! def test_create_and_list_items(client): # Create r = client.post("/items", json={"name": "Widget", "price": 9.99}) assert r.status_code == 201 # List r = client.get("/items") assert r.status_code == 200 assert len(r.json()) == 1 assert r.json()[0]["name"] == "Widget" def test_next_test_sees_empty_db(client): # Previous test rolled back — clean slate! r = client.get("/items") assert r.json() == []
conftest.py at the root of your test directory. pytest finds them automatically — no imports needed in your test files.
When your route calls an external API (payment gateway, email service, Slack, etc.), you don't want real calls in tests. Use pytest-mock (or Python's built-in unittest.mock) to intercept and fake those calls.
pip install pytest-mock
import stripe from fastapi import FastAPI app = FastAPI() @app.post("/checkout") def checkout(amount: int): charge = stripe.PaymentIntent.create(amount=amount, currency="usd") return {"charge_id": charge["id"], "status": charge["status"]}
from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_checkout_success(mocker): # Intercept Stripe — never touches the real API! mock_create = mocker.patch("stripe.PaymentIntent.create") mock_create.return_value = { "id": "pi_fake123", "status": "succeeded" } response = client.post("/checkout", params={"amount": 5000}) assert response.status_code == 200 assert response.json() == {"charge_id": "pi_fake123", "status": "succeeded"} # Verify Stripe was called with the right amount mock_create.assert_called_once_with(amount=5000, currency="usd") def test_checkout_stripe_error(mocker): import stripe mocker.patch("stripe.PaymentIntent.create", side_effect=stripe.error.CardError("Card declined", None, None)) response = client.post("/checkout", params={"amount": 5000}) assert response.status_code == 400 # your error handler should catch it
mocker parameter is injected by pytest-mock. It automatically restores the original function after each test — no manual cleanup needed.import pytest @pytest.mark.parametrize("amount,expected_status", [ (1000, 200), # valid amount (0, 422), # zero — Pydantic rejects (-500, 422), # negative (99999, 200), # large amount ]) def test_checkout_amounts(client, amount, expected_status, mocker): mocker.patch("stripe.PaymentIntent.create", return_value={"id": "pi_x", "status": "succeeded"}) r = client.post("/checkout", params={"amount": amount}) assert r.status_code == expected_status
TestClient is synchronous — it wraps an async app but runs it in a blocking way. If you have truly async routes (using async def with real awaits) and want to test them without blocking, use httpx.AsyncClient with pytest-anyio (or pytest-asyncio).
pip install httpx pytest-asyncio anyio
pytest.ini or pyproject.toml:[pytest]asyncio_mode = autoOr use
@pytest.mark.asyncio on each async test.
import asyncio from fastapi import FastAPI app = FastAPI() @app.get("/slow-data") async def slow_data(): await asyncio.sleep(0.1) # simulates async IO (e.g. httpx call) return {"data": "fetched asynchronously"} @app.post("/async-items") async def create_async_item(name: str): await asyncio.sleep(0) # yields control back to event loop return {"created": name}
import pytest import httpx from main import app # pytest-asyncio will run this in an event loop automatically @pytest.mark.asyncio async def test_slow_data(): async with httpx.AsyncClient( app=app, base_url="http://test" ) as ac: response = await ac.get("/slow-data") assert response.status_code == 200 assert response.json() == {"data": "fetched asynchronously"} @pytest.mark.asyncio async def test_create_async_item(): async with httpx.AsyncClient(app=app, base_url="http://test") as ac: response = await ac.post("/async-items", params={"name": "Async Widget"}) assert response.status_code == 200 assert response.json()["created"] == "Async Widget"
import pytest import httpx from main import app @pytest.fixture async def async_client(): async with httpx.AsyncClient(app=app, base_url="http://test") as ac: yield ac # client is open for the duration of the test # Use in tests: @pytest.mark.asyncio async def test_ping(async_client): r = await async_client.get("/ping") assert r.status_code == 200
| Feature | TestClient (sync) | AsyncClient (async) |
|---|---|---|
| Syntax | Regular def |
async def |
| Runs async routes | Yes (blocks until done) | Yes (truly concurrent) |
| Dependency override | ✅ Yes | ✅ Yes |
| WebSocket support | ✅ Yes | ⚠️ Limited |
| Best for | Most API tests | Real async I/O, streaming |
When your app uses async SQLAlchemy (AsyncSession), your tests also need to be async. The pattern: create an async test engine pointing at a test DB, override the get_db dependency, and rollback after each test.
import pytest import httpx from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from main import app, get_db from database import Base # Use async SQLite in-memory for speed TEST_DB_URL = "sqlite+aiosqlite:///./test.db" async_engine = create_async_engine(TEST_DB_URL) AsyncTestSession = sessionmaker( bind=async_engine, class_=AsyncSession, expire_on_commit=False ) @pytest.fixture(scope="session", autouse=True) async def create_tables(): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @pytest.fixture async def session(): async with AsyncTestSession() as s: yield s await s.rollback() @pytest.fixture async def async_client(session): async def override_get_db(): yield session app.dependency_overrides[get_db] = override_get_db async with httpx.AsyncClient(app=app, base_url="http://test") as ac: yield ac app.dependency_overrides = {}
import pytest @pytest.mark.asyncio async def test_register_user(async_client): payload = {"username": "alice", "password": "secret123"} r = await async_client.post("/register", json=payload) assert r.status_code == 201 assert r.json()["username"] == "alice" @pytest.mark.asyncio async def test_user_not_in_next_test(async_client): # Previous test rolled back — alice doesn't exist here r = await async_client.get("/users") assert r.json() == []
aiosqlite to use async SQLite: pip install aiosqlite. It's perfect for tests — no Postgres needed, blazing fast, runs in-memory or as a temp file.
# Install coverage tools pip install pytest-cov # Run tests with coverage report pytest --cov=app --cov-report=term-missing # Generate HTML report (open htmlcov/index.html) pytest --cov=app --cov-report=html
[pytest] asyncio_mode = auto testpaths = tests addopts = -v --cov=app --cov-report=term-missing
# Run all tests pytest # Run one file pytest tests/test_items.py # Run one specific test pytest tests/test_items.py::test_create_item # Run tests matching a keyword pytest -k "auth" # Stop on first failure pytest -x # Show slowest 5 tests pytest --durations=5
conftest.py, mock external services, override FastAPI dependencies cleanly, and test fully async routes with httpx.AsyncClient. Reply "next" to continue to Topic 19: Observability (Logging, Metrics & Tracing).