FASTAPI / 9. TESTING

Testing FastAPI Applications

Unit tests, integration tests, TestClient, dependency overrides


EXPLANATION

FastAPI is designed to be testable. The TestClient (from httpx) lets you send real HTTP requests to your app in tests without starting a server. Everything runs in-process.

Testing stack:
• pytest → test runner and fixture system
• httpx → the HTTP client TestClient is built on
• pytest-asyncio → for testing async routes directly

The TestClient pattern:
• client = TestClient(app) → creates a test HTTP client
• client.get("/path") → sends a real GET request to your app
• Returns a response object with .status_code, .json(), .headers

Dependency overrides — the killer feature for testing:
• Your routes depend on get_db() which talks to a real database
• In tests, you override get_db() with a test DB (SQLite in-memory)
• app.dependency_overrides[get_db] = override_get_db
• After tests, clear overrides: app.dependency_overrides.clear()
• This lets you test routes without a real DB, without real auth, without real email

What to test:
① Happy path → valid input, expected response, correct status code
② Error cases → 404, 401, 422 for bad input
③ Auth → protected routes reject unauthenticated requests
④ Edge cases → empty lists, null fields, boundary values

Fixtures (pytest) setup shared state:
• @pytest.fixture → defines reusable test setup
• yield in fixtures = setup before yield, teardown after
• Scope (function/module/session) controls how often setup runs

conftest.py holds shared fixtures — TestClient, test DB session — visible to all test files without import.

ARCHITECTURE

TEST STRUCTURE:
  tests/
  ├── conftest.py        ← shared fixtures (client, db, auth token)
  ├── test_tasks.py      ← task CRUD tests
  ├── test_auth.py       ← login, signup, protected routes
  └── test_users.py

  DEPENDENCY OVERRIDE PATTERN:
  Real app:                     Test app:
  get_db() → PostgreSQL    →    get_db() → SQLite in-memory
  get_current_user() → JWT →    get_current_user() → fake user

  app.dependency_overrides = {
      get_db: lambda: test_db_session,
      get_current_user: lambda: {"username": "testuser"}
  }

  TestClient sends real HTTP → routes execute → assertions on response

CODE

PYTHON
1# tests/conftest.py
2import pytest
3from fastapi.testclient import TestClient
4from sqlmodel import Session, SQLModel, create_engine
5from sqlmodel.pool import StaticPool
6from main import app, get_db
7
8@pytest.fixture(name="engine")
9def engine_fixture():
10 engine = create_engine(
11 "sqlite://", # in-memory SQLite
12 connect_args={"check_same_thread": False},
13 poolclass=StaticPool, # same connection for all threads
14 )
15 SQLModel.metadata.create_all(engine)
16 yield engine
17 SQLModel.metadata.drop_all(engine)
18
19@pytest.fixture(name="db")
20def db_fixture(engine):
21 with Session(engine) as session:
22 yield session
23
24@pytest.fixture(name="client")
25def client_fixture(db):
26 def override_get_db():
27 yield db
28
29 app.dependency_overrides[get_db] = override_get_db
30 client = TestClient(app)
31 yield client
32 app.dependency_overrides.clear()
33
34# ──────────────────────────────────────────────────────
35# tests/test_tasks.py
36import pytest
37
38def test_create_task(client):
39 response = client.post("/tasks/", json={"title": "Learn FastAPI", "done": False})
40 assert response.status_code == 201
41 data = response.json()
42 assert data["title"] == "Learn FastAPI"
43 assert data["done"] is False
44 assert "id" in data
45
46def test_get_task_not_found(client):
47 response = client.get("/tasks/999")
48 assert response.status_code == 404
49 assert response.json()["detail"] == "Task not found"
50
51def test_update_task(client):
52 # create first
53 created = client.post("/tasks/", json={"title": "Old title"}).json()
54 # update
55 response = client.patch(f"/tasks/{created['id']}", json={"title": "New title", "done": True})
56 assert response.status_code == 200
57 assert response.json()["title"] == "New title"
58 assert response.json()["done"] is True
59
60def test_delete_task(client):
61 created = client.post("/tasks/", json={"title": "To delete"}).json()
62 response = client.delete(f"/tasks/{created['id']}")
63 assert response.status_code == 204
64 # verify it's gone
65 get_response = client.get(f"/tasks/{created['id']}")
66 assert get_response.status_code == 404
67
68def test_invalid_task_body(client):
69 response = client.post("/tasks/", json={"done": False}) # missing title
70 assert response.status_code == 422 # Pydantic validation error
← PREV8. Async & Background TasksNEXT →10. Full Project — TaskFlow