Dependency Overrides — Testing with Fake Dependencies

FastAPI’s app.dependency_overrides dictionary replaces any dependency with a different callable for the duration of testing. This is the mechanism that makes FastAPI applications highly testable — instead of hitting a real database, authentication system, or external service, tests swap in lightweight fakes that control all inputs. The pattern is: define a factory function that creates the override, call app.dependency_overrides[original_dep] = override_factory, run the test, then clear the override. Combined with pytest’s conftest.py for fixtures, this produces a clean, reproducible test environment.

Basic Dependency Override Pattern

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.main import app
from app.dependencies import get_db, get_current_user
from app.database import Base
from app.models.user import User

# ── Test database — SQLite in-memory for speed ────────────────────────────────
TEST_DATABASE_URL = "sqlite:///./test.db"   # or "sqlite:///:memory:"
test_engine = create_engine(
    TEST_DATABASE_URL,
    connect_args={"check_same_thread": False}
)
TestSessionLocal = sessionmaker(bind=test_engine)

@pytest.fixture(scope="session", autouse=True)
def create_test_tables():
    Base.metadata.create_all(test_engine)
    yield
    Base.metadata.drop_all(test_engine)

@pytest.fixture
def db_session():
    """
    Yields a session that rolls back all changes after each test.
    Ensures test isolation without recreating the schema.
    """
    connection = test_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)

    yield session

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

@pytest.fixture
def test_user(db_session) -> User:
    """Create a test user in the test database."""
    user = User(
        email         = "test@example.com",
        name          = "Test User",
        password_hash = "hashed_password",
        role          = "user",
    )
    db_session.add(user)
    db_session.flush()
    return user
Note: The test session fixture uses a transaction that wraps the entire test and rolls back at the end. This is significantly faster than dropping and recreating tables between tests — the schema stays in place, only the data changes are rolled back. The scope="function" default means a fresh rolled-back session for every test function, guaranteeing isolation. Use scope="module" only if you are certain tests do not interfere with each other.
Tip: Use a real PostgreSQL test database (not SQLite) when your application uses PostgreSQL-specific features like JSONB, tsvector, arrays, or PostgreSQL-specific constraints. SQLite’s SQL dialect differs from PostgreSQL in subtle ways — queries that work in SQLite may fail in PostgreSQL or produce different results. Create a dedicated test_db PostgreSQL database and set TEST_DATABASE_URL to point to it in your CI environment.
Warning: Always clear app.dependency_overrides after each test that sets them, or use a pytest fixture with yield to guarantee cleanup. If you forget to clear overrides, a subsequent test that expects the real dependency will still get the fake, producing false positives or confusing failures. The safest pattern is to set overrides in a fixture with yield and clear them in the fixture’s teardown.

Complete Test Client Setup

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

@pytest.fixture
def client(db_session, test_user) -> TestClient:
    """
    Returns a TestClient with get_db and get_current_user overridden.
    """
    def override_get_db():
        yield db_session   # use the test session (auto-rolls-back)

    def override_get_current_user():
        return test_user   # always return the test user

    app.dependency_overrides[get_db]           = override_get_db
    app.dependency_overrides[get_current_user] = override_get_current_user

    yield TestClient(app)

    app.dependency_overrides.clear()   # crucial: reset after each test

@pytest.fixture
def anonymous_client(db_session) -> TestClient:
    """
    TestClient without authentication — for testing public endpoints
    and verifying 401 responses on protected endpoints.
    """
    def override_get_db():
        yield db_session

    app.dependency_overrides[get_db] = override_get_db

    yield TestClient(app)

    app.dependency_overrides.clear()

Writing Integration Tests

# tests/test_posts.py
import pytest
from fastapi.testclient import TestClient
from app.models.post import Post

class TestPostEndpoints:

    def test_list_posts_returns_empty_for_fresh_db(self, client):
        response = client.get("/posts/")
        assert response.status_code == 200
        data = response.json()
        assert data["total"] == 0
        assert data["items"] == []

    def test_create_post_success(self, client, db_session, test_user):
        response = client.post("/posts/", json={
            "title": "Test Post",
            "body":  "This is the post body content.",
            "slug":  "test-post",
        })
        assert response.status_code == 201
        data = response.json()
        assert data["title"] == "Test Post"
        assert data["author_id"] == test_user.id

        # Verify in database
        post = db_session.get(Post, data["id"])
        assert post is not None
        assert post.title == "Test Post"

    def test_create_post_unauthenticated(self, anonymous_client):
        response = anonymous_client.post("/posts/", json={
            "title": "Test", "body": "Body content", "slug": "test"
        })
        assert response.status_code == 401

    def test_get_nonexistent_post_returns_404(self, client):
        response = client.get("/posts/999999")
        assert response.status_code == 404

    def test_update_post_by_owner(self, client, db_session, test_user):
        # Create a post first
        post = Post(title="Old", body="Old body", slug="old",
                    author_id=test_user.id, status="published")
        db_session.add(post)
        db_session.flush()

        response = client.patch(f"/posts/{post.id}", json={"title": "New Title"})
        assert response.status_code == 200
        assert response.json()["title"] == "New Title"

    def test_delete_post_by_non_owner_returns_403(self, client, db_session):
        other_user_id = 999   # different from test_user.id
        post = Post(title="Other's Post", body="Body", slug="others-post",
                    author_id=other_user_id, status="published")
        db_session.add(post)
        db_session.flush()

        response = client.delete(f"/posts/{post.id}")
        assert response.status_code == 403

Overriding for Admin Tests

@pytest.fixture
def admin_client(db_session) -> TestClient:
    admin_user = User(
        email="admin@example.com",
        name="Admin",
        password_hash="hash",
        role="admin",
    )
    db_session.add(admin_user)
    db_session.flush()

    def override_get_db():
        yield db_session

    def override_get_current_user():
        return admin_user

    app.dependency_overrides[get_db]           = override_get_db
    app.dependency_overrides[get_current_user] = override_get_current_user

    yield TestClient(app)

    app.dependency_overrides.clear()

# Test admin-only endpoint
def test_hard_delete_requires_admin(client, admin_client, db_session, test_user):
    post = Post(title="Delete Me", body="Body", slug="delete-me",
                author_id=test_user.id, status="published")
    db_session.add(post)
    db_session.flush()

    # Regular user cannot hard-delete
    r1 = client.delete(f"/posts/{post.id}/hard-delete")
    assert r1.status_code == 403

    # Admin can hard-delete
    r2 = admin_client.delete(f"/posts/{post.id}/hard-delete")
    assert r2.status_code == 204

Common Mistakes

Mistake 1 — Not clearing dependency_overrides after tests

❌ Wrong — override leaks into next test:

def test_something():
    app.dependency_overrides[get_db] = override_get_db
    client = TestClient(app)
    ...
    # No cleanup! Next test gets the fake db too

✅ Correct — clear in fixture teardown:

@pytest.fixture
def client():
    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()   # ✓ always runs, even on test failure

Mistake 2 — Using SQLite for PostgreSQL-specific features

❌ Wrong — tsvector query fails silently in SQLite:

TEST_DATABASE_URL = "sqlite:///./test.db"   # no JSONB, tsvector, etc!

✅ Correct — use PostgreSQL for the test database when the app uses PG-specific features.

Mistake 3 — Not rolling back between tests (test pollution)

❌ Wrong — test data from test A affects test B:

def test_a():
    Post(title="Leftover").save()   # stays in DB
def test_b():
    count = Post.count()   # sees leftover from test_a!

✅ Correct — roll back the test transaction after each test (as in the fixture above).

Quick Reference

Task Code
Override dependency app.dependency_overrides[dep] = override
Clear all overrides app.dependency_overrides.clear()
Test client TestClient(app) (from fastapi.testclient)
Rollback per test Wrap session in a transaction, rollback in fixture teardown
Override auth app.dependency_overrides[get_current_user] = lambda: test_user
Anonymous client Override get_db only, not get_current_user

🧠 Test Yourself

Why should you use a transaction rollback (not table truncation) to reset the test database between tests?