Why test FastAPI apps (and what to focus on)
Automated tests protect your API from regressions when you refactor routes, change dependencies, or evolve schemas. In FastAPI, most bugs show up at the boundaries: request validation, dependency behavior, authentication/authorization, and error responses. This chapter focuses on testing those boundaries using pytest and FastAPI’s TestClient, including dependency overrides for a test database/session and JWT-protected endpoints.
Tooling: pytest + TestClient
TestClient runs your FastAPI app in-process and lets you make HTTP requests as if you were calling the real server. You get realistic request/response behavior (validation, dependencies, middleware) without starting Uvicorn.
Install test dependencies
pip install -U pytest httpxFastAPI’s TestClient is built on httpx. If you already have it installed through FastAPI, you may still want to pin versions in your project.
Recommended test layout
app/ # your application package (already exists in your project) tests/ conftest.py test_health.py test_users.py test_auth.py test_errors.py pytest.iniKeep tests small and grouped by feature. Use conftest.py for shared fixtures (app creation, client, auth helpers, dependency overrides).
Core fixtures: app creation, client, and dependency overrides
The most maintainable approach is: (1) create the app once per test session or per test module, (2) override dependencies for tests (database session, current user, settings), and (3) provide a client fixture that uses those overrides.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Example: conftest.py with app + client fixtures
The snippet below assumes your project exposes an app factory (recommended) like create_app() and has a database-session dependency like get_db. If your names differ, adapt accordingly.
# tests/conftest.py import pytest from fastapi.testclient import TestClient from app.main import create_app from app.dependencies import get_db @pytest.fixture(scope="session") def app(): app = create_app() return app @pytest.fixture() def client(app): # Clear overrides before each test to avoid cross-test leakage app.dependency_overrides = {} with TestClient(app) as c: yield cThis is the baseline. Next, we’ll override dependencies (like the DB) so tests are isolated and deterministic.
Override a database/session dependency
For route tests, you typically don’t want to hit your real database. You can use an in-memory database, a temporary database, or a fake repository. The key is that your app depends on an injectable function (e.g., get_db), which you can override in tests.
Below is a pattern using a lightweight “fake” repository object. This keeps tests fast and avoids SQL setup in this chapter. If you already have a test DB fixture, the override mechanism is the same.
# tests/conftest.py import pytest from app.dependencies import get_user_repo class FakeUserRepo: def __init__(self): self._users = {} self._next_id = 1 def create(self, user_in): user = {"id": self._next_id, "email": user_in["email"], "is_active": True} self._users[user["id"]] = user self._next_id += 1 return user def get(self, user_id: int): return self._users.get(user_id) def get_by_email(self, email: str): return next((u for u in self._users.values() if u["email"] == email), None) @pytest.fixture() def fake_user_repo(): return FakeUserRepo() @pytest.fixture() def client(app, fake_user_repo): app.dependency_overrides = {} def override_get_user_repo(): return fake_user_repo app.dependency_overrides[get_user_repo] = override_get_user_repo from fastapi.testclient import TestClient with TestClient(app) as c: yield cNotes for maintainability:
- Keep fake repos small and feature-focused; don’t re-implement your whole persistence layer.
- Reset overrides per test to avoid state leakage.
- If you use a real test DB, prefer a transaction-per-test pattern and rollback after each test.
Testing routes: success cases and response shape
Route tests should verify: status code, response body, and key headers. Avoid asserting the entire JSON if it’s large; assert the parts that matter.
Example: test a simple health endpoint
# tests/test_health.py def test_health(client): r = client.get("/health") assert r.status_code == 200 assert r.json() == {"status": "ok"}Verify response schema (practical approach)
FastAPI already validates responses at runtime if you use response_model, but tests should still confirm the contract. A practical way is to validate the JSON with the same Pydantic model used by the endpoint.
# tests/test_users.py from app.schemas import UserOut def test_get_user_success(client, fake_user_repo): created = fake_user_repo.create({"email": "a@example.com"}) r = client.get(f"/users/{created['id']}") assert r.status_code == 200 data = r.json() # Pydantic validation: raises if shape/types are wrong UserOut.model_validate(data) assert data["id"] == created["id"] assert data["email"] == "a@example.com"This style catches subtle issues (missing fields, wrong types) while keeping assertions readable.
Testing validation: missing fields, wrong types, and constraints
Validation failures should be tested explicitly. FastAPI returns 422 for request validation errors. Your tests should assert both the status code and that the error structure contains the expected field path.
Example: POST validation error
# tests/test_users.py def test_create_user_validation_error(client): # Suppose /users expects JSON with required "email" r = client.post("/users", json={}) assert r.status_code == 422 body = r.json() assert body["detail"] # non-empty # Check that "email" is mentioned in the error location assert any(err["loc"][-1] == "email" for err in body["detail"]) def test_create_user_wrong_type(client): r = client.post("/users", json={"email": 123}) assert r.status_code == 422Tip: Don’t assert the entire detail payload; it can change between Pydantic/FastAPI versions. Assert stable parts (status code, field name, presence of errors).
Testing error handling: 404, domain errors, and consistent responses
For error handling, test both the HTTP status and the shape of the error response your API promises (e.g., {"error": {"code": ..., "message": ...}}). Even if the internal exception changes, the client-facing contract should remain stable.
Example: resource not found
# tests/test_errors.py def test_get_user_not_found(client): r = client.get("/users/999999") assert r.status_code == 404 data = r.json() # Adapt these assertions to your API's error schema assert "detail" in data or "error" in dataExample: domain conflict (e.g., duplicate email)
# tests/test_errors.py def test_create_user_duplicate_email(client, fake_user_repo): fake_user_repo.create({"email": "dup@example.com"}) r = client.post("/users", json={"email": "dup@example.com"}) assert r.status_code in (400, 409) data = r.json() assert "detail" in data or "error" in dataKeep these tests aligned with your chosen error strategy: if you standardize on 409 for conflicts, assert 409.
Testing authentication: JWT-protected endpoints
JWT-protected endpoints require tests for: unauthenticated requests (missing/invalid token), authenticated success, and authorization rules (wrong role/ownership). The cleanest approach is to avoid generating “real” JWTs in every test and instead override the dependency that resolves the current user from the token.
Approach A (recommended for most route tests): override the current-user dependency
If your endpoints depend on something like get_current_user, override it to return a known user object. This isolates route behavior from JWT implementation details.
# tests/test_auth.py import pytest from app.dependencies import get_current_user @pytest.fixture() def auth_client(app, client): # client fixture already resets overrides; we add an auth override here def override_get_current_user(): return {"id": 1, "email": "test@example.com", "is_active": True} app.dependency_overrides[get_current_user] = override_get_current_user return client def test_protected_endpoint_success(auth_client): r = auth_client.get("/me") assert r.status_code == 200 data = r.json() assert data["email"] == "test@example.com" def test_protected_endpoint_unauthorized(client): # No override: should behave like real unauthenticated request r = client.get("/me") assert r.status_code in (401, 403)This gives you fast, stable tests for authorization logic and response shape.
Approach B (integration-style): obtain a token and call endpoints with Authorization header
Use this approach for a smaller number of tests to ensure the auth flow is wired correctly (token endpoint works, token is accepted, claims are interpreted correctly). Keep it minimal because it’s more brittle and requires more setup.
# tests/test_auth.py def test_login_and_access_protected(client, fake_user_repo): # Arrange: create a user in the fake repo with whatever your login expects fake_user_repo.create({"email": "login@example.com"}) # Act: get token (adapt payload to your token endpoint) token_resp = client.post("/auth/token", data={"username": "login@example.com", "password": "secret"}) assert token_resp.status_code == 200 token = token_resp.json()["access_token"] # Use token r = client.get("/me", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200Notes:
- If your token endpoint uses form data, pass
data=...(notjson=...). - For invalid tokens, assert
401and verify theWWW-Authenticateheader if your implementation sets it.
Testing authorization rules: roles and ownership
Authentication answers “who are you?” Authorization answers “are you allowed to do this?”. Test both allowed and denied cases by overriding the current user with different roles/IDs.
# tests/test_auth.py from app.dependencies import get_current_user def test_admin_only_forbidden(client, app): def override_user(): return {"id": 2, "email": "user@example.com", "role": "user"} app.dependency_overrides[get_current_user] = override_user r = client.delete("/admin/users/1") assert r.status_code in (403,) def test_admin_only_allowed(client, app): def override_admin(): return {"id": 1, "email": "admin@example.com", "role": "admin"} app.dependency_overrides[get_current_user] = override_admin r = client.delete("/admin/users/1") assert r.status_code in (200, 204)Keep authorization tests explicit: one test per rule, with clear user identity and expected outcome.
Parametrizing success and failure cases
pytest.mark.parametrize reduces duplication and makes it easy to add new cases later.
# tests/test_users.py import pytest @pytest.mark.parametrize( "payload, expected_status", [ ({"email": "valid@example.com"}, 201), ({}, 422), ({"email": "not-an-email"}, 422), ], ) def test_create_user_cases(client, payload, expected_status): r = client.post("/users", json=payload) assert r.status_code == expected_statusTesting dependency failures and timeouts (without flakiness)
Sometimes you want to ensure your API responds correctly when a dependency fails (e.g., repository raises, external service unavailable). Do this by overriding the dependency with a stub that raises a known exception, then assert the API’s error response.
# tests/test_errors.py from app.dependencies import get_user_repo class FailingRepo: def get(self, user_id: int): raise RuntimeError("db down") def test_dependency_failure_returns_500(client, app): app.dependency_overrides[get_user_repo] = lambda: FailingRepo() r = client.get("/users/1") assert r.status_code in (500,) # Depending on your error handler, assert your standard error shape data = r.json() assert "detail" in data or "error" in dataAvoid real sleeps/timeouts in unit tests. If you must test timeout behavior, design your code to accept a timeout parameter and inject a stub that simulates timeout instantly.
Making schema assertions robust
When verifying response schemas, aim for checks that survive small changes:
- Validate with Pydantic models (
model_validate) to ensure types and required fields. - Assert invariants (e.g.,
idis int,emailmatches) rather than full payload equality. - For lists, assert length and validate a sample item.
# tests/test_users.py from app.schemas import UserOut def test_list_users_schema(client, fake_user_repo): fake_user_repo.create({"email": "u1@example.com"}) fake_user_repo.create({"email": "u2@example.com"}) r = client.get("/users") assert r.status_code == 200 items = r.json() assert isinstance(items, list) assert len(items) >= 2 UserOut.model_validate(items[0])A small, maintainable test suite (ready to run locally)
Below is a compact suite you can keep as a baseline. It covers: route success, validation failure, not found, auth success/unauthorized, and schema validation. Adapt endpoint paths and schema imports to your project.
pytest.ini
[pytest] testpaths = tests addopts = -qtests/conftest.py
import pytest from fastapi.testclient import TestClient from app.main import create_app from app.dependencies import get_user_repo class FakeUserRepo: def __init__(self): self._users = {} self._next_id = 1 def create(self, user_in): user = {"id": self._next_id, "email": user_in["email"], "is_active": True} self._users[user["id"]] = user self._next_id += 1 return user def get(self, user_id: int): return self._users.get(user_id) def list(self): return list(self._users.values()) def get_by_email(self, email: str): return next((u for u in self._users.values() if u["email"] == email), None) @pytest.fixture(scope="session") def app(): return create_app() @pytest.fixture() def fake_user_repo(): return FakeUserRepo() @pytest.fixture() def client(app, fake_user_repo): app.dependency_overrides = {} app.dependency_overrides[get_user_repo] = lambda: fake_user_repo with TestClient(app) as c: yield ctests/test_users.py
import pytest from app.schemas import UserOut def test_create_user_success(client): r = client.post("/users", json={"email": "a@example.com"}) assert r.status_code in (200, 201) UserOut.model_validate(r.json()) @pytest.mark.parametrize("payload", [{}, {"email": 123}]) def test_create_user_validation_error(client, payload): r = client.post("/users", json=payload) assert r.status_code == 422 def test_get_user_not_found(client): r = client.get("/users/999999") assert r.status_code == 404tests/test_auth.py
from app.dependencies import get_current_user def test_me_unauthorized(client): r = client.get("/me") assert r.status_code in (401, 403) def test_me_authorized(client, app): app.dependency_overrides[get_current_user] = lambda: {"id": 1, "email": "test@example.com", "is_active": True} r = client.get("/me") assert r.status_code == 200 assert r.json()["email"] == "test@example.com"Run the suite
pytestAs your API grows, keep this suite healthy by adding tests when you add features, using dependency overrides to isolate external systems, and validating response schemas with your Pydantic models so changes are intentional and visible.