Module 10 · Testing · Drills

Drills: Testing Python & FastAPI

Reading about tests teaches you nothing — running them teaches you everything. Write each drill into a real file, run pytest, watch it go green, then reveal the solution to compare.

How to use this page Each drill is a small task. Write it and run pytest first, then click "Show solution" to compare. If yours passes differently but correctly — great, that's fluency. Tick each box as you go; your progress is saved in this browser. Assume pip install pytest pytest-asyncio httpx is done.

A · Warm-up reps Basic

Drill 1 first test

You have a function def slugify(s): return s.lower().replace(" ", "-"). Write a pytest test asserting slugify("Hello World") equals "hello-world". What command runs only this test verbosely?

Show solution
from app.text import slugify

def test_slugify_basic():
    assert slugify("Hello World") == "hello-world"

Run with pytest -k slugify -v. The function name must start with test_ or pytest won't discover it.

Drill 2 fixture

Write a fixture sample_user that returns {"id": 1, "name": "Sam"}, and a test that uses it to assert the name.

Show solution
import pytest

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Sam"}

def test_user_name(sample_user):   # param name == fixture name
    assert sample_user["name"] == "Sam"

pytest injects the fixture by matching the parameter name. Move it to conftest.py and every test file can use it with no import.

Drill 3 parametrize

Parametrize a test of slugify over three cases: "A B""a-b", "X""x", "Two Words""two-words".

Show solution
import pytest
from app.text import slugify

@pytest.mark.parametrize("raw, expected", [
    ("A B", "a-b"),
    ("X", "x"),
    ("Two Words", "two-words"),
])
def test_slugify_cases(raw, expected):
    assert slugify(raw) == expected

Three rows = three independent tests. A failing one is reported as test_slugify_cases[X-x] so you know exactly which case broke.

B · Stretch Intermediate

Drill 4 monkeypatch

A function app.rag.call_llm(prompt) hits a real API. Write a test for summarize(text) (which calls it) that monkeypatches call_llm to return "fake", so no network is touched.

Show solution
from app.rag import summarize

def test_summarize_mocks_llm(monkeypatch):
    monkeypatch.setattr("app.rag.call_llm",
                        lambda prompt: "fake")
    assert summarize("long document text") == "fake"

Patch where the name is used (app.rag.call_llm), not where it's defined. monkeypatch auto-restores after the test — no cleanup needed. The same fixture sets env vars: monkeypatch.setenv("OPENAI_API_KEY", "x").

Drill 5 async

You have async def embed_text(s) returning a list of 1536 floats. Write an async test that awaits it and asserts the length.

Show solution
import pytest
from app.rag import embed_text

@pytest.mark.asyncio
async def test_embed_length():
    vec = await embed_text("hello")
    assert len(vec) == 1536

Without @pytest.mark.asyncio (from pytest-asyncio) the coroutine is never awaited and the test silently does nothing.

Drill 6 TestClient

DocChat has a sync route GET /health returning {"status": "ok"}. Write a TestClient test asserting both the status code and the JSON body.

Show solution
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_health():
    resp = client.get("/health")
    assert resp.status_code == 200
    assert resp.json() == {"status": "ok"}

Always assert the status code before the body — a 422 also returns valid JSON, so a body-only check can pass against the wrong response.

Drill 7 dependency override

DocChat's /ask depends on get_db. Override it with a fake session for one test, then clear the override so it doesn't leak into other tests.

Show solution
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_db

def test_ask_with_fake_db(fake_session):
    def override():
        yield fake_session
    app.dependency_overrides[get_db] = override
    try:
        resp = TestClient(app).post("/ask",
            json={"question": "hi"})
        assert resp.status_code == 200
    finally:
        app.dependency_overrides.clear()   # never leak

The finally (or a client fixture that clears on teardown) is essential — a stray override poisons every later test. The same mechanism swaps get_current_user for a fixed test user.

C · Build challenge Build

Mini-project Build a small test suite for DocChat's auth + ask flow: a request with no token is rejected (401), and an authenticated request returns a grounded answer with the LLM fully mocked. Override auth and DB, fake the LLM, and keep the whole suite under a second.

Build · auth + ask suite

In conftest.py, build a client fixture that overrides get_db and mocks the LLM. Then write two tests: unauthenticated → 401, authenticated → 200 with the canned answer.

Show solution
tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_db
from app.auth import get_current_user

@pytest.fixture
def client(test_session, monkeypatch):
    monkeypatch.setattr("app.rag.call_llm",
                        lambda prompt: "Grounded answer.")
    monkeypatch.setattr("app.rag.embed_text",
                        lambda text: [0.1] * 1536)
    app.dependency_overrides[get_db] = lambda: (yield test_session)
    yield TestClient(app)
    app.dependency_overrides.clear()

@pytest.fixture
def auth_client(client):
    app.dependency_overrides[get_current_user] = lambda: {"id": 1}
    return client
tests/test_auth_ask.py
def test_ask_requires_auth(client):
    resp = client.post("/ask", json={"question": "hi"})
    assert resp.status_code == 401

def test_authed_ask_returns_answer(auth_client):
    resp = auth_client.post("/ask",
        json={"question": "What is RAG?"})
    assert resp.status_code == 200
    assert resp.json()["answer"] == "Grounded answer."

The unauthenticated test uses the bare client (real auth runs → 401). The happy-path test uses auth_client, which layers an auth override on top. The LLM is mocked in both, so the suite is fast and deterministic — exactly what CI needs.

D · Rapid recall Flashcards

Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

What name prefix does pytest discover?
Files test_*.py and functions test_*. No class or base required.
click to flip
Run one test, stop at first failure?
pytest -k name -x. Add -v for verbose names.
click to flip
Where do shared fixtures go?
conftest.py — auto-available to all tests below it, no import.
click to flip
Mark an async test?
@pytest.mark.asyncio on an async def (needs pytest-asyncio).
click to flip
Swap a FastAPI dependency in tests?
app.dependency_overrides[dep] = fake, then .clear() after.
click to flip
Async in-process FastAPI client?
AsyncClient(transport=ASGITransport(app=app)) from httpx.
click to flip

E · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? Your backend is now provably correct. Next we test what the user actually sees — the browser flow end to end: Lesson 10.2 — Frontend & End-to-End Testing.