Home / Courses / Testing for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 4 of 9

Hands-On: Fixtures & Dependency Override

Writing pytest fixtures with yield, why tests use in-memory SQLite instead of a real Postgres, swapping FastAPI dependencies via dependency_overrides, and the lifespan trap that reaches for a real database.

July 10, 2026

The unit tests in the previous article tested pure functions. Now we move up a layer: integration tests — testing real API endpoints over HTTP, including their interaction with a database. That needs something we haven't covered yet: a consistent way to set up (and tear down) test conditions, without repeating setup code in every test.

Fixtures — Reusable Setup Code

A fixture is a function that prepares something a test needs, and pytest automatically "injects" it into any test that requests it via a parameter:

import pytest


@pytest.fixture(name="session")
def session_fixture():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session

Notice yield, not return — this is an important pattern. Code BEFORE yield is setup, code AFTER yield (if any) is teardown, run automatically once the test finishes, REGARDLESS of the outcome (pass or fail). This fixture is used just by naming it as a parameter:

def test_something(session: Session):
    # `session` here is ALREADY a ready-to-use Session object
    ...

Why In-Memory SQLite Instead of Real Postgres?

Our production app uses Postgres (see the Scalability and Security series), but our tests use in-memory SQLite. This is a deliberate decision, directly applying the Fast and Independent principles from the article on test anatomy:

  • In-memory SQLite (sqlite://) only lives for as long as the test process runs, never touches disk, and needs no separate database process — much faster for repeated setup/teardown.
  • Every test gets a FRESH, EMPTY DATABASE (the session fixture is re-run for each test, by default), so no test "leaks" state into another — the Independent principle.
  • poolclass=StaticPool matters here — by default, in-memory SQLite creates a NEW database every time a connection is opened, which would break consistency unless we make sure the same single connection is reused for the duration of that test.

    Dependency Override — Swapping the Real App Dependency for a Test Version

    Remember Depends(get_session), which we've used since the start of the FastAPI series? This is where its payoff really shows. FastAPI provides app.dependency_overrides, a dictionary that maps a REAL dependency to a REPLACEMENT version:

    from app.database import get_session
    from app.main import app
    
    
    @pytest.fixture(name="client")
    def client_fixture(session: Session, cache: FakeRedis):
        app.dependency_overrides[get_session] = lambda: session
        app.dependency_overrides[get_redis] = lambda: cache
        client = TestClient(app)
        yield client
        app.dependency_overrides.clear()
    

    Once get_session is overridden, EVERY endpoint that uses Depends(get_session) — no exceptions, without changing a single line of endpoint code — automatically uses our test session instead of a real Postgres connection. This is concrete proof that dependency injection (a concept we've discussed since the FastAPI series) isn't just "a tidy way to write code" — it's what makes an application TESTABLE without changing the application code at all.

    app.dependency_overrides.clear() at the end (after yield) is MANDATORY — without it, the override "leaks" into other tests that run afterward, violating the Independent principle.

    A Trap We Found the Hard Way: A Lifespan That Reaches for the Real Database

    One detail that's easy to miss, and genuinely broke our tests while we were putting this series together:

    client = TestClient(app)  # NOT: with TestClient(app) as client:
    

    Why NOT use it as a context manager (with ... as client:)? Because our main.py has a lifespan that calls create_db_and_tables() on startup — and that function uses the REAL engine pointing at Postgres, NOT our test session (dependency overrides only apply to Depends(...), not code called directly inside a lifespan). If TestClient is used as a context manager, that lifespan actually runs, trying to connect to a Postgres instance that doesn't exist while the test is running — and the test fails completely, not because of an application bug, but because of a mistake in the test setup itself.

    The fix is simple: don't use it as a context manager, so the lifespan never fires. The test tables are already created manually via SQLModel.metadata.create_all(engine) in the session fixture. This is a good reminder: a bug in the test itself is just as possible as a bug in the application code — if tests suddenly all fail for no clear reason, suspect the test setup too, not just assume the application code is broken.

    Writing Our First Integration Test

    def test_register_creates_user(client: TestClient):
        response = client.post("/auth/register", json={"username": "alice", "password": "supersecret123"})
        assert response.status_code == 201
        assert response.json()["username"] == "alice"
    
    
    def test_protected_endpoint_rejects_without_token(client: TestClient):
        response = client.get("/api/tasks")
        assert response.status_code == 401
    

    Notice how short this test is ONCE the fixture is in place — client is already automatically connected to a clean test database, and the test itself doesn't need to know any of the details of how that happened. This is what makes fixtures so valuable: setup complexity is written ONCE in conftest.py, then reused by dozens of tests.

    Run the whole suite:

    pytest -v
    
    tests/integration/test_auth.py::test_register_creates_user PASSED
    tests/integration/test_auth.py::test_protected_endpoint_rejects_without_token PASSED
    ...
    ======================= 41 passed in 20.02s =======================
    

    Summary

  • A fixture (@pytest.fixture) is reusable setup code, with yield separating setup (before) from teardown (after).
  • In-memory SQLite is used for tests so they're fast and every test gets a clean database — applying the Fast and Independent principles.
  • app.dependency_overrides swaps a real application dependency for a test version, WITHOUT changing the endpoint code at all.
  • Avoid with TestClient(app) as client: if the app's lifespan touches real resources (like Postgres) that aren't available during tests.
  • Topics

    TestingPytest