Testing AI-Dependent Code: Mocking LLMs, Evaluating Outputs, and Avoiding Flaky Tests

The test checked whether the extracted invoice total matched the expected value. It passed nine times out of ten. On the tenth run, the model formatted the number differently — "1,234.56" instead of "1234.56". Exact string equality, both technically correct, test failed.
Developers learned to re-run CI when it went red. Nobody fixed the test. Six months later the suite had twelve flaky tests. People had stopped trusting CI entirely — a genuinely broken change could slip through unnoticed because the red runs were noise.
Flaky AI tests aren't a testing framework problem. They're an assertion strategy problem.
Why AI Tests Are Different
Three things make testing AI-dependent code harder than testing regular service code:
Non-determinism. The same prompt with temperature > 0 produces different outputs on every call. Exact value assertions will fail intermittently. This isn't a bug — it's the model working as designed.
Cost and latency. Every real API call costs money and adds 5–30 seconds to your test suite. A test suite that calls OpenAI on every run is slow, expensive, and breaks when you're offline.
Output structure vs output value. For extraction and classification tasks, the structure of the output (valid JSON, correct field names, right data types) is deterministic even when the values aren't. Asserting on structure is reliable. Asserting on values often isn't.
Mock at the Client Level
The right place to mock LLM calls is at the client — not at the HTTP layer, not with unittest.mock.patch scattered across test files. A pytest fixture that replaces the client once, cleanly:
# tests/conftest.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from openai.types.chat import ChatCompletion, ChatCompletionMessage, Choice
from openai.types import CompletionUsage
def make_completion(content: str, model: str = "gpt-4o-mini") -> ChatCompletion:
"""Helper to build a realistic ChatCompletion response."""
return ChatCompletion(
id="test-completion-id",
choices=[Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(role="assistant", content=content),
)],
created=1700000000,
model=model,
object="chat.completion",
usage=CompletionUsage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
)
@pytest.fixture
def mock_openai(monkeypatch):
"""Replace the AsyncOpenAI client with a controllable mock."""
mock_client = AsyncMock()
monkeypatch.setattr("services.llm_client.client", mock_client)
return mock_client
Usage in tests:
# tests/integration/test_extraction.py
import pytest
import json
from services.extraction import extract_document
@pytest.mark.anyio
async def test_extraction_valid_output(mock_openai):
expected_output = {
"invoice_number": "INV-001",
"total_amount": 1234.56,
"vendor_name": "Acme Corp",
"date": "2025-09-01",
"line_items": []
}
mock_openai.chat.completions.create.return_value = make_completion(
json.dumps(expected_output)
)
result = await extract_document("test document text")
assert result.invoice_number == "INV-001"
assert result.total_amount == 1234.56
mock_openai.chat.completions.create.assert_called_once()
Zero API calls. Deterministic. Runs in milliseconds. The fixture controls exactly what the model returns, so you can test every code path — including the ones where the model returns malformed output.
Testing Structured Output — Schema, Not Value
For extraction tasks, the most important assertion is that the output matches the expected schema — not that it contains specific values. Schema validity is deterministic even when output values aren't:
# tests/integration/test_extraction.py
@pytest.mark.anyio
async def test_extraction_schema_validity(mock_openai):
# Simulate a realistic but variable model output
mock_openai.chat.completions.create.return_value = make_completion(
'{"invoice_number": "INV-002", "total_amount": 500.0, "vendor_name": "Vendor", "date": "2025-09-02", "line_items": []}'
)
result = await extract_document("any document")
# Assert schema — not specific values
assert isinstance(result.invoice_number, str)
assert len(result.invoice_number) > 0
assert isinstance(result.total_amount, float)
assert result.total_amount > 0
assert isinstance(result.line_items, list)
For Pydantic models, the validation itself is the schema test — if extract_document returns an ExtractionResult without raising, the schema is valid. Your assertions can focus on business logic rather than type checking.
Snapshot Testing for Prompts
Prompts change — during development, after model updates, when requirements shift. Unexpected prompt changes can silently degrade output quality. Snapshot tests catch them:
# tests/unit/test_prompts.py
from prompts.store import get_prompt
import json
def test_extraction_prompt_unchanged(snapshot):
prompt_config = get_prompt("document_extraction", version="v2")
# Snapshot saves the first run, asserts equality on subsequent runs
assert snapshot == json.dumps(prompt_config, sort_keys=True, indent=2)
Use pytest-snapshot for this. On the first run, it saves the output as a .txt file in a snapshots/ directory. On subsequent runs, it asserts equality. When a prompt legitimately changes, you update the snapshot intentionally — the change is visible in the PR diff.
This is how you catch "I just tweaked the wording" prompt changes that alter model behaviour without anyone realising.
What to Assert on Non-Deterministic Output
For classification tasks where the model returns one of a fixed set of labels, exact assertion is safe:
@pytest.mark.anyio
async def test_classification_returns_valid_label(mock_openai):
mock_openai.chat.completions.create.return_value = make_completion("positive")
result = await classify_sentiment("Great product!")
assert result in {"positive", "negative", "neutral", "unclear"}
For generation tasks where the output is free-form, assert on properties rather than values:
@pytest.mark.anyio
async def test_summary_reasonable_length(mock_openai):
mock_openai.chat.completions.create.return_value = make_completion(
"This is a summary of the document covering the main points."
)
result = await summarise_document("long document text...")
assert len(result) > 20 # not empty
assert len(result) < 2000 # not truncated mid-sentence
assert not result.startswith("```") # no markdown fences
The assertions test that your code handled the output correctly — stripped fences, checked length, returned a string — without asserting on content that would vary with each real model call.
Integration Tests That Call the Real API
Unit tests with mocks cover the code paths. Integration tests against the real API catch prompt regressions and model behaviour changes. Run them separately — not on every push.
# tests/integration/test_real_api.py
import pytest
@pytest.mark.real_api # custom marker — skipped unless explicitly enabled
@pytest.mark.anyio
async def test_extraction_real_api():
result = await extract_document(SAMPLE_INVOICE_TEXT)
assert result.total_amount > 0
assert len(result.invoice_number) > 0
# pytest.ini
[pytest]
markers =
real_api: marks tests that call the real LLM API (deselect with '-m "not real_api"')
# .github/workflows/test.yml
- name: Run tests (no real API)
run: pytest -m "not real_api"
# Run weekly or manually
- name: Run real API tests
if: github.event_name == 'schedule'
run: pytest -m real_api
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Weekly scheduled runs catch model behaviour drift. Manual runs before a prompt change goes to prod. Never in the default push pipeline.
The Rule
Mock the client, assert on schema not values, snapshot the prompts, gate real API tests behind a flag. The twelve flaky tests from the opening story all had the same fix: stop asserting on the exact string the model returned and start asserting on what your code did with it.
About the author
- Senior full-stack engineer and engineering lead with 5+ years building cloud-native systems at scale. I write about the things the docs don't cover — GCP, FastAPI, Terraform, event-driven architecture, and AI-powered backends. No hello-world demos.
Comments
Join the discussionThe invoice total example is exactly how my CI got ignored too. One formatting change from the model and suddenly everyone just reruns until green. Switching to schema checks plus a small normalizer for numbers and dates fixed most of it for me. The part I still struggle with is free text fields. Do you assert anything on those beyond length and required keywords?
The normaliser for numbers and dates is exactly the right move — one fix, whole category gone.
For free text: three things I actually assert on — required concept is present (loose, not keyword), length is within a sane range, and no model artefacts (leading "Sure!", markdown fences in plain-text fields). Beyond that I treat it as an evaluation problem, not a unit test problem. Weekly real API runs are where those quality regressions surface, not mocked tests.
Treating free text as an evaluation problem instead of a unit test makes sense. The artefact check is a good one, I hadn't thought about asserting on a leading "Sure!". Weekly real runs sounds like the right cadence to me.
Good breakdown of the tradeoffs. One middle ground worth mentioning: for the live-API integration tests, passing a fixed seed (where the provider supports it) together with temperature 0 gets you much closer to deterministic without going fully mocked. It's not a guarantee - the same seed can still drift across model versions - but it noticeably reduces the retry count. Pairs well with the normalization point in the comments.
Good addition — temperature=0 plus a fixed seed is a useful middle ground, especially for integration tests where you want some real-API coverage but less noise. The caveat you flagged is the right one: seed behaviour isn't guaranteed across model versions, so it's a noise reducer not a flakiness eliminator. Still worth doing. I'd pair it with snapshotting the model version in the test so a version bump is a visible signal rather than a silent source of drift.
The client-level fixture can also distinguish a truncated response from malformed JSON. Add a response with finish_reason set to length, then check that extraction stops before saving a partial result and that any retry is bounded. A valid-looking prefix should not cross the same persistence boundary as a complete response.
Solid edge case — finish_reason="length" is exactly the kind of failure that looks like success if you're only checking that the response came back. Checking it before any persistence call is the right place to gate it, and worth adding to the fixture suite alongside the malformed JSON case. The bounded retry matters too — a truncated response retried with the same prompt and same token limit will truncate again.
Yes, the unchanged token limit makes that retry a repeat of the same failure. I would make the length case return a clear incomplete-result state, then check that no save occurs and that the retry limit is reached. Increasing the allowance should be a separate explicit choice, rather than an endless retry.
The invoice example seems to call for normalized value comparison rather than removing the value assertion. '1,234.56' and '1234.56' can represent the same amount, but a schema-valid positive number could still be the wrong total, and the real-API test shown would accept it.
I would parse the amount under an explicit locale and currency contract, then compare it with the labelled invoice amount. Keep the client mocks for malformed output and application behavior, and keep a separate extraction-quality suite with known source documents. That preserves the useful deterministic test boundary while ensuring that eliminating formatting flakiness does not also eliminate the test's ability to detect an incorrect extraction.
You're right that schema validity and extraction correctness are different things, and the post conflates them. Asserting total_amount > 0 catches malformed output, not wrong output. The locale-aware comparison against a labelled expected value is the correct approach for testing extraction quality — it just belongs in the separate evaluation suite against known source documents, not the mocked unit tests. The mocked tests cover application behaviour; the evaluation suite covers whether the model extracted the right value. Both need to exist.
That separation also makes a deliberately wrong, schema-valid amount a useful control. A mocked response with the wrong total should exercise the application path without being mistaken for evidence that extraction quality is good. The labelled-document suite should then fail that same value under the locale and currency contract.
Keeping a small set of such negative controls alongside the formatting equivalents would demonstrate that the suite remains sensitive to semantic errors after normalization, rather than merely becoming quieter.
The schema-versus-value distinction is useful, but there’s an interesting gap between “valid output” and “correct output.” A response can satisfy the schema perfectly while extracting the wrong invoice total, misclassifying a document, or producing a summary that misses a critical fact. I’d treat those as separate testing layers: deterministic tests verify the contract and how the application handles the response, while a smaller set of evaluation cases checks semantic correctness against known expectations. That also makes flaky tests easier to avoid without weakening coverage. The goal isn't to make every assertion deterministic; it's to make each test responsible for a property that can actually be judged reliably.
This is the cleaner framing than what the post uses. Contract tests verify the application handles the response correctly — schema, error paths, retry bounds. Evaluation cases verify the model extracted the right thing. Conflating the two is what produces tests that are either flaky or meaningless. "Make each test responsible for a property that can actually be judged reliably" is the principle the post should have opened with.
This is a really practical take on something that can get confusing very quickly with AI-powered applications.
The part about flaky tests being an “assertion strategy problem” really stood out to me. It’s tempting to test an LLM response the same way we would test a traditional function, but expecting the exact same wording from a model is setting the test up to fail.
I also liked the distinction between testing our application logic with mocks and testing the actual model behaviour separately. That feels like a much healthier approach than having the entire test suite depend on an external API being available and returning exactly what we expect.
The idea of asserting on structure and behaviour rather than exact generated text is probably something a lot of teams will learn the hard way. Great article, and very useful for anyone trying to move LLM features from a demo into production.
Thanks — "assertion strategy problem" is the reframe that made the rest of the testing approach click for us too. The demo-to-production gap on AI features is real and mostly comes down to exactly this: the habits that work for deterministic code silently fail on probabilistic output. Glad it was useful.
AI-Powered Backend Services
LLM APIs don't fail the way REST APIs do. A 200 response with a well-formed body can still contain output you can't use, costs you didn't expect, or behaviour that changed silently after a model update. This series covers building AI-powered backend services that hold up in production — prompt management, output validation, async pipelines, cost control, testing non-deterministic code, and deployment patterns for variable-latency workloads. Written from real experience integrating OpenAI and Gemini into production FastAPI services on GCP.





