Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced4 min readUpdated
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 ChatModel to 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

Split AI testing: mock the model for logic, use property assertions for behaviour.

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:

ChatServiceTest.java
@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:

ClassificationEvalTest.java
@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.

RetrievalEvalTest.java
@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:

FaithfulnessJudge.java
@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

LayerWhat it testsModelRun when
UnitLogic, parsing, errorsMockedEvery commit
Retrieval evalGolden-dataset hit rateNone (deterministic)Every commit
Behaviour evalProperty assertionsReal, cheap modelScheduled / pre-release
Quality evalFaithfulness, relevanceReal + judgePre-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.

Frequently Asked Questions

How do you test code that calls a non-deterministic LLM?
Split it into two problems. For your application logic — routing, parsing, error handling — mock the ChatModel so the test is deterministic and free. For the AI behaviour itself, use a small fixed set of inputs and assert on properties (contains, matches a schema, is one of a set of valid answers) rather than exact string equality, and run these sparingly because they cost money and time.
How do I mock the model in a Spring AI test?
Mock the ChatModel bean with Mockito and have it return a canned ChatResponse. Because your service depends on the abstraction, the mock lets you test everything around the model call — how you build the prompt, parse the response and handle errors — with no network and no cost.
What is LLM-as-judge testing?
Using a second model call to evaluate the output of the first — for example asking a model whether an answer is faithful to the provided context, or whether it is relevant and complete. It is imperfect and should not be your only check, but it scales quality evaluation to cases that exact-match assertions cannot express.
Should AI integration tests run in CI on every commit?
Generally no. They cost money, are slow, and can be flaky due to model non-determinism. Run mocked unit tests on every commit, and run the small suite of real-model evaluation tests on a schedule or before releases, guarded so they skip when no API key is present.

Related tutorials