FastAPI Mastery
Topic 18 of 22 — Testing
81.8% complete
Topic 18 of 22
Testing
A FastAPI app without tests is a bug waiting to happen. Learn to use TestClient for synchronous HTTP testing, harness pytest fixtures for reusable setup, override dependencies cleanly, and run fully async tests with httpx's AsyncClient — so every route, edge case, and auth flow is covered before it reaches production.
Your FastAPI App │ ▼ ┌──────────────────────────────────────────┐ │ Testing Pyramid │ │ │ │ ┌────────────────────────────────────┐ │ │ │ E2E / Integration Tests │ │ ← AsyncClient + real DB │ ├────────────────────────────────────┤ │ │ │ Route / API Tests │ │ ← TestClient + overrides │ ├────────────────────────────────────┤ │ │ │ Unit Tests (functions, services) │ │ ← pure pytest │ └────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │ ▼ pytest + coverage report
18.1
TestClient
🧪
Setup & Basics

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.

💡
Think of TestClient as a fake browser that calls your app in-process. No server starts, no port is opened — it's all function calls internally.
Python — main.py
# 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}
Python — test_main.py
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 test_main.py -v ========================= test session starts ========================= PASSED test_main.py::test_ping PASSED test_main.py::test_create_item PASSED test_main.py::test_create_item_missing_field ========================= 3 passed in 0.32s ==========================
Run pytest -v from your project root. pytest auto-discovers any file named test_*.py or *_test.py.
Testing with headers, query params & path params:
Python — test examples
# 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")})
🔄
Dependency Override

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.

⚠️
Always clean up overrides after a test. Otherwise your fake bleeds into other tests. Use app.dependency_overrides = {} in a teardown or finally block.
Python — dependency override
# 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 = {}
Overriding auth dependencies is especially useful:
Python — override auth
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!
18.2
pytest Integration
🔧
pytest Fixtures

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.

scope="function" (default)

Runs setup & teardown around each test. Safest — no state bleed.

scope="module"

Runs once per test file. Good for expensive setup (e.g. DB schema creation).

scope="session"

Runs once for the entire test run. Best for a shared test database.

scope="class"

Once per test class. Useful when grouping related tests in a class.

Python — conftest.py (shared fixtures)
# 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
Python — test_items.py (using fixtures)
# 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() == []
Put your fixtures in conftest.py at the root of your test directory. pytest finds them automatically — no imports needed in your test files.
🎭
Mocking External Services

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.

bash — install
pip install pytest-mock
Python — main.py (calls Stripe)
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"]}
Python — test_checkout.py
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
💡
The mocker parameter is injected by pytest-mock. It automatically restores the original function after each test — no manual cleanup needed.
Parametrize tests to cover multiple scenarios with one test function:
Python — parametrize
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
$ pytest test_checkout.py -v PASSED test_checkout_amounts[1000-200] FAILED test_checkout_amounts[0-422] PASSED test_checkout_amounts[-500-422] PASSED test_checkout_amounts[99999-200]
18.3
Async Testing
AsyncClient (httpx)

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

bash — install
pip install httpx pytest-asyncio anyio
⚠️
You need to configure pytest-asyncio mode. Add this to your pytest.ini or pyproject.toml:

[pytest]
asyncio_mode = auto

Or use @pytest.mark.asyncio on each async test.
Python — async route in main.py
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}
Python — test_async.py (AsyncClient)
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"
Async fixture for a shared AsyncClient:
Python — conftest.py (async fixture)
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
🗄️
Async DB Tests (SQLAlchemy async)

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.

Python — conftest.py (full async DB setup)
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 = {}
Python — test_users.py (async DB test)
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() == []
Install 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.
Measuring code coverage:
bash
# 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
---------- coverage: platform linux, python 3.12 ---------- Name Stmts Miss Cover ------------------------------------------------- app/main.py 45 3 93% app/routers/items.py 28 0 100% app/routers/users.py 34 6 82% ------------------------------------------------- TOTAL 107 9 92%
💡
Aim for >80% coverage on critical paths (auth, payment, data writes). 100% is a nice goal but don't chase it at the expense of test quality — meaningful tests matter more than raw percentage.
📁
Recommended Test Project Structure
myproject/ ├── app/ │ ├── main.py │ ├── routers/ │ │ ├── items.py │ │ └── users.py │ ├── models.py │ ├── database.py │ └── dependencies.py │ ├── tests/ │ ├── conftest.py ← shared fixtures (client, db, mocks) │ ├── test_items.py │ ├── test_users.py │ ├── test_auth.py │ └── test_websockets.py │ ├── pytest.ini ← asyncio_mode = auto └── pyproject.toml
ini — pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
addopts = -v --cov=app --cov-report=term-missing
bash — useful pytest commands
# 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
Topic 18 Complete! You now know how to use TestClient for quick synchronous tests, write clean pytest fixtures in 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).