Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). A model is slow, costs money, needs credentials or
a gigabyte-sized download, and gives a slightly different answer each time. A test suite that depends on one of those is one nobody runs. The way out is
to test each layer of DocsAssistant with the cheapest stand-in that still exercises the code you wrote, and to reserve the real model for a
small, separate group of tests. This page builds that pyramid: a scripted ChatModel, WireMock in place of the provider’s HTTP API, Spring Boot tests on top
of the stub, slice tests for tools and prompts, Testcontainers Ollama for the live tests, and the settings that make a live model as repeatable as it can be. All the classes
were compiled against the 2.0.1 jars and run, with two exceptions that are stated where they occur: the llama3.1:8b live test, and CI wiring.
| Layer | Proves | Does not prove |
|---|---|---|
Scripted |
Your advisors, tools, memory and parsing behave for a given model reply |
That the provider’s API accepts what you send, or that a real model replies that way |
WireMock in place of the provider |
The provider client, options mapping and JSON on the wire are what you expect, and error responses are handled |
The quality of an answer |
Testcontainers Ollama |
The whole stack works against a real model server, with a real (small) model |
Behaviour of the hosted models you run in production |
Evaluators on top |
An answer means the right thing, judged by another model |
Anything, if the judge is weak (see Evaluation) |
A scripted ChatModel
ChatModel has one abstract method, call(Prompt), so a lambda is a working stub (the advisors page uses one for the canary-word test). For anything with a sequence, such as a
tool call followed by an answer, a small class that plays back a script and remembers every prompt it received is more useful:
package com.example.docsassistant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
/** A stub ChatModel that plays back a script and remembers every prompt it received. */
final class ScriptedChatModel implements ChatModel {
private final Deque<ChatResponse> script = new ArrayDeque<>();
private final boolean toolCapable;
final List<Prompt> prompts = new CopyOnWriteArrayList<>();
ScriptedChatModel(boolean toolCapable) {
this.toolCapable = toolCapable;
}
ScriptedChatModel then(ChatResponse response) {
this.script.add(response);
return this;
}
static ChatResponse text(String text) {
return new ChatResponse(List.of(new Generation(new AssistantMessage(text))));
}
static ChatResponse toolCall(String id, String toolName, String jsonArguments) {
AssistantMessage message = AssistantMessage.builder()
.toolCalls(List.of(new AssistantMessage.ToolCall(id, "function", toolName, jsonArguments)))
.build();
return new ChatResponse(List.of(new Generation(message)));
}
@Override
public ChatResponse call(Prompt prompt) {
this.prompts.add(prompt);
ChatResponse next = this.script.poll();
if (next == null) {
throw new IllegalStateException("the script is exhausted after " + this.prompts.size() + " calls");
}
return next;
}
/**
* ToolCallingAdvisor only runs the tool loop when the model's default options are ToolCallingChatOptions.
* A stub that keeps the ChatModel default (plain options) silently skips every tool call.
*/
@Override
public ChatOptions getOptions() {
return this.toolCapable ? ToolCallingChatOptions.builder().build() : ChatModel.super.getOptions();
}
}
Source: Spring AI: Chat Model API.
The getOptions() override is the trap. In 2.0 the tool loop lives in ToolCallingAdvisor, which acts only when the model’s default options are ToolCallingChatOptions.
A stub that keeps the interface default returns plain ChatOptions, so the advisor never sees a tool-capable model: no tool runs, the stub is called once, and
content() returns null. Nothing fails loudly. The next test class shows both sides.
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
class ToolLoopTest {
static class BuildTools {
int calls;
@Tool(description = "Return the build id of a documentation component")
String buildId(@ToolParam(description = "component name") String component) {
this.calls++;
return component + "-2026-09-29";
}
}
private ScriptedChatModel scripted(boolean toolCapable) {
return new ScriptedChatModel(toolCapable)
.then(ScriptedChatModel.toolCall("call-1", "buildId", "{\"component\":\"irurueta-algebra\"}"))
.then(ScriptedChatModel.text("The build is irurueta-algebra-2026-09-29."));
}
@Test
void theModelAsksForTheToolAndThenAnswersFromItsResult() {
ScriptedChatModel model = scripted(true);
BuildTools tools = new BuildTools();
String answer = ChatClient.create(model).prompt().user("Which build is irurueta-algebra?")
.tools(tools).call().content();
assertThat(answer).contains("2026-09-29");
assertThat(tools.calls).isEqualTo(1);
// the second prompt carries the tool result back to the model
var second = model.prompts.get(1).getInstructions();
assertThat(second.get(second.size() - 1)).isInstanceOf(ToolResponseMessage.class);
assertThat(((ToolResponseMessage) second.get(second.size() - 1)).getResponses().get(0).responseData())
.contains("irurueta-algebra-2026-09-29");
}
@Test
void aStubWithPlainOptionsNeverRunsTheTool() {
BuildTools tools = new BuildTools();
ScriptedChatModel model = scripted(false);
String answer = ChatClient.create(model).prompt().user("Which build is irurueta-algebra?")
.tools(tools).call().content();
assertThat(tools.calls).isZero();
assertThat(model.prompts).hasSize(1);
System.out.println("PLAIN-OPTIONS answer=[" + answer + "]");
}
}
Source: Spring AI: Tool Calling.
Both tests passed. In the first, the second prompt the model received ended with a ToolResponseMessage holding the tool’s return value, which is the assertion to
reach for when you want to prove that the result was sent back. In the second, the tool ran zero times and the answer was null. For streaming stubs and StepVerifier, see
the tests on Streaming & Reactive; for an advisor tested against a stub model, see
Advisors.
Slice tests: tools, prompts and advisors
A tool is a plain method with annotations. Test the pieces separately: the definition the model will see, and the call the framework will make.
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
class ToolSliceTest {
private final ToolCallback buildId = ToolCallbacks.from(new ToolLoopTest.BuildTools())[0];
@Test
void theModelSeesTheNameDescriptionAndSchemaYouIntended() {
var definition = this.buildId.getToolDefinition();
assertThat(definition.name()).isEqualTo("buildId");
assertThat(definition.description()).contains("build id");
assertThat(definition.inputSchema()).contains("\"component\"").contains("component name");
}
@Test
void callingTheCallbackWithModelStyleJsonRunsTheMethod() {
assertThat(this.buildId.call("{\"component\":\"irurueta-algebra\"}")).contains("irurueta-algebra-2026-09-29");
}
}
Source: Spring AI: Tool Calling.
The first test guards against a rename or a lost @ToolParam description changing what the model is told. The second runs the callback with the JSON a model would send. Add a case
for every error path you rely on; how exceptions become tool results is on Tool Calling. A tool guarded by @PreAuthorize needs a Spring context to get its
proxy, and its test is on Security & Guardrails.
A prompt template is a string with placeholders and deserves a test that involves no model at all:
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.PromptTemplate;
/** A prompt is a string: test the rendering with no model at all. */
class PromptTemplateTest {
private static final String TEMPLATE = """
Answer the question using only the passages below.
Question: {question}
Passages:
{passages}""";
@Test
void everyPlaceholderIsFilled() {
String rendered = new PromptTemplate(TEMPLATE).render(Map.of(
"question", "How do I invert a matrix?",
"passages", "MatrixInverse uses LU decomposition."));
assertThat(rendered).contains("Question: How do I invert a matrix?")
.contains("MatrixInverse uses LU decomposition.")
.doesNotContain("{question}", "{passages}");
}
@Test
void aMissingVariableFailsInsteadOfSendingHalfAPrompt() {
assertThatThrownBy(() -> new PromptTemplate(TEMPLATE).render(Map.of("question", "q")))
.isInstanceOf(IllegalStateException.class);
}
}
Source: Spring AI: Prompts.
The second test passed because a missing variable makes render throw IllegalStateException rather than send a prompt with {passages} left in it, so the failure appears in the test and not in the model’s confused answer.
Advisors are tested the way the canary-word advisor is on the advisors page: build ChatClient.builder(stub).defaultAdvisors(advisor), call it, and assert on the response and on stub.prompts, the prompts that reached the model.
Memory is tested with MessageWindowChatMemory in a test, as in the isolation test of Chat Memory.
WireMock in place of the provider
A scripted ChatModel skips the provider client. If you want to test that layer too, the request mapping, the options, retries and error handling, run the real model class against
WireMock. Every provider is an HTTP API, and OllamaChatModel takes a base URL. The dependency is the standalone artifact, which shades its own Jetty and Jackson and so does not clash with Spring Boot’s:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-ollama</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
WireMock 3.13.2 ran on Spring Boot 4.1.1 (Jetty 12 in the test JVM) in every test of these pages. The version is one chosen at the time of writing; check the current release. The Testcontainers
2.x artifacts carry a testcontainers- prefix, unlike the 1.x names in older articles. For the stub replies, the helper from Observability answers /api/chat with Ollama’s JSON, including the
token counts, and can match on the model name in the request. The tool-call flow needs two different replies to the same URL, which WireMock does with scenarios: the first request moves the scenario to a
new state, and the second is answered from that state. ToolObservationTest on the observability page uses exactly this.
Providers other than Ollama work the same way. Point spring.ai.openai.base-url (or the equivalent for another provider) at WireMock and stub the paths that provider uses. That route was not run
for this page; the Ollama route was.
Spring Boot tests on the stub
A @SpringBootTest can run the real auto-configuration against the stub server, which tests wiring, properties and the endpoint together. The provider URL is not known until WireMock starts, so it is
set with @DynamicPropertySource. The context should not start everything the application has (pgvector, JDBC memory, MCP), so the test names a small application class and excludes the infrastructure
auto-configurations:
package com.example.docsassistant;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = ChatEndpointTest.App.class, properties = {
"spring.ai.model.chat=ollama",
"spring.ai.model.embedding=none",
"spring.ai.model.moderation=none",
"spring.ai.model.image=none",
"spring.ai.model.audio.speech=none",
"spring.ai.model.audio.transcription=none",
"spring.ai.ollama.init.pull-model-strategy=never",
"spring.ai.ollama.chat.model=llama3.1:8b",
"spring.ai.ollama.chat.temperature=0.0",
"spring.ai.ollama.chat.seed=42",
"spring.security.user.name=ana",
"spring.security.user.password=pw"
})
@AutoConfigureMockMvc
class ChatEndpointTest {
@EnableAutoConfiguration(excludeName = {
"org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration",
"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration",
"org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryAutoConfiguration"
})
@Import({ DocsAssistantController.class, ChatClientConfig.class })
static class App {
}
static WireMockServer ollama;
@BeforeAll
static void start() {
ollama = new WireMockServer(WireMockConfiguration.options().dynamicPort());
ollama.start();
OllamaStubs.chatReply(ollama, "Use MatrixInverse.", 40, 6);
}
@AfterAll
static void stop() {
ollama.stop();
}
@DynamicPropertySource
static void ollamaUrl(DynamicPropertyRegistry registry) {
registry.add("spring.ai.ollama.base-url", () -> ollama.baseUrl());
}
@Autowired
MockMvc mvc;
@Test
void chatEndpointAnswersAndSendsDeterministicOptions() throws Exception {
this.mvc.perform(post("/api/chat").with(httpBasic("ana", "pw")).with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("{\"question\":\"How do I invert a matrix?\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.answer").value("Use MatrixInverse."));
// assert on what the application sent, not only on what it returned
ollama.verify(postRequestedFor(urlEqualTo("/api/chat"))
.withRequestBody(matchingJsonPath("$.model", equalTo("llama3.1:8b")))
.withRequestBody(matchingJsonPath("$.options.temperature", equalTo("0.0")))
.withRequestBody(matchingJsonPath("$.options.seed", equalTo("42")))
.withRequestBody(matchingJsonPath("$.messages[0].role", equalTo("system")))
.withRequestBody(matchingJsonPath("$.messages[1].content", equalTo("How do I invert a matrix?"))));
}
}
The test passed. App imports ChatClientConfig as well as the controller, because auto-configuration registers only a ChatClient.Builder: the controller’s docsChatClient bean, and the default system message the test asserts on, come from that configuration. The last block is the point: ollama.verify(…) asserts on what the application sent (the model, the temperature and seed from spring.ai.ollama.chat.*, the system message first and the user’s question second),
which a test on the returned text alone cannot see. Three gotchas surfaced while writing it. With Spring Security on the classpath, a POST without a CSRF token got 403 and a request without credentials got 401, which is why the test sends httpBasic and
csrf(). Boot 4’s test annotations moved into per-technology modules (org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc, from spring-boot-starter-webmvc-test; AutoConfigureMetrics from spring-boot-starter-micrometer-metrics-test).
And spring.ai.model.embedding=none and its siblings switch off the providers that would otherwise demand credentials at start-up; if you test with several starters on the classpath, every model family needs a value.
Testcontainers Ollama with @ServiceConnection
The live tests need a real model, and Testcontainers provides one without a manual install. @ServiceConnection on an OllamaContainer field lets Spring Boot read the container’s URL and configure spring.ai.ollama.base-url for you; the connection-details factory comes
from spring-ai-spring-boot-testcontainers, which supports Ollama and several vector stores. The container starts empty, so the test asks Spring AI to pull the model on start-up through the connection it was given:
package com.example.docsassistant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.evaluation.FactCheckingEvaluator;
import org.springframework.ai.chat.evaluation.RelevancyEvaluator;
import org.springframework.ai.document.Document;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.ollama.OllamaContainer;
import org.testcontainers.utility.DockerImageName;
/** Runs against a real model in a container. Excluded from the default build by its tag. */
@Tag("live")
@Testcontainers
@SpringBootTest(classes = OllamaLiveTest.App.class, properties = {
"spring.ai.model.chat=ollama",
"spring.ai.model.embedding=none",
"spring.ai.model.moderation=none",
"spring.ai.model.image=none",
"spring.ai.model.audio.speech=none",
"spring.ai.model.audio.transcription=none",
"spring.ai.ollama.chat.model=llama3.1:8b",
"spring.ai.ollama.chat.temperature=0.0",
"spring.ai.ollama.chat.seed=42",
// the container starts empty: let Spring AI pull the model through the connection details it was given
"spring.ai.ollama.init.pull-model-strategy=when_missing",
"spring.ai.ollama.init.timeout=30m"
})
class OllamaLiveTest {
@EnableAutoConfiguration(excludeName = {
"org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration",
"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration",
"org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryAutoConfiguration"
})
static class App {
}
@Container
@ServiceConnection
static OllamaContainer ollama = new OllamaContainer(DockerImageName.parse("ollama/ollama:0.34.4"));
@Autowired
ChatClient.Builder builder;
@Test
void theModelAnswersAndTheJudgeAgrees() {
String question = "In one sentence: what is the capital of France?";
String answer = this.builder.build().prompt().user(question).call().content();
var context = List.of(new Document("Paris is the capital and largest city of France."));
assertThat(answer).containsIgnoringCase("Paris");
// assert on meaning with an evaluator, not on the exact wording of a non-deterministic answer
assertThat(RelevancyEvaluator.builder().chatClientBuilder(this.builder).build()
.evaluate(new EvaluationRequest(question, context, answer)).isPass()).isTrue();
assertThat(FactCheckingEvaluator.builder(this.builder).build()
.evaluate(new EvaluationRequest(question, context, answer)).isPass()).isTrue();
}
}
Source: Spring AI: Testcontainers and Testcontainers: Ollama module.
What was run: an identical test class with smollm2:135m in place of llama3.1:8b and a trivial assertion. It started the pinned ollama/ollama:0.34.4 container, injected the connection details, pulled the model with when_missing
(about ten seconds for that small model), and got an answer back, about fifteen seconds in total. What was not run: the class above with llama3.1:8b, which is a download of several gigabytes and whose relevancy and
fact-check assertions depend on a judge that is good enough (the tiny model was not; see the evaluation page). Plan for the size: pin the image tag, cache the Docker layers and the Ollama model directory in CI, or
build a derived image once with OllamaContainer.commitToImage(…) so every run starts with the model already inside; the commit method exists in the Testcontainers 2.0.5 API but was not exercised here.
Tag such tests so the default build skips them. JUnit’s tag plus a Maven property does it, and a profile turns them back on:
<properties>
<excludedTestGroups>live</excludedTestGroups>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludedGroups>${excludedTestGroups}</excludedGroups>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>live</id>
<properties>
<excludedTestGroups>none</excludedTestGroups>
</properties>
</profile>
</profiles>
Source: JUnit: Tag expressions.
With this in place mvn test ran the 33 other tests of this scratch project and reported zero for OllamaLiveTest; -Plive sets the property to none. The Testcontainers basics, including the pgvector container
that the vector store tests use, are on Spring Boot Integration Tests with Testcontainers, and the RAG-specific version of this section is
Testing: Testcontainers and RAG evaluators.
Evaluator-based assertions
The exact words of a live answer change from run to run, so the live test asserts on meaning. It checks a fact that must appear (containsIgnoringCase("Paris")) and then lets RelevancyEvaluator and FactCheckingEvaluator
judge the answer against a context, as above. Use the cheap deterministic assertion for anything you can (a required word, a JSON shape, a citation), and the model-based judge only for what a rule cannot express. Write those
assertions once as helpers in the test source tree so a failing judge prints the question, the answer and the evaluator’s feedback. The evaluators, their limits, and how to measure how much a suite proves are on
Evaluation.
Deterministic settings
Set the options that reduce variation, and check that they arrive. In DocsAssistant’s test properties:
spring.ai.ollama.chat.temperature=0.0
spring.ai.ollama.chat.seed=42
Source: Spring AI: Ollama Chat.
ChatEndpointTest proved that both reached the request body as options.temperature and options.seed. Treat them as reducing, not removing, variation. A fixed seed and zero temperature give the same output on the same model file,
server version and hardware, and often not across them; hosted providers document their own behaviour for these settings, and some models ignore or restrict them. So a live test should tolerate wording differences and only fail on
meaning, and a test that needs an exact string belongs in the scripted layer, where the reply is yours. Keep the answer short in tests (a low numPredict for a judge, an instruction to reply in one sentence) so runs are faster and drift less.
Testing other parts
| Part | Where |
|---|---|
Streaming, cancellation, |
|
MCP server and client |
MCP Server, MCP Client and, for the Inspector and protocol-level checks, Testing and Debugging MCP Servers |
Voice and images |
Audio Transcription & Speech and Image Generation, where the model interfaces are functional and a lambda is the fake |
Authorisation, moderation, injection |
|
Metrics and traces |
Observability, which asserts on a |
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
The tool never runs and the answer is |
The stub’s |
Return |
The context fails to start looking for a |
Auto-configuration of infrastructure or of a second model family that the test does not need |
Use a small application class with |
|
Spring Security is active |
|
The stub returns the same reply for every call |
One stub, no scenario |
Use WireMock scenarios or match on the request body |
A live test runs in every build |
No tag, or the exclusion is not configured |
|
The live test times out |
The model is still downloading |
Raise |
Where older material differs
| Older text | Spring AI 2.0.1 and Spring Boot 4 |
|---|---|
|
|
|
|
A stub |
Also override |
Evaluator constructors |
|