Free Ebook cover FastAPI for Beginners: Build a Production-Ready REST API

FastAPI for Beginners: Build a Production-Ready REST API

New course

14 pages

Testing FastAPI Applications with Pytest and TestClient

Capítulo 13

Estimated reading time: 12 minutes

+ Exercise

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 httpx

FastAPI’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.ini

Keep 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 App

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 c

This 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 c

Notes 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 == 422

Tip: 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 data

Example: 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 data

Keep 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 == 200

Notes:

  • If your token endpoint uses form data, pass data=... (not json=...).
  • For invalid tokens, assert 401 and verify the WWW-Authenticate header 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_status

Testing 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 data

Avoid 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., id is int, email matches) 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 = -q

tests/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 c

tests/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 == 404

tests/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

pytest

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

Now answer the exercise about the content:

When testing a JWT-protected FastAPI endpoint, what approach keeps route tests fast and stable while still allowing you to verify authorization behavior?

You are right! Congratulations, now go to the next page

You missed! Try again.

Overriding the current-user dependency isolates route behavior from JWT implementation details, making tests faster and less brittle while still enabling clear allowed/denied authorization checks.

Next chapter

Deployment Considerations for a Production-Ready FastAPI REST API

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.