---
title: "How to write tests for FastAPI"
slug: "write-tests-for-fastapi"
category: "howto"
tags: ["howto", "fastapi", "pytest", "testing", "python", "tutorial", "backend"]
status: "stable"
last_updated: 2026-05-14
summary: "Wire pytest with TestClient, override dependencies for database isolation, and write fixtures that reset state between tests."
related: ["[[backend/fastapi]]", "[[backend/fastapi-dependencies]]", "[[coding/testing]]", "[[coding/python-testing]]", "[[howto/set-up-pytest]]", "[[backend/postgres]]", "[[howto/set-up-postgres-locally]]", "[[cheatsheets/bash-one-liners]]"]
---

## Overview

FastAPI's `TestClient` wraps the ASGI app in a synchronous interface so pytest can call routes without a running server. The key pattern is dependency overrides: swap real database sessions for transactions that roll back after each test, and replace external service clients with stubs. The broader pytest setup lives in [[howto/set-up-pytest]].

## Prerequisites

- A FastAPI project with `pyproject.toml` and a virtual environment active.
- `pip install "fastapi[standard]" pytest pytest-asyncio httpx` (TestClient uses httpx internally).
- A Postgres database reachable for integration tests. See [[howto/set-up-postgres-locally]].
- The app factory pattern: a function that creates and returns the `FastAPI` instance. Avoid module-level app creation, which complicates overrides.

## Steps

### 1. Create a `conftest.py` with the app fixture

Place `conftest.py` at the root of the `tests/` directory. The `app` fixture creates a clean instance; `client` wraps it.

```python
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import create_app
from myapp.database import get_session, Base, engine

@pytest.fixture(scope="session")
def app():
    application = create_app()
    Base.metadata.create_all(bind=engine)
    yield application
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client(app):
    with TestClient(app) as c:
        yield c
```

`scope="session"` creates the database schema once. The `client` fixture is function-scoped, so each test starts a fresh `TestClient` context. See [[backend/fastapi-dependencies]] for how FastAPI resolves dependency graphs.

### 2. Override the database dependency for isolation

Use `app.dependency_overrides` to swap the real session for a test session that wraps each test in a rolled-back transaction.

```python
# tests/conftest.py (continued)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

TEST_DATABASE_URL = "postgresql://test:test@localhost/test_db"

test_engine = create_engine(TEST_DATABASE_URL)
TestSessionLocal = sessionmaker(bind=test_engine)

@pytest.fixture
def db_session(app):
    connection = test_engine.connect()
    transaction = connection.begin()
    session = TestSessionLocal(bind=connection)

    def override_get_session():
        yield session

    app.dependency_overrides[get_session] = override_get_session
    yield session
    session.close()
    transaction.rollback()
    connection.close()
    app.dependency_overrides.clear()
```

The rollback after yield ensures no test data persists to the next test. Pass `db_session` as a fixture to any test that writes to the database.

### 3. Write route tests with TestClient

```python
# tests/test_users.py
def test_create_user_returns_201(client, db_session):
    payload = {"email": "alice@example.com", "name": "Alice"}
    response = client.post("/users", json=payload)
    assert response.status_code == 201
    body = response.json()
    assert body["email"] == "alice@example.com"
    assert "id" in body

def test_create_user_rejects_duplicate_email(client, db_session):
    payload = {"email": "bob@example.com", "name": "Bob"}
    client.post("/users", json=payload)
    response = client.post("/users", json=payload)
    assert response.status_code == 409
```

Request headers, authentication tokens, and query parameters all pass through the `TestClient` interface in the same way as `requests`. See [[backend/fastapi]] for route patterns.

### 4. Stub external service dependencies

Avoid calling real external APIs in tests. Use `dependency_overrides` to replace service clients.

```python
# tests/conftest.py (continued)
from myapp.services import get_email_service

class StubEmailService:
    def __init__(self):
        self.sent: list[dict] = []

    def send(self, to: str, subject: str, body: str) -> None:
        self.sent.append({"to": to, "subject": subject})

@pytest.fixture
def stub_email(app):
    stub = StubEmailService()
    app.dependency_overrides[get_email_service] = lambda: stub
    yield stub
    app.dependency_overrides.pop(get_email_service, None)
```

In tests, assert on `stub_email.sent` after calling the route. No real emails are sent.

### 5. Test authentication

Pass tokens as headers; test both authenticated and unauthenticated paths.

```python
def test_protected_route_requires_auth(client):
    response = client.get("/admin/users")
    assert response.status_code == 401

def test_protected_route_with_valid_token(client, db_session):
    token = create_test_token(user_id=1, role="admin")
    response = client.get(
        "/admin/users",
        headers={"Authorization": f"Bearer {token}"},
    )
    assert response.status_code == 200
```

## Verify it worked

```bash
pytest tests/ -v --tb=short
# All tests pass; no database state leaks between tests.

pytest --collect-only | grep "<Function"
# Every route has at least one test function.

pytest --cov=myapp --cov-report=term-missing
# Route handlers covered; dependency override paths covered.
```

## Common errors

- **`RuntimeError: Session is already closed`**. The `db_session` fixture was not passed to the test that calls a route writing to the database. Add it as a parameter.
- **Tests pass individually but fail together**. Test order matters; state is leaking. Check that all fixtures use `transaction.rollback()` and `dependency_overrides.clear()`.
- **`httpx.ConnectError` in TestClient**. The app startup event failed. Check the lifespan context in `create_app()`; see [[backend/fastapi-lifespan]].
- **Dependency override has no effect**. The key in `dependency_overrides` must be the exact callable object imported into the router module, not a re-imported copy. Use the same import path everywhere.
- **Async route tests hang**. Add `asyncio_mode = "auto"` to `[tool.pytest.ini_options]` and use `pytest-asyncio` for async test functions.

## Related

- [[backend/fastapi]]
- [[backend/fastapi-dependencies]]
- [[coding/testing]]
- [[coding/python-testing]]
- [[howto/set-up-pytest]]
- [[backend/postgres]]
- [[howto/set-up-postgres-locally]]
- [[cheatsheets/bash-one-liners]]
