Module 2: Unit Testing LLM Applications
1. Introduction: Unit Testing LLM Applications
Description
Module 1 configured pytest and established the fundamental mindset. Now we apply the core technique: making the non-deterministic deterministic through mocks, and treating prompts as contracts that can be validated. This module gives you the superpower of testing AI apps without calling the real LLM.
Context: Where are we?
Before getting into the technical detail, place yourself on the map of what you learned and what's coming:
| Module | What you learned/will learn |
|---|---|
| Module 1 | Pytest, fixtures, markers, test structure, AI testing mindset |
| Module 2 | Mocking LLM responses, prompt contracts, parsers, snapshot testing |
| Module 3 | Integration testing with a real LLM, semantic assertions, non-determinism |
| Module 4 | CI/CD pipeline, E2E tests, testing in production |
Module 2 is the bridge between "I know how to configure pytest" and "I have real tests that protect my app". Here you build the foundation of your test suite: fast, deterministic, with no API cost.
The module's mental transformation
Before this module
Developer → "My app uses LLMs, it can't be tested well"
Developer → "The tests would be slow and expensive"
Developer → "The output varies, how do I make assertions?"
Developer → "I can only test manually"
After this module
Developer → "I mock the LLM → tests in milliseconds, no API calls"
Developer → "My prompt is a contract → I can validate it automatically"
Developer → "Parsers are deterministic → 100% coverage is easy"
Developer → "Snapshot testing detects regressions effortlessly"
This change in mindset is the module's most important outcome.
Central idea: Prompts as contracts
A prompt isn't just text — it's a behavioral contract:
"Given this input, the output will have this structure, meet these constraints, and respect this format."
If the contract is clear, it's testable. If you change the prompt and the contract breaks, the test fails. This idea transforms prompts from "text I modify and pray it works" to "a specification with automatic validation".
Implicit vs explicit contract
Most developers have implicit contracts in their heads. The goal is to make them explicit and testable:
Implicit contract (in your head):
"The LLM summarizes texts and returns something useful"
Explicit contract (testable):
"The output is JSON with:
summary: a string of 10 to 500 charactersconfidence: a float between 0.0 and 1.0sources: a list of strings (can be empty)- Valid format always, even if the input is short"
# The contract as code:
def test_summary_prompt_contract(mock_client):
"""The summary prompt meets the structure and constraints contract."""
# Arrange
text = "Python is an interpreted programming language."
# Act
result = summarize(text, client=mock_client)
# Assert: structure
assert isinstance(result, dict), "The result must be a dictionary"
assert "summary" in result, "The 'summary' key must exist"
assert "confidence" in result, "The 'confidence' key must exist"
assert "sources" in result, "The 'sources' key must exist"
# Assert: types
assert isinstance(result["summary"], str)
assert isinstance(result["confidence"], (int, float))
assert isinstance(result["sources"], list)
# Assert: constraints
assert 10 <= len(result["summary"]) <= 500, \
f"summary must be 10-500 chars, it has {len(result['summary'])}"
assert 0.0 <= result["confidence"] <= 1.0, \
f"confidence must be 0-1, it is {result['confidence']}"
Mocking as a superpower
The LLM is non-deterministic: same input → slightly different outputs. That makes testing hard. The solution: mock the LLM so it always returns what you define.
Why mocking wins
# Without a mock: slow, expensive, variable
def test_slow_and_costly():
client = openai.OpenAI() # Real API
result = summarize("text", client=client) # ~2 seconds, ~$0.001
assert "summary" in result # can fail due to variation
# ❌ Slow (seconds), expensive, non-deterministic
# With a mock: fast, free, deterministic
def test_fast_and_free(mock_client):
result = summarize("text", client=mock_client) # <1ms, $0
assert "summary" in result # always the same
# ✅ Fast (<1ms), free, deterministic
Anatomy of a basic LLM mock
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_client():
"""OpenAI client mock with a realistic structure."""
client = MagicMock()
# Real structure of the OpenAI response
mock_choice = MagicMock()
mock_choice.message.content = '{"summary": "Test summary", "confidence": 0.9, "sources": []}'
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.total_tokens = 150
mock_response.model = "gpt-4o-mini"
client.chat.completions.create.return_value = mock_response
return client
def test_with_mock(mock_client):
result = summarize("Input text", client=mock_client)
assert result["summary"] == "Test summary"
assert result["confidence"] == 0.9
# The mock guarantees this always passes — no variation
The three measurable benefits
Speed: Mock = 0.001s | Real LLM = 2-5s → 2000-5000x faster
Cost: Mock = $0 | Real LLM = $0.001+ → saves thousands in CI
Determinism: Mock = always the same | Real LLM = variable → 0 flaky tests
The 70/30 of testing in AI apps
Module 1 introduced this concept. In Module 2 you apply it:
70% of your code is deterministic:
├── Parsers (convert raw string → dict)
├── Validators (verify the output structure)
├── Formatters (prepare the input for the LLM)
├── Chains (orchestrate multiple calls)
└── Error handlers (handle incorrect responses)
30% is non-deterministic:
└── The LLM (black box, variable outputs)
Module 2 tests the 70% with mocks + deterministic tests. Module 3 tests the 30% with integration tests and semantic assertions.
What you'll achieve in this module
By the end of Module 2 you'll have:
Technical skills
- ✅ Mock LLM responses with
unittest.mockandpytest-mock - ✅ Create mocks with a realistic structure (choices, message, usage)
- ✅ Define specific, testable prompt contracts
- ✅ Write tests that validate contracts with precise assertions
- ✅ Implement snapshot testing to detect regressions
- ✅ Test parsers and output processors in isolation
- ✅ Create fixture factories for parametrized variations
The mini-project
A suite of Prompt Contract Tests for the Module 1 app that:
- Mocks all the LLM calls (0 real calls)
- Defines contracts for each of the app's prompts
- Validates structure, types, and constraints automatically
- Detects regressions with snapshots
- Runs the complete suite in < 10 seconds
Comparison: Module 1 vs Module 2
| Aspect | Module 1 | Module 2 |
|---|---|---|
| Focus | Configure pytest, mindset | Mocking, contracts, parsers |
| Tests | Smoke, basic contract | Complete contract, snapshot, parsers |
| Fixtures | Basic static | Factories, variations, edge cases |
| Execution time | < 30s | < 10s |
| API calls | Some (smoke) | 0 (pure unit tests) |
| Project | Test Suite Setup | Prompt Contract Tests |
Mental architecture: what you mock and what you test
User input
↓
[Formatter] ← test directly (deterministic)
↓
[Prompt builder] ← test directly (deterministic)
↓
[LLM call] ← MOCK HERE
↓
[Raw response] ← mocked
↓
[Parser] ← test directly (deterministic)
↓
[Validator] ← test directly (deterministic)
↓
[Output processor] ← test directly (deterministic)
↓
Final output
Rule of thumb: You mock the LLM. You test everything else directly. Most bugs are in the "everything else" — and that's where it's easiest to write tests.
The difference between a Contract Test and Evaluation
This confusion comes up frequently. Definitive clarification:
| Question | Contract Test | Evaluation Metric |
|---|---|---|
| What does it verify? | Structure and format | Semantic quality |
| Assertion example | "summary" in result | "The summary is coherent" |
| Tool | pytest, Pydantic | LLM-as-judge, ROUGE, BERTScore |
| When it fails | Malformed JSON, missing key | Nonsensical response |
| Speed | Milliseconds | Seconds (needs the LLM) |
| Determinism | Yes (with a mock) | No (variable LLM) |
| When to use it | Every commit | Pre-release, periodic |
Summary: A contract test verifies "does it have the correct form?". Evaluation verifies "is it good?". Both are necessary. This module covers contract tests; evaluation is another topic (Guide #12).
Module roadmap
| # | Session | Content | Type |
|---|---|---|---|
| 01 | Introduction | This session — mindset and architecture | Conceptual |
| 02 | Mocking responses | unittest.mock, pytest-mock, realistic mocks | Technical |
| 03 | Prompt contract tests | Define and validate contracts with pytest + Pydantic | Technical |
| 04 | Snapshot testing | Detect prompt regressions automatically | Technical |
| 05 | Parsers and output processors | Isolated testing of deterministic logic | Technical |
| 06 | Fixture factories | Parametrized variations of mocks | Technical |
| 07 | Prompt Contract Tests project | Hands-on: build the complete suite | Project |
| 08 | Summary and troubleshooting | Wrap-up, common errors, transition to M3 | Wrap-up |
Setup: what do you need from Module 1?
Before starting, verify that you have from Module 1:
# 1. Project structure
your-project/
├── src/
│ └── app/
│ ├── __init__.py
│ ├── config.py
│ ├── parsers.py
│ ├── sentiment.py
│ └── main.py
├── tests/
│ ├── conftest.py
│ ├── smoke/
│ └── unit/
├── pytest.ini
└── requirements.txt
# 2. pytest works
pytest --collect-only # must show tests without error
# 3. Markers configured
pytest -m smoke # must run smoke tests
pytest -m unit # must run unit tests
If you have that, you're ready for Module 2. If not, go back to Module 1 and set up the base.
A note about the reference app
In Module 1 you built (or received) a sentiment analysis app. This is the base we'll use in Module 2 to build the prompt contract tests.
The app does:
- Receives a text
- Calls an LLM with a sentiment analysis prompt
- Parses the LLM's JSON output
- Returns
{sentiment: str, score: float, explanation: str}
In Module 2 you will:
- Mock the LLM call
- Define the sentiment prompt's contract
- Test the parser in isolation
- Create a snapshot of the expected output
- Use fixture factories to test edge cases
Exercises
Exercise 1: Identify your prompt's contract
Take a prompt you use at work or in a project. Write the contract in prose (not in code yet):
- What keys must the output have?
- What types are each value?
- What are the constraints (ranges, lengths, allowed values)?
- What should happen if the input is unusual (empty, very long, a different language)?
See evaluation guide
A good contract has:
- Structure: "The output is a JSON / a list / a structured string"
- Required vs optional keys: "summary is required, sources is optional"
- Types: "confidence is a float, tags is a list of strings"
- Constraints: "summary has a maximum of 200 characters", "confidence is between 0 and 1"
- Edge cases: "If the input is empty, summary is an empty string, confidence is 0.0"
If your contract has all that, it's already at 80% quality. The remaining 20% are error cases you'll discover as you test.
Exercise 2: Identify the deterministic 70%
For the following app, identify which parts are deterministic (mockable with a mock) and which are the LLM:
def classify_ticket(ticket_text: str) -> dict:
# 1. Normalize the text
clean_text = ticket_text.strip().lower()[:2000]
# 2. Build the prompt
prompt = f"Classify this ticket: {clean_text}\nCategory: urgent/normal/low"
# 3. Call the LLM
raw_response = call_llm(prompt)
# 4. Parse the output
category = parse_category(raw_response)
# 5. Validate and structure
return {
"category": category,
"original_length": len(ticket_text),
"truncated": len(ticket_text) > 2000
}
See solution
Deterministic (test directly):
- Step 1: Text normalization →
assert clean_text == expected - Step 2: Prompt construction → verify that the prompt contains the clean text
- Step 4:
parse_category→ test with known input strings - Step 5: Dict construction → test with the mocked parser output
Non-deterministic (mock):
- Step 3:
call_llm→ mock that returns a fixed response
For the unit test:
def test_classify_ticket(mock_llm):
# mock_llm always returns "urgent"
result = classify_ticket("System down in production")
assert result["category"] in ["urgent", "normal", "low"]
assert isinstance(result["original_length"], int)
assert isinstance(result["truncated"], bool)
Exercise 3: Contract vs Evaluation
For each assertion, indicate whether it's a contract test or an evaluation metric:
assert "sentiment" in resultassert result["score"] >= 0 and result["score"] <= 1assert result["explanation"].startswith("The text")- Verify that the summary captures the key points of the original
assert isinstance(result["tags"], list)- Verify that the sentiment classification is correct for negative texts
See solution
- ✅ Contract — verifies that the key exists
- ✅ Contract — verifies a numeric range (constraint)
- ⚠️ Fragile contract — verifies a specific prefix; this is contractual but very fragile. Better:
assert isinstance(result["explanation"], str) and len(result["explanation"]) > 0 - ❌ Evaluation — "captures the key points" requires semantic judgment
- ✅ Contract — verifies the data type
- ❌ Evaluation — "correct for negative texts" requires ground truth + judgment
Pattern: If the assertion uses isinstance, in, len, >=, <= over structure → contract. If the assertion requires semantic comparison or a quality judgment → evaluation.
Exercise 4: Design the mock
For this function, design the mock you'd use for unit testing:
def analyze_sentiment(text: str, client: openai.OpenAI) -> dict:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Analyze the sentiment."},
{"role": "user", "content": text}
],
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
return parse_sentiment(raw)
What structure must the mock have? What should choices[0].message.content return?
See solution
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_openai_client():
client = MagicMock()
# Structure that exactly replicates the OpenAI API
mock_choice = MagicMock()
mock_choice.message.content = '{"sentiment": "positive", "score": 0.85, "explanation": "The text expresses satisfaction."}'
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.prompt_tokens = 50
mock_response.usage.completion_tokens = 30
mock_response.usage.total_tokens = 80
mock_response.model = "gpt-4o-mini"
mock_response.id = "chatcmpl-test-id"
client.chat.completions.create.return_value = mock_response
return client
def test_analyze_sentiment(mock_openai_client):
result = analyze_sentiment("The product is excellent", mock_openai_client)
assert result["sentiment"] == "positive"
assert result["score"] == 0.85
assert isinstance(result["explanation"], str)
Key: The mock's content must be valid JSON that the parser can process — not a Python object, but the real JSON string the LLM would return.
Exercise 5: When to use mocks vs a real LLM?
Classify each situation: mock or real LLM?
| Situation | Mock or real? |
|---|---|
| Test that the parser handles well-formed JSON | ? |
| Verify that the Spanish prompt works correctly | ? |
| Run tests on every commit (CI) | ? |
| Verify that the output has the "summary" key | ? |
| Validate that the LLM doesn't degrade its quality after a model update | ? |
| Test that the app handles a 429 error (rate limit) correctly | ? |
See solution
| Situation | Answer |
|---|---|
| Test that the parser handles well-formed JSON | Mock (deterministic, doesn't need the LLM) |
| Verify that the Spanish prompt works correctly | Real LLM (semantic quality evaluation) |
| Run tests on every commit (CI) | Mock (speed, cost, determinism) |
| Verify that the output has the "summary" key | Mock (pure contract test) |
| Validate that the LLM doesn't degrade its quality | Real LLM (quality regression testing) |
| Test that the app handles a 429 error | Mock (simulate the error with side_effect) |
Rule: Mock for structure/logic/errors. Real for semantic quality.
Summary
- Prompts as contracts: structure, constraints, format — all testable
- Mocking eliminates non-determinism in unit tests: fast, free, deterministic
- 70% of your code is deterministic — test it directly, without a mock
- Contract test ≠ Evaluation: one verifies form, the other verifies quality
- Most of your suite should use mocks; integration tests with a real LLM are a strategic complement
- Parsers and processors are the easiest, highest-ROI logic to test
Additional resources
- unittest.mock — Official Python documentation — The foundation of mocking in Python
- pytest-mock — pytest plugin for more ergonomic mocking
- Pact — Contract Testing — Contract concepts (more oriented to microservices, but useful for understanding the philosophy)
- OpenAI API Reference — Real structure of the responses for creating realistic mocks
- Property-based testing with Hypothesis — Complementary technique (covered in M3)
- Module 1: Testing Fundamentals — Prerequisite
- Software Testing Anti-patterns — Common mistakes to avoid