Testing Setup — pytest, TestClient and conftest.py

A well-tested FastAPI application catches regressions before they reach production, documents expected behaviour through executable examples, and gives developers the confidence to refactor. The foundation is pytest (the test runner), FastAPI’s TestClient (a synchronous HTTP client backed by the ASGI app), and a conftest.py file that sets up the fixtures every test depends on — a test database session, authenticated clients, and pre-built test data. Getting this setup right makes every subsequent test simple to write.

Installation and Configuration

pip install pytest pytest-asyncio httpx pytest-cov

# pytest.ini (or pyproject.toml [tool.pytest.ini_options])
# [pytest]
# testpaths = tests
# asyncio_mode = auto          # pytest-asyncio: all async tests run automatically
# filterwarnings = ignore::DeprecationWarning
# tests/conftest.py — shared fixtures for the entire test suite
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.main       import app
from app.database   import Base
from app.dependencies import get_db, get_current_user
from app.models.user  import User
from app.auth.password import hash_password

# ── In-memory SQLite database for fast, isolated tests ────────────────────────
# Use a real PostgreSQL test DB if the app uses PG-specific features (JSONB, etc.)
SQLITE_URL = "sqlite://"   # in-memory
test_engine = create_engine(
    SQLITE_URL,
    connect_args = {"check_same_thread": False},
    poolclass    = StaticPool,   # single connection, prevents threading issues
)
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)

@pytest.fixture(scope="session", autouse=True)
def create_tables():
    """Create all tables once before the test session; drop them after."""
    Base.metadata.create_all(test_engine)
    yield
    Base.metadata.drop_all(test_engine)

@pytest.fixture
def db():
    """
    Provide a session that wraps each test in a transaction and rolls back.
    This isolates tests without re-creating the schema between tests.
    """
    connection  = test_engine.connect()
    transaction = connection.begin()
    session     = Session(bind=connection)

    yield session

    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture
def user(db) -> User:
    """A standard registered user for most tests."""
    u = User(
        email         = "user@example.com",
        name          = "Test User",
        password_hash = hash_password("Password1"),
        role          = "user",
        is_active     = True,
    )
    db.add(u)
    db.flush()
    return u

@pytest.fixture
def admin(db) -> User:
    """An admin user for admin-only endpoint tests."""
    u = User(
        email         = "admin@example.com",
        name          = "Admin User",
        password_hash = hash_password("Password1"),
        role          = "admin",
        is_active     = True,
    )
    db.add(u)
    db.flush()
    return u
Note: The rollback fixture pattern — wrapping each test in a transaction and rolling it back after — provides test isolation without dropping and recreating tables. Each test starts with a clean database state at zero cost. The StaticPool ensures all connections in the session share the same in-memory SQLite database (without it, each connection would get a separate in-memory database, causing the tables created by create_tables to be invisible to the test session).
Tip: Use SQLite for unit and fast integration tests (it is much faster than PostgreSQL for test suites that run hundreds of times per day), but maintain a separate PostgreSQL test database for tests that use PostgreSQL-specific features: JSONB, arrays, full-text search, triggers, or CHECK constraints with PostgreSQL syntax. Run the PostgreSQL tests in CI only, not on every local save, to keep the development feedback loop fast.
Warning: Do not reuse the production database URL for tests, even in development. A test that creates, modifies, or deletes data against the production database is dangerous — a missed rollback or a bug in the test teardown can corrupt real data. Always use a dedicated test database URL, configured via an environment variable: TEST_DATABASE_URL=postgresql://user:pass@localhost/test_db.

TestClient Fixtures

# tests/conftest.py (continued)
from fastapi.testclient import TestClient

@pytest.fixture
def client(db, user) -> TestClient:
    """
    TestClient authenticated as the standard test user.
    Overrides get_db and get_current_user dependencies.
    """
    def override_db():
        yield db

    def override_user():
        return user

    app.dependency_overrides[get_db]           = override_db
    app.dependency_overrides[get_current_user] = override_user

    with TestClient(app) as c:
        yield c

    app.dependency_overrides.clear()

@pytest.fixture
def admin_client(db, admin) -> TestClient:
    """TestClient authenticated as an admin user."""
    def override_db():  yield db
    def override_user(): return admin

    app.dependency_overrides[get_db]           = override_db
    app.dependency_overrides[get_current_user] = override_user

    with TestClient(app) as c:
        yield c

    app.dependency_overrides.clear()

@pytest.fixture
def anon_client(db) -> TestClient:
    """TestClient with no authentication (anonymous requests)."""
    def override_db(): yield db
    app.dependency_overrides[get_db] = override_db

    with TestClient(app) as c:
        yield c

    app.dependency_overrides.clear()

Common Mistakes

Mistake 1 — Not clearing dependency_overrides after each test

❌ Wrong — overrides leak into subsequent tests:

def test_something():
    app.dependency_overrides[get_db] = override_db
    TestClient(app).get("/posts")
    # No cleanup — next test may still use override_db!

✅ Correct — clear in fixture teardown (after yield).

Mistake 2 — scope=”session” on the db fixture (shared state between tests)

❌ Wrong — data from one test affects another:

@pytest.fixture(scope="session")
def db(): ...   # all tests share one session — mutations accumulate!

✅ Correct — scope="function" (default) with rollback per test.

Mistake 3 — Using TestClient inside async test without proper event loop

❌ Wrong — TestClient is synchronous; using it in an async test has subtle issues.

✅ Correct — use TestClient in sync tests; use httpx.AsyncClient in async tests (Lesson 4).

Quick Reference

Task Code
Install pip install pytest pytest-asyncio httpx pytest-cov
Test client TestClient(app) from fastapi.testclient
Override dependency app.dependency_overrides[dep] = override
Clear overrides app.dependency_overrides.clear()
Rollback pattern Transaction → yield session → rollback
Run tests pytest tests/ -v
Run with coverage pytest --cov=app --cov-report=term-missing

🧠 Test Yourself

You have a client fixture that sets app.dependency_overrides and yields a TestClient. Test A passes and test B fails with an error. What happens to the dependency overrides?