Lesson 1: Testing
The problem this lesson solves
Everything built so far has been tested by running it and looking at the output. That worked while the pipeline was small. It does not work now.
The recipe-extractor system has a retrieval stage, a repair loop, a tool executor, a streaming endpoint, a retry layer, a circuit breaker, and a budget guard. A change to the chunking strategy can break citation resolution three modules away. A prompt edit can double cost. And the core operation calls a model that returns something different every run, costs money each time, and needs a network.
Standard testing advice has nothing to say about that last part, which is why AI systems are often barely tested at all. The way through is a division: most of your code is ordinary deterministic logic and should be tested normally, and the small part that is genuinely non-deterministic needs a different tier entirely.
pytest: fixtures, parametrization, markers, conftest
Fixtures provide test dependencies and clean up after themselves.
# tests/conftest.py
import pytest
from collections.abc import AsyncIterator
@pytest.fixture
def sample_document() -> Document:
return Document(
id="doc_001",
content_hash="a3f8",
content="Focaccia\n\nBake at 220C for 25 minutes.",
source_path="fixtures/focaccia.html",
status="valid",
)
@pytest.fixture
async def repository() -> AsyncIterator[DocumentRepository]:
"""An in-memory repository, fresh for every test."""
repo = InMemoryDocumentRepository()
yield repo
await repo.clear()
@pytest.fixture
def fake_provider() -> FakeProvider:
return FakeProvider(responses=[Completion(text="ok", model="fake", usage=Usage(10, 5))])
conftest.py holds fixtures shared across a directory, discovered automatically with no import. Fixtures using yield run their cleanup after the test, which is how the repository is reset without every test remembering to.
The in-memory repository and fake provider come directly from the Protocols in Module 7. That is the payoff of that design arriving: testing needs no mocking library because a substitute implementation is just a class with the right methods.
Parametrization runs one test over many inputs.
@pytest.mark.parametrize(
("raw", "expected"),
[
("4", 4),
("4-6", 4),
("serves 4", 4),
("", None),
("about four", None),
("0", None),
],
)
def test_parse_servings(raw: str, expected: int | None) -> None:
assert parse_servings(raw) == expected
Six separate tests with six names in the output, so a failure tells you which input broke rather than that the function is wrong somewhere. This is where the edge cases from Module 2 belong: empty input, boundary values, and the malformed cases you found by attacking your own solution.
Markers label tests so you can select them.
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second",
"integration: requires a real database",
"live: makes real API calls and costs money",
"eval: quality evaluation, runs nightly",
]
asyncio_mode = "auto"
@pytest.mark.live
async def test_real_provider_returns_completion() -> None: ...
uv run pytest -m "not live and not eval" # the fast suite
uv run pytest -m eval # nightly
That selection is what makes the CI layering in Lesson 5 possible, and it is worth setting up before you have many tests rather than after.
Testing async code. With asyncio_mode = "auto" configured, async test functions run without any decorator. [VOLATILE: this depends on the async pytest plugin in use. Verify the current configuration key.]
The testing pyramid for AI systems
The classic pyramid has unit tests at the base, then integration, then end-to-end. AI systems need a fourth tier, and the discipline is knowing which tier a given check belongs in.
Unit tests cover deterministic logic with no network: chunking, token counting, budget arithmetic, prompt template rendering, argument validation, retry classification, fusion ranking. These are fast, free, and should be the large majority of your tests.
Integration tests cover the wiring: a repository against a real database, a retriever against a real vector store, an endpoint through the HTTP layer. Slower, still deterministic, run on every commit if they finish quickly enough.
Contract tests cover the boundary with a provider using recorded responses: that your adapter parses a real payload correctly, that a malformed response is handled, that a 429 triggers retry. Deterministic because the response is recorded.
Evals cover quality, which is not a pass or fail on one example but a measurement across many. They cost money, they vary between runs, and they belong in a separate tier with its own schedule, which is Lesson 2's subject.
[IMAGE PROMPT M11-1
Purpose: Show the four testing tiers for an AI system, what each covers, and how cost and determinism change up the stack.
Visual type: Layered pyramid diagram with annotated axes.
Prompt: A clean educational pyramid divided into four horizontal bands, widest at the bottom. From bottom to top the bands are labelled "Unit" with the sub-label "chunking, counting, budgets, validation, ranking", "Integration" with "repository, retriever, endpoints", "Contract" with "recorded provider responses, error paths", and "Eval" with "quality across many examples". To the left of the pyramid, a vertical arrow points upward labelled "cost per run rises". To the right, a second vertical arrow points upward labelled "determinism falls". Beside each band on the right sit two short labels giving run frequency: "every commit" beside Unit and Integration, "every commit" beside Contract, and "nightly" beside Eval. A note beneath the pyramid reads "most checks belong at the bottom, where they are free and repeatable".
Required elements: Four labelled bands in the stated order with their sub-labels, two directional axis arrows with their labels, run-frequency labels beside each band, the note beneath.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, clean band divisions.
Layout: Centred pyramid with axis arrows on the left and right, frequency labels on the outer right, note centred beneath.
Text labels: "Unit", "Integration", "Contract", "Eval", "chunking, counting, budgets, validation, ranking", "repository, retriever, endpoints", "recorded provider responses, error paths", "quality across many examples", "cost per run rises", "determinism falls", "every commit", "nightly", "most checks belong at the bottom, where they are free and repeatable".
Aspect ratio: 4:3
Accessibility: Convey the progression through band order, arrow direction, and text labels rather than colour alone.
Avoid: Decorative icons, screenshots, tiny text, logos, watermarks.
Alt text: Four-tier testing pyramid for AI systems with unit tests at the base, then integration, contract, and eval tiers, annotated to show cost rising and determinism falling toward the top.
END IMAGE PROMPT]
Separating deterministic logic from model calls
The single highest-value testing decision is making most of your code testable without a model, and Module 3 already established how: keep the decisions in a pure core and push the model calls to an impure shell.
# Hard to test: one function, one model call, all the logic inside
async def extract_recipe(document: Document) -> ExtractedRecipe:
prompt = f"Extract from: {document.content[:8000]}"
response = await provider.generate([Message("user", prompt)])
data = json.loads(response.text.strip().removeprefix("```json").removesuffix("```"))
if data.get("servings") and isinstance(data["servings"], str):
data["servings"] = int(re.search(r"\d+", data["servings"]).group())
return ExtractedRecipe(**data)
Every rule in that function requires a model call to exercise. Split it:
def build_extraction_messages(document: Document, schema: type[BaseModel]) -> list[Message]:
"""Pure: document in, messages out."""
def extract_json_text(raw: str) -> str:
"""Pure: strip fences and preambles."""
def parse_extraction(raw: str, schema: type[BaseModel]) -> BaseModel:
"""Pure: text in, validated record out, raises on failure."""
async def extract_recipe(document: Document, schema: type[BaseModel]) -> BaseModel:
"""Impure shell: three lines, no logic worth testing."""
messages = build_extraction_messages(document, schema)
response = await provider.generate(messages)
return parse_extraction(response.text, schema)
The three pure functions now carry every rule and are tested with plain strings in microseconds. The shell has nothing to get wrong, so one contract test covers it.
Apply the same split throughout. Retry classification, budget checks, chunk fitting, fusion ranking, citation resolution, and loop termination are all pure functions of their inputs, and all of them were written that way in earlier modules for exactly this reason.
Mocking providers and fake clients
Two approaches, and one is usually better.
A fake implementation is a class satisfying the Protocol.
class FakeProvider:
"""A provider returning scripted responses. Satisfies LLMProvider."""
def __init__(self, responses: list[Completion | Exception]) -> None:
self._responses = list(responses)
self.calls: list[list[Message]] = []
async def generate(self, messages: list[Message], **kwargs) -> Completion:
self.calls.append(messages)
if not self._responses:
raise AssertionError("FakeProvider ran out of scripted responses")
item = self._responses.pop(0)
if isinstance(item, Exception):
raise item
return item
async def test_repair_loop_retries_on_validation_failure() -> None:
provider = FakeProvider([
Completion(text='{"title": 123}', model="fake", usage=Usage(10, 5)), # invalid
Completion(text='{"title": "Focaccia"}', model="fake", usage=Usage(10, 5)),
])
result = await extract_with_repair(document, schema=ExtractedRecipe, provider=provider)
assert result.title == "Focaccia"
assert len(provider.calls) == 2
assert "validation" in provider.calls[1][-1].content.lower()
That last assertion is the interesting one: it checks that the validation error was actually fed back, which is the behaviour the repair loop exists for.
Note the scripted list including exceptions, which makes failure paths as easy to test as success paths.
unittest.mock patches an existing object.
from unittest.mock import AsyncMock, patch
async def test_timeout_is_retried() -> None:
with patch.object(provider, "generate", new=AsyncMock(side_effect=[
ProviderTimeoutError("timeout"),
Completion(text="ok", model="fake", usage=Usage(1, 1)),
])) as mocked:
result = await generate_with_retry(messages)
assert mocked.await_count == 2
Prefer the fake. A mock patches a name, so it breaks when you rename or move something and it silently passes when the real interface changes. A fake satisfies the Protocol, so mypy checks it against the same contract as the real implementation, and a signature change breaks the fake at type-check time rather than leaving a test that passes against an interface nobody has.
Reach for unittest.mock when you need to patch something you do not control, or to assert on call arguments of a function that is not behind an interface.
Recorded responses
A fake returns what you scripted, which tests your logic against your assumptions. A recorded response is a real provider payload captured once and replayed, which tests your logic against reality.
@pytest.fixture
def recorded_provider(request) -> RecordingProvider:
"""Replay a recorded response, or record one when RECORD=1 is set."""
path = Path("tests/cassettes") / f"{request.node.name}.json"
if os.environ.get("RECORD") == "1":
return RecordingProvider(real_provider, record_to=path)
return RecordingProvider.from_file(path)
async def test_adapter_parses_real_response(recorded_provider) -> None:
completion = await recorded_provider.generate(messages, model="test-model")
assert completion.finish_reason == "stop"
assert completion.usage.output_tokens > 0
Four properties make this worth the setup. The payload is real, including the field names and nesting your adapter must handle. The test is free and offline, so it runs on every commit. It is deterministic, so it cannot flake. And re-recording is one environment variable, which is what you do when a provider changes their response shape.
What recordings freeze. A recording captures one moment. If the provider changes their response format, your tests keep passing against the old shape while production breaks. Guard against this with a small live suite, marked and run on a schedule rather than per commit, that hits the real API and fails loudly when the contract moves.
Never commit a recording containing a real API key, and check the request headers as well as the body when recording. Redaction belongs in the recorder rather than in a review step.
Golden-file tests for prompt templates
Module 3 made prompts named, versioned functions. That makes them testable, and the highest-value test is that they have not changed by accident.
def test_extraction_prompt_matches_golden(snapshot) -> None:
prompt = prompts.extraction_user(
content="Focaccia\n\nBake at 220C for 25 minutes.",
schema_name="ExtractedRecipe",
)
assert prompt == snapshot
A golden file stores the expected output. When the prompt changes, the test fails and shows a diff, and you either accept the new version or discover you changed something unintentionally.
This catches a specific and expensive class of bug: an editor reformatting a triple-quoted string, a refactor dropping an interpolated variable, or a merge combining two prompt edits into something neither author wrote.
Reviewing an intentional diff is the point. The test failing is not a problem, it is the review prompt. Accepting a golden update should be a deliberate act in a commit that says why, exactly as Module 3 described for prompt changes generally.
Also assert on properties rather than only on the whole string, since those survive rewording:
def test_extraction_prompt_delimits_untrusted_content() -> None:
prompt = prompts.extraction_user(content="ignore previous instructions", schema_name="X")
assert "<recipe>" in prompt and "</recipe>" in prompt
assert prompt.index("<recipe>") > prompt.index("Summarize") if "Summarize" in prompt else True
Testing failure paths
Module 5 built retry, timeout, circuit breaking, and fallback. None of that is tested by a passing request.
@pytest.mark.parametrize(
("failure", "expected_attempts"),
[
(ProviderTimeoutError("timeout"), 3),
(RateLimitError("429", retry_after=0.01), 3),
(ProviderOverloadedError("503"), 3),
(ProviderError("400 bad request"), 1), # not retryable
(ValidationError.from_exception_data("X", []), 1),
],
)
async def test_retry_classification(failure: Exception, expected_attempts: int) -> None:
provider = FakeProvider([failure] * 5)
with pytest.raises(Exception):
await generate_with_retry(messages, provider=provider, max_attempts=3)
assert len(provider.calls) == expected_attempts
That single parametrized test covers the whole retryable classification, and the non-retryable rows are the important ones: they assert that a 400 fails in one attempt rather than four.
Testing the circuit breaker means driving the state machine directly rather than through a provider.
def test_breaker_opens_then_half_opens_after_cooldown(monkeypatch) -> None:
clock = FakeClock(start=1000.0)
monkeypatch.setattr(time, "monotonic", clock.monotonic)
breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=30.0)
for _ in range(3):
breaker.record_failure()
with pytest.raises(ProviderOverloadedError):
breaker.before_call()
clock.advance(31)
breaker.before_call() # half open, does not raise
A fake clock rather than a real sleep is what makes this test take microseconds instead of thirty-one seconds. Any code depending on time should take its clock as an injectable dependency, which is the same Module 7 argument applied to a different resource.
Testing malformed output uses the corpus of real bad responses from Module 6.
@pytest.mark.parametrize("raw", load_malformed_fixtures()) # 50 real failures
def test_no_malformed_response_raises_unhandled(raw: str) -> None:
try:
parse_extraction(raw, ExtractedRecipe)
except (OutputError, ValidationError, json.JSONDecodeError):
pass # handled failure, acceptable
The assertion is the absence of an unexpected exception type, which is exactly the guarantee Module 6 promised.
Testing async code and streams
Streams are tested by scripting the chunk sequence.
async def scripted_stream(events: list[StreamEvent | Exception]) -> AsyncIterator[StreamEvent]:
for item in events:
if isinstance(item, Exception):
raise item
yield item
async def test_stream_accumulates_and_reports_usage() -> None:
result = await stream_and_collect(
scripted_stream([
StreamEvent(kind="content", text="Bake at "),
StreamEvent(kind="content", text="220C"),
StreamEvent(kind="finish", finish_reason="stop"),
StreamEvent(kind="usage", usage=Usage(100, 20)),
]),
on_text=lambda _: asyncio.sleep(0),
)
assert result.text == "Bake at 220C"
assert result.usage.output_tokens == 20
Test the failure modes specifically, since those are what Module 8 built machinery for.
async def test_mid_stream_failure_returns_partial_and_reports_error() -> None:
events = scripted_stream([
StreamEvent(kind="content", text="Bake at "),
ProviderError("connection reset"),
])
with pytest.raises(ProviderError):
await stream_and_collect(events, on_text=collector.append)
assert collector.text == "Bake at " # partial output was delivered
async def test_client_disconnect_cancels_upstream() -> None:
upstream = TrackingStream()
task = asyncio.create_task(consume(upstream))
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert upstream.was_closed
That second test is the one worth writing carefully, because Module 8 noted that a broken cancellation chain produces no error and costs money silently.
Property-based testing with Hypothesis
Example-based tests check the cases you thought of. Property-based tests generate inputs and check that a property holds for all of them.
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=5000), st.integers(min_value=50, max_value=1000))
def test_chunking_preserves_all_content(text: str, size: int) -> None:
"""Every character of the input appears in some chunk, in order."""
chunks = chunk_recursive(text, size=size, separators=DEFAULT_SEPARATORS)
assert "".join(chunks).replace(" ", "") == text.replace(" ", "")
@given(st.text())
def test_token_count_is_never_negative(text: str) -> None:
assert count_text_tokens(text) >= 0
@given(st.lists(st.builds(make_scored_chunk), min_size=1, max_size=50))
def test_fusion_output_is_subset_of_input(chunks: list[ScoredChunk]) -> None:
fused = reciprocal_rank_fusion([chunks], top_k=10)
input_ids = {c.chunk.id for c in chunks}
assert {c.chunk.id for c in fused} <= input_ids
Hypothesis will find the inputs you did not consider: empty strings, strings of only whitespace, a single character, a string that is entirely one separator, and Unicode that behaves oddly. It shrinks failures to a minimal reproducing case, so a failure arrives as the smallest input that breaks it rather than a random 4,000 character string.
Where it pays off in this system. Parsers, chunkers, token counters, fusion and ranking functions, and budget arithmetic are all functions with properties that should hold universally, and all of them handle input from sources you do not control.
Where it does not. Anything requiring a model call, or where you cannot state a property more useful than restating the implementation.
Testing the agent loop
Module 6's loop has three guards and a dispatch path, and all four need tests.
async def test_loop_stops_on_repeated_identical_calls() -> None:
provider = FakeProvider([tool_call_response("search", {"q": "x"})] * 10)
result = await run_agent("find x", registry=registry, provider=provider)
assert result.stopped_reason is not None
assert "identical arguments" in result.stopped_reason
async def test_loop_respects_max_iterations() -> None:
provider = FakeProvider([tool_call_response("search", {"q": f"q{i}"}) for i in range(50)])
result = await run_agent("go", registry=registry, provider=provider, max_iterations=5)
assert len(provider.calls) <= 5
async def test_unknown_tool_returns_error_to_model_not_raise() -> None:
result = await execute_tool(registry, "not_a_tool", {})
assert result.is_error
assert "unknown tool" in result.message.lower()
async def test_invalid_arguments_never_reach_the_handler() -> None:
handler = TrackingHandler()
registry.register(RegisteredTool("search", SearchArgs, handler))
result = await execute_tool(registry, "search", {"query": 123, "limit": "five"})
assert result.is_error
assert handler.call_count == 0
That last test is the security-relevant one and states the Module 6 guarantee directly: an unvalidated argument never reaches executable code.
Also test the injection case, since Module 6 argued you cannot rely on the model resisting it.
async def test_injected_instruction_in_tool_result_cannot_reach_a_gated_tool() -> None:
registry = build_registry(search_returns=POISONED_DOCUMENT)
result = await run_agent("summarize the document", registry=registry, provider=provider)
assert not any(c.name == "send_email" for c in result.tool_calls)
Reading coverage honestly
Coverage measures which lines executed during the test run. It does not measure whether they were checked.
uv run pytest --cov=src/recipe_extractor --cov-report=term-missing
What it is genuinely good for. Finding code no test touches at all, which is usually error handling, and specifically the branches Module 5 built. Missing coverage on your retry classification or your fallback chain means those paths have never run outside production.
How it misleads. A test calling a function and asserting nothing gives full coverage and zero confidence. Line coverage ignores branches, so an if with no else can be fully covered by the true case alone. And a high number invites the belief that the system is tested when the untested twelve percent is the failure handling.
Use it as a map, not a score. Read the missing-lines report, decide whether each gap matters, and cover the ones that do. A coverage percentage target creates pressure to write tests that raise the number, which are precisely the tests that assert nothing.
Concept check. Your test suite is 91 percent covered and a production incident happened when the provider returned a 503 during a stream. How is that consistent, and what would have caught it?
Answer
Entirely consistent. The nine percent uncovered is very likely the error handling, because success paths are what tests naturally exercise and failure paths require deliberate construction. Coverage told you the number and not which lines, and the missing lines were the ones that mattered.
What would have caught it is a scripted stream test raising a ProviderError partway through, asserting that partial output is delivered, that an error event is emitted after the status line, and that the concurrency slot is released. All three of those are Module 8 behaviours that a passing request never exercises.
The broader lesson is to read the missing-lines report rather than the percentage. Error handling appearing in that report is the signal to act on, and it is invisible in the summary number.