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.

flowchart TB L["Live: Testcontainers Ollama, evaluator assertions (few, slow, tagged live)"] S["Spring Boot: real model class + WireMock, MockMvc (some, seconds)"] U["Unit and slice: scripted ChatModel, tool and prompt tests (many, milliseconds)"] L --> S --> U
Table 1. What each layer proves
Layer Proves Does not prove

Scripted ChatModel

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:

ScriptedChatModel.java: a stub that plays a script and records prompts
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();
    }
}

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.

ToolLoopTest.java: a tool call and its result, scripted
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 + "]");
    }
}

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.

ToolSliceTest.java: the schema the model sees, and a call in the model’s JSON style
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");
    }
}

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:

PromptTemplateTest.java: rendering is testable without a model
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);
    }
}

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:

pom.xml (test dependencies used on this page)
<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:

ChatEndpointTest.java: the endpoint, the real Ollama client, a stub server
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:

OllamaLiveTest.java: a real model in a container, judged by evaluators
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();
    }
}

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:

pom.xml: keep the live tests out of the normal build
<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>

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:

src/test/resources/application.properties (repeatability)
spring.ai.ollama.chat.temperature=0.0
spring.ai.ollama.chat.seed=42

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

Table 2. Where the rest is tested
Part Where

Streaming, cancellation, StepVerifier

Streaming & Reactive

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

Security & Guardrails

Metrics and traces

Observability, which asserts on a MeterRegistry and on the finished observations

Common problems

Table 3. Symptoms of a broken test setup
Symptom Likely cause Fix

The tool never runs and the answer is null

The stub’s getOptions() returns plain ChatOptions

Return ToolCallingChatOptions.builder().build()

The context fails to start looking for a JdbcTemplate, a DataSource or an API key

Auto-configuration of infrastructure or of a second model family that the test does not need

Use a small application class with excludeName, and spring.ai.model.*=none for the unused families

401 or 403 from MockMvc

Spring Security is active

httpBasic(…​) or user(…​), and csrf() for a state-changing request

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

@Tag("live") and excludedGroups

The live test times out

The model is still downloading

Raise spring.ai.ollama.init.timeout, cache the model, or use a derived image

Where older material differs

Table 4. Older testing material and the 2.0 API
Older text Spring AI 2.0.1 and Spring Boot 4

org.testcontainers:ollama, org.testcontainers:junit-jupiter

testcontainers-ollama, testcontainers-junit-jupiter (Testcontainers 2.x)

@AutoConfigureMockMvc from org.springframework.boot.test.autoconfigure.web.servlet

org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc

A stub ChatModel with only call(Prompt)

Also override getOptions() when tools are involved

Evaluator constructors new FactCheckingEvaluator(builder)

FactCheckingEvaluator.builder(builder).build()