Testing AI Applications
How to test non-deterministic AI code in Spring Boot: mocking the ChatModel for unit tests, golden datasets for retrieval, property-based assertions, and LLM-as-judge for quality.
On this page
Non-determinism makes people think AI code is untestable. It is not — you just test two different things two different ways. Your application logic is fully deterministic and should be tested like any code. The model's behaviour is probabilistic and needs property-based, sparingly-run evaluation.
Key Takeaways
- Mock the
ChatModelto test your logic — prompt building, parsing, error handling — for free and deterministically. - For model behaviour, assert on properties (contains, schema-valid, one-of), never exact equality.
- Use a golden dataset for retrieval, which is deterministic and can be asserted precisely.
- Run real-model tests sparingly — scheduled or pre-release, not on every commit.
Two kinds of tests
Mocking the model for unit tests
Most of your code is logic around the model call. Mock the model and test all of it deterministically:
@ExtendWith(MockitoExtension.class)
class ChatServiceTest {
@Mock ChatModel chatModel;
@Test
void extractsAnswerAndRecordsTokens() {
// Canned response — no network, no cost, fully repeatable.
var response = new ChatResponse(List.of(
new Generation(new AssistantMessage("The answer is 42."))));
when(chatModel.call(any(Prompt.class))).thenReturn(response);
var service = new ChatService(ChatClient.builder(chatModel).build());
String result = service.ask("What is the answer?");
assertThat(result).isEqualTo("The answer is 42.");
}
@Test
void handlesModelFailureGracefully() {
when(chatModel.call(any(Prompt.class)))
.thenThrow(new RuntimeException("provider down"));
var service = new ChatService(ChatClient.builder(chatModel).build());
// The point of this test: your error handling, not the model.
assertThat(service.askSafely("q")).isEqualTo("Sorry, please try again.");
}
}This catches the bugs that actually bite in production — a parsing error, an unhandled exception, a wrong prompt variable — none of which need a real model to find.
Property assertions for real model behaviour
When you do call a real model, never assert exact strings. Assert properties that a correct answer has:
@SpringBootTest
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class ClassificationEvalTest {
@Autowired ClassificationService service;
@ParameterizedTest
@CsvSource({
"I want a refund, BILLING",
"The app keeps crashing, TECHNICAL",
"Can I get a demo, SALES",
})
void classifiesIntentCorrectly(String message, Intent expected) {
// Enum output at temperature 0 is deterministic enough to assert on.
assertThat(service.classify(message)).isEqualTo(expected);
}
@Test
void answerMentionsTheKeyFact() {
String answer = service.ask("What is the capital of France?");
// Property, not exact match: the answer contains the fact.
assertThat(answer).containsIgnoringCase("Paris");
}
}Golden datasets for retrieval
Retrieval is deterministic given a fixed corpus, so it can be asserted precisely — and it is the component whose quality caps your whole RAG system.
@SpringBootTest
class RetrievalEvalTest {
@Autowired VectorStore vectorStore;
@ParameterizedTest
@CsvSource({
"How do I reset my password?, auth-guide",
"What is the refund window?, billing-policy",
})
void retrievesTheExpectedSource(String question, String expectedSourceId) {
var hits = vectorStore.similaritySearch(SearchRequest.builder()
.query(question).topK(5).similarityThreshold(0.7).build());
// Is the correct passage in the top 5? This number is the ceiling on
// your whole system's accuracy.
assertThat(hits).anyMatch(d -> expectedSourceId.equals(d.getMetadata().get("sourceId")));
}
}Track the pass rate over time. When it drops, retrieval regressed — and no amount of prompt work will recover accuracy that retrieval lost.
LLM-as-judge for quality
Some qualities cannot be expressed as contains — faithfulness, relevance, completeness. Use a model
to judge them, accepting that the judge is imperfect:
@Component
public class FaithfulnessJudge {
private final ChatClient judge;
public FaithfulnessJudge(ChatClient.Builder builder) {
this.judge = builder.build();
}
public boolean isFaithful(String answer, String context) {
// A separate model call assesses whether the answer follows from the
// context. Use a capable model as the judge, and temperature 0.
String verdict = judge.prompt()
.user(u -> u.text("""
Does the ANSWER follow only from the CONTEXT?
Reply with exactly YES or NO.
CONTEXT: {context}
ANSWER: {answer}
""").param("context", context).param("answer", answer))
.options(ChatOptions.builder().temperature(0.0).build())
.call().content();
return verdict.trim().toUpperCase().startsWith("YES");
}
}Structuring the test suite
| Layer | What it tests | Model | Run when |
|---|---|---|---|
| Unit | Logic, parsing, errors | Mocked | Every commit |
| Retrieval eval | Golden-dataset hit rate | None (deterministic) | Every commit |
| Behaviour eval | Property assertions | Real, cheap model | Scheduled / pre-release |
| Quality eval | Faithfulness, relevance | Real + judge | Pre-release |
Only the first two run on every commit — they are fast, free and deterministic. The model-calling tests run on a schedule or before releases, guarded to skip without an API key.
Next
You have completed Phase 1. You can build, secure, observe and test AI features in Spring Boot.
- LangChain4j introduction & architecture — Phase 2 begins
- Agent evaluation & testing — testing agents specifically
Frequently Asked Questions
How do you test code that calls a non-deterministic LLM?
How do I mock the model in a Spring AI test?
What is LLM-as-judge testing?
Should AI integration tests run in CI on every commit?
Related tutorials
- Security in AI-Powered Spring ApplicationsSecure a Spring Boot AI application against the OWASP LLM Top 10: prompt injection defenses, output validation, rate limiting, PII handling and safe tool authorization — with code.
- Spring AI Observability & MonitoringInstrument Spring AI with Micrometer and OpenTelemetry: token and cost metrics per feature, latency tracking, tracing model calls, and dashboards that catch a cost problem before the invoice does.
- Spring AI with Ollama (Local LLMs)Run local LLMs in Spring Boot with Spring AI and Ollama: setup, model selection, offline development, cost and privacy trade-offs, and when a local model is the right call.
- Multimodal AI with Spring BootSend images and audio to vision models from Spring Boot with Spring AI: the Media API, image analysis, document extraction from scans, and handling multimodal input safely.