Module 10 · Testing · Drills
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.
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.
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?
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
test_*.py and functions test_*. No class or base required.pytest -k name -x. Add -v for verbose names.conftest.py — auto-available to all tests below it, no import.@pytest.mark.asyncio on an async def (needs pytest-asyncio).app.dependency_overrides[dep] = fake, then .clear() after.AsyncClient(transport=ASGITransport(app=app)) from httpx.Tick each only if you can do it without looking:
assert and the -k/-x/-v flagsyield teardown) and share it via conftest.py