Class 14: Testing
Duration: ~65 minutes | Level: Intermediate | Prerequisites: Class 13: Roots, Notifications and Sampling.
What We'll Cover
- The parts of the system worth asserting on
- Tool, resource and prompt methods as ordinary Java
- Stubbing
McpSyncRequestContextfor the tools that take one - A test that every tool reached the server, with the right hints and schema
- Replacing the model on the client side, so the suite runs without an API key
- Why switching the MCP client off stops the context loading, and what to supply instead
- Measuring whether the model chooses a tool, and why that stays out of the normal build
This class carries on from Class 13. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_13
branch to start from the same place:
git clone --branch class_13 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Two Kinds of Behaviour
This application has a deterministic part, the Java methods and the Spring wiring, and a non-deterministic part, the model. Separating the two first avoids writing tests that fail even though the code has not changed.
| What runs | Same result every run? | Where this class asserts on it |
|---|---|---|
| A tool, resource or prompt method | Yes: the same arguments give the same value or the same exception | OrderToolsTest, RefundPromptsTest, OrderToolsContextTest |
| The registration the scanner produced: names, hints, input schema | Yes | McpRegistrationTest |
| The wiring: the context starts and the question reaches the model | Yes | SupportAgentServiceTest |
| The words the model produces | No: it phrases the same answer differently between runs | Nowhere |
| Whether the model calls a tool | No: it calls the tool on most runs, and not on every run | A separate tagged suite, kept out of mvn test |
Tests of the two rows marked No are slow, cost money on every run, and can fail even though the code has not changed. Keeping the deterministic group large is a design decision, and we already made it in Classes 2 to 5 by leaving the logic in OrderService and the MCP classes thin.
Methods Are Just Methods
The most valuable tests call the tool methods directly: no MCP request, no schema, no model. An @McpTool method is an ordinary public method; the annotation matters to the scanner, not to the compiler.
order-service already has the dependency these tests need, spring-boot-starter-test, which brings JUnit Jupiter, AssertJ, Mockito and Spring's test support. Spring Boot 4.1 manages JUnit Jupiter at 6.0.3, so the tests below run on JUnit 6. (support-agent does need it, and this class adds it when the first client-side test arrives.)
Create order-service/src/test/java/com/themcpguy/supportdesk/orders/mcp/OrderToolsTest.java:
package com.themcpguy.supportdesk.orders.mcp;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.annotation.McpMeta;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import com.themcpguy.supportdesk.orders.domain.Order;
import com.themcpguy.supportdesk.orders.domain.OrderStatus;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Transactional
class OrderToolsTest {
@Autowired
OrderTools tools;
@Test
void shouldReturnTheRequestedOrder() {
Order order = tools.getOrder("ORD-10001", new McpMeta(null));
assertThat(order.orderId()).isEqualTo("ORD-10001");
assertThat(order.status()).isEqualTo(OrderStatus.SHIPPED);
assertThat(order.items()).hasSize(2);
}
@Test
void shouldExplainAnUnknownOrderId() {
assertThatThrownBy(() -> tools.getOrder("ORD-99999", new McpMeta(null)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ORD-99999")
.hasMessageContaining("Check the ID");
}
}
Each test name states the expectation. shouldReturnTheRequestedOrder says what the method must do, so when the build fails, the name Maven prints already says what stopped working, before anyone opens the file. Every test in this class is named that way.
The second argument to getOrder is the McpMeta parameter from Class 13. The tests pass new McpMeta(null), an empty metadata object, which is the same value the annotations library hands a tool when a request arrives without _meta. The audit line these calls produce is the honest record of that: a test is a caller that did not send an agent ID.
get_order ORD-10001 requested by null
shouldExplainAnUnknownOrderId asserts on the error message as well as on the exception type. Class 3 explained why: the message becomes the tool result the model reads, so its wording is part of the tool's behaviour. hasMessageContaining("ORD-99999") checks that the message names what was rejected, and hasMessageContaining("Check the ID") checks that it says what to do next. The test keeps passing when the sentence is reworded around those two facts, and fails when either of them is dropped.
These are ordinary method calls, the same kind OrderServiceTest has been making since Class 1. Spring is not the only way to get an OrderTools. It takes its dependencies through its constructor, so a test could build one directly with new OrderTools(...), passing stubs in place of the real services, and skip the Spring context entirely.
The two annotations on the class each do one job.
| Annotation | What it does | Why this test needs it |
|---|---|---|
@SpringBootTest | builds the application context that running the module would build, with the default webEnvironment of MOCK, so no embedded server starts and the module does not listen on port 8080 | getOrder("ORD-10001") has to find a real order somewhere, and the seeded database is where it comes from |
@Transactional | runs each test method in its own transaction and rolls that transaction back when the method ends | the H2 database is shared by the whole suite, so a change that stayed would alter what later tests read |
Spring creates the beans, OrderTools and OrderService and the repositories among them, and DataInitializer fills the in-memory H2 database with the orders the course has been using since Class 1. The MCP machinery is not involved: @McpTool is read by the scanner when a server starts, and calling the method from a test does not touch it.
Rollback is the default for test-managed transactions in Spring, and a test that wants the opposite says so with @Commit.
The rollback is cleanup, and Spring's TestContext framework runs it after the test method returns. The code under test really executes, and a test that cancels an order and reads its status back sees CANCELLED:
The ROLLBACK at the end is what discards the row, so the next test starts from the seeded data again.
That cleanup matters more than it looks, because Spring caches the application context and reuses it for every test class configured the same way. DataInitializer therefore seeds the 200 orders once for the whole suite, and the in-memory database outlives any single test class. Without the rollback, one cancellation of ORD-10002 would change that order, and the result of findByStatus("PENDING"), for every test that runs after it.
The transaction does not commit, so whatever the database only does at commit time stays unexercised:
- constraints the database checks when Hibernate flushes the pending SQL, or when the transaction commits
- optimistic-locking conflicts, where two transactions hold the same row
- triggers
Spring's own testing documentation calls these false positives: the test passes, and the same code throws an exception in production. Its remedy is to flush inside the test, whenever the point of the test is that the write reached the database.
A second trap our entities happen to avoid: inside a test transaction the persistence context stays open for the whole method, so a lazily fetched association loads happily in a test and throws a LazyInitializationException in production. OrderEntity fetches both its customer and its items eagerly, and OrderService maps every entity to an immutable Order record before returning it, so nothing lazy leaves the service.
Experienced people disagree about this annotation, and that is worth knowing before adopting it as a habit.
| Who | Position | The reason they give |
|---|---|---|
| Tomasz Nurkiewicz, in Spring pitfalls: transactional tests considered harmful | Against | A lazily fetched association loads inside the test transaction and throws a LazyInitializationException in production. |
| Thorben Janssen | It depends on who owns the transaction | Code that manages its own transaction, as OrderService does, should be tested with that transaction and not a second one. |
| Andreas Eisele, writing on marcobehler.com in 2014 | It depends: drop it, or keep it and flush by hand | The rollback skips the flush, so anything that would break at flush time stays hidden. |
| The Spring team | Documents it, and @DataJpaTest switches it on | Every test starts from the same seeded data. |
We keep it in this class for two reasons: the failure modes the critics point at cannot happen with these entities, and the seeded database is shared, so one committed cancellation would change what every later test reads. In an application built on lazy associations the balance tips the other way, and re-seeding the data in a @BeforeEach is the usual answer. That is the trade-off: re-seeding exercises real commits and costs a write before every test, and the rollback is faster and isolates perfectly, as long as we remember it proves what the service did, and not what the database stored.
Resource and prompt methods test the same way. Create order-service/src/test/java/com/themcpguy/supportdesk/orders/mcp/RefundPromptsTest.java:
package com.themcpguy.supportdesk.orders.mcp;
import java.util.List;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional
class RefundPromptsTest {
@Autowired
RefundPrompts prompts;
@Test
void shouldPutTheCustomerDetailsInTheRefundPrompt() {
List<PromptMessage> messages = prompts.draftRefundEmail("ORD-10001", "arrived damaged");
assertThat(messages).hasSize(1);
assertThat(messages.getFirst().role()).isEqualTo(Role.USER);
assertThat(((TextContent) messages.getFirst().content()).text())
.contains("Ana Ruiz")
.contains("ORD-10001")
.contains("179.99");
}
}
The assertion on Role.USER is there because Class 5 explained that returning a String instead of List<PromptMessage> silently produces an ASSISTANT message, which changes how the model reads it. A refactor that changes the return type still compiles, and this assertion is where the change shows up.
Stubbing the Request Context
recheckShipments from Class 11 and cancelOrder from Class 12 take an McpSyncRequestContext. That is an interface, so a test supplies its own.
McpSyncRequestContext has around twenty methods, so writing our own implementation would be tedious. Mockito builds one for us. A mock is a stand-in object that implements the interface, answers every call with a default value, and records the calls it received. Telling one of its methods what to return is called stubbing that method. These tests get a class of their own, because they all share the same mock and the same @BeforeEach method, which JUnit runs before every test in the class. Create order-service/src/test/java/com/themcpguy/supportdesk/orders/mcp/OrderToolsContextTest.java:
package com.themcpguy.supportdesk.orders.mcp;
import java.util.Map;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult.Action;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.ai.mcp.annotation.context.StructuredElicitResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import com.themcpguy.supportdesk.orders.domain.OrderStatus;
import com.themcpguy.supportdesk.orders.service.OrderService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringBootTest
@Transactional
class OrderToolsContextTest {
@Autowired
OrderTools tools;
@Autowired
OrderService orderService;
private McpSyncRequestContext context;
@BeforeEach
void setUp() {
context = mock(McpSyncRequestContext.class);
}
@Test
void shouldReportProgressAndFinishTheRecheck() {
String summary = tools.recheckShipments(context);
assertThat(summary).contains("Rechecked 87 shipments");
verify(context).progress(100);
verify(context).info("Rechecking 87 shipments");
}
@Test
void shouldLeaveTheOrderPendingWhenTheUserDeclines() {
elicitReturns(new StructuredElicitResult<>(Action.DECLINE, null, Map.of()));
String result = tools.cancelOrder(context, "ORD-10002");
assertThat(result).contains("The client declined the confirmation");
assertThat(statusOf("ORD-10002")).isEqualTo(OrderStatus.PENDING);
}
@Test
void shouldLeaveTheOrderPendingWhenAcceptedWithoutConfirming() {
elicitReturns(new StructuredElicitResult<>(Action.ACCEPT,
new CancellationConfirmation(false, "changed my mind"), Map.of()));
String result = tools.cancelOrder(context, "ORD-10002");
assertThat(result).contains("confirmation was declined");
assertThat(statusOf("ORD-10002")).isEqualTo(OrderStatus.PENDING);
}
@Test
void shouldRefuseAShippedOrderWithoutAsking() {
String result = tools.cancelOrder(context, "ORD-10001");
assertThat(result).contains("can no longer be cancelled");
verify(context, never()).elicitEnabled();
}
private void elicitReturns(StructuredElicitResult<CancellationConfirmation> result) {
when(context.elicitEnabled()).thenReturn(true);
when(context.elicit(any(), eq(CancellationConfirmation.class))).thenReturn(result);
}
private OrderStatus statusOf(String orderId) {
return orderService.findById(orderId).orElseThrow().status();
}
}
recheckShipments only calls progress and info, so the mock's defaults are enough for it, and verify then checks the tool made those two calls.
For the cancellation tests, elicitReturns decides in advance what the person "answered", which makes every branch cheap to cover. Each of them asserts twice on purpose: the first assertion checks the sentence the method returned, and the second reads the order back through OrderService and checks its status, which shows the tool left the order alone.
Match the assertion text to what the tool actually returns. cancelOrder answers a DECLINE with "The client declined the confirmation, either because the person said no or because the question could not be presented", so the test asserts on a fragment of that sentence.
There are six paths through cancelOrder, and the three tests above cover the three marked here:
Write a test for the elicitEnabled branch next. It is the guard that keeps a person in the loop, the one we built the cancelOrder tool around in Class 12, and a bare mock(McpSyncRequestContext.class) already returns false from elicitEnabled(), so that test does not need any stubbing. The two branches left after it, a dismissed question and a confirmed cancellation, test the same way. The shipped-order test covers the other end of the same guard: verify(context, never()).elicitEnabled() shows the status check runs before the elicitation, so the confirmation question only reaches a person for an order that can still be cancelled.
Testing That Registration Happened
The tests so far show our methods work, but they do not check that the annotation scanner registered them with the server. If an annotation sits on a class that is not a bean, or the scanner has been switched off, every test above still passes while tools/list comes back empty.
Checking registration needs the full context. Create order-service/src/test/java/com/themcpguy/supportdesk/orders/mcp/McpRegistrationTest.java:
package com.themcpguy.supportdesk.orders.mcp;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class McpRegistrationTest {
@Autowired
ObjectProvider<List<SyncToolSpecification>> toolSpecs;
@Test
void shouldRegisterEveryTool() {
Set<String> names = toolSpecs.getObject().stream()
.map(spec -> spec.tool().name())
.collect(Collectors.toSet());
assertThat(names).containsExactlyInAnyOrder(
"get_order", "get_customer_orders", "get_orders_by_status",
"update_order_status", "recheck_shipments", "cancel_order");
}
@Test
void shouldMarkCancelOrderAsDestructive() {
var annotations = tool("cancel_order").tool().annotations();
assertThat(annotations.destructiveHint()).isTrue();
assertThat(annotations.readOnlyHint()).isFalse();
}
@Test
void shouldDeclareOnlyTheOrderIdParameter() {
assertThat(properties("get_order")).containsOnlyKeys("orderId");
}
@Test
void shouldKeepTheRequestContextOutOfTheSchema() {
// recheck_shipments takes only an McpSyncRequestContext.
assertThat(properties("recheck_shipments")).isEmpty();
// cancel_order takes a context and an orderId; only the orderId is declared.
assertThat(properties("cancel_order")).containsOnlyKeys("orderId");
}
private SyncToolSpecification tool(String name) {
return toolSpecs.getObject().stream()
.filter(spec -> spec.tool().name().equals(name))
.findFirst()
.orElseThrow(() -> new AssertionError("No tool registered called " + name));
}
@SuppressWarnings("unchecked")
private Map<String, Object> properties(String toolName) {
Object properties = tool(toolName).tool().inputSchema().get("properties");
return properties == null ? Map.of() : (Map<String, Object>) properties;
}
}
The injected ObjectProvider is Spring's deferred lookup: getObject() resolves the list of tool specifications when the test asks for it, and not when the test class is created. Tool.inputSchema() is a plain Map<String, Object> in SDK 2.0.0, so the properties come out with get("properties"). McpSchema also defines a JsonSchema record, and Tool does not use it.
McpSyncRequestContext, McpMeta and @McpProgressToken parameters all stay out of the input schema, and each assertion in that class watches for a different regression:
| Assertion | The change it catches |
|---|---|
containsExactlyInAnyOrder on the six names | a tool renamed, dropped, or left on a class that is not a bean, so tools/list comes back short |
destructiveHint true and readOnlyHint false on cancel_order | a refactor that flips a hint, which only this assertion catches |
containsOnlyKeys("orderId") on get_order | Class 13's McpMeta parameter leaking into the input schema the model reads |
properties("recheck_shipments") is empty | an McpSyncRequestContext parameter being declared as a tool argument |
destructiveHint on cancel_order is what a host reads when it decides how to present the tool. The specification calls every field in ToolAnnotations a hint a client may ignore, and tells clients to distrust annotations that come from a server they do not trust. A refactor that flips the flag to false still compiles, starts and answers normally, so this assertion is the only place a wrong hint shows up.
Replacing the Model
On the client side, the test should start the real context, wire the real SupportAgentService, and replace only the model, so the suite does not need an API key, a network connection or a running order-service.
This is the first test in support-agent, and that module lacks the test dependency, so add it to support-agent/pom.xml before writing any of the code below. Without it the test class fails to compile, with errors such as package org.junit.jupiter.api does not exist:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Two pieces of configuration then make the rest possible. First, create support-agent/src/test/resources/application.yaml; a file with this name shadows the main one whenever tests run:
spring:
ai:
anthropic:
api-key: not-used-in-tests
openai:
api-key: not-used-in-tests
mcp:
client:
enabled: false
toolcallback:
enabled: false
| Property | Value | What it stops happening |
|---|---|---|
spring.ai.anthropic.api-key | not-used-in-tests | the context failing to start while resolving an environment variable that a continuous integration (CI) runner does not have |
spring.ai.openai.api-key | not-used-in-tests | the same, for the OpenAI starter |
spring.ai.mcp.client.enabled | false | the client opening connections to order-service and spawning npx when @SpringBootTest builds the context |
spring.ai.mcp.client.toolcallback.enabled | false | the tool-callback integration being built from those clients |
None of the tests call a model, so the two placeholders do not reach an API. Switching the client off is what lets the suite run while order-service is down and without Node installed.
With the client disabled, Spring AI does not create the SyncMcpToolCallbackProvider bean, so any bean injecting it, such as SupportAgentService from Class 7, fails to load with an UnsatisfiedDependencyException.
Something has to supply that bean, and @MockitoBean is not enough. ChatClient.Builder reads both the tool callbacks and the model's options while SupportAgentService is being constructed, which is before any @BeforeEach stubbing runs. A bare mock fails twice, both times with a NullPointerException:
Cannot read the array length because "callbacks" is null
Cannot invoke "ChatOptions.mutate()" because ChatModel.getOptions() is null
Mockito returns null for an array and for an object by default, and both are read too early to stub. Supply the beans from a @TestConfiguration instead, a class of test-only bean definitions that a test class imports, so the beans exist before the context is built.
SupportAgentServiceTest, below, holds that configuration and two tests. The model in both is the Mockito mock built in Stubs: it does not contact Anthropic or Ollama, it answers every call with a fixed "ok", and it records the Prompt our code handed it. Both tests then assert on that recorded prompt:
- the first checks that the user's question is in it, which is how we know
SupportAgentServicepassed the question through rather than losing it - the second checks that the returns policy from Class 8 is in it as well, ahead of the question
McpResources would try to talk to order-service for that policy, so @MockitoBean replaces it:
package com.themcpguy.supportdesk.agent.service;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.mcp.SyncMcpToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import com.themcpguy.supportdesk.agent.mcp.McpResources;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@SpringBootTest
@Import(SupportAgentServiceTest.Stubs.class)
class SupportAgentServiceTest {
@TestConfiguration
static class Stubs {
@Bean
SyncMcpToolCallbackProvider syncMcpToolCallbackProvider() {
return new SyncMcpToolCallbackProvider(List.of());
}
// @Primary because the Anthropic starter autoconfigures its own ChatModel,
// leaving two candidates.
@Bean
@Primary
ChatModel chatModel() {
ChatModel model = mock(ChatModel.class);
given(model.getOptions()).willReturn(ChatOptions.builder().build());
return model;
}
}
@Autowired
ChatModel chatModel;
@MockitoBean
McpResources resources;
@Autowired
SupportAgentService agent;
@BeforeEach
void resetTheModel() {
reset(chatModel);
given(chatModel.getOptions()).willReturn(ChatOptions.builder().build());
}
@Test
void shouldSendTheQuestionToTheModel() {
given(chatModel.call(any(Prompt.class)))
.willReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("ok")))));
String reply = agent.chat("test", "Where is ORD-10001?");
assertThat(reply).isEqualTo("ok");
ArgumentCaptor<Prompt> prompt = ArgumentCaptor.forClass(Prompt.class);
verify(chatModel).call(prompt.capture());
assertThat(prompt.getValue().getInstructions())
.anyMatch(message -> message.getText().contains("Where is ORD-10001?"));
}
@Test
void shouldAttachTheReturnsPolicyToTheSystemPrompt() {
given(resources.read("policy://returns")).willReturn("RETURNS POLICY BODY");
given(chatModel.call(any(Prompt.class)))
.willReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("ok")))));
agent.chatWithPolicy("test", "Can they still return it?", null);
ArgumentCaptor<Prompt> prompt = ArgumentCaptor.forClass(Prompt.class);
verify(chatModel).call(prompt.capture());
assertThat(prompt.getValue().getInstructions())
.anyMatch(message -> message.getText().contains("RETURNS POLICY BODY"));
}
}
One run of shouldAttachTheReturnsPolicyToTheSystemPrompt travels like this, with the two replaced beans marked as mocks:
The model and McpResources are the only replaced pieces, so everything SupportAgentService does to assemble the prompt is the real code, and the captured Prompt is worth asserting on.
The mock supplied from the @TestConfiguration is an ordinary singleton bean, one instance shared by every test in the class. Nothing resets it between tests, so recorded invocations accumulate until a verify(...) reports TooManyActualInvocations. The @BeforeEach reset clears them, and restores the one stubbed method the builder needs.
An ArgumentCaptor is what makes both assertions possible: verify(chatModel).call(prompt.capture()) keeps the Prompt the code passed, and prompt.getValue() hands it back for inspection. RETURNS POLICY BODY is the marker the second test then looks for in it, ahead of the question. Neither test asserts on the wording of an answer, because a mock produced it.
Whether the Model Uses a Tool
Whether the model chooses to call a tool is hard to test in the usual way: tool choice is a judgement the model makes, and it can come out differently on the next run. A single assertion cannot describe that.
If it does need measuring, keep the suite out of the normal build:
- Tag it so
mvn testdoes not run it. Against a hosted model it needs an API key and costs money on every run, and against a local model through Ollama it is free. Either way it belongs outside the build that has to pass before a change is merged. A model is not deterministic: the same question can produce a different answer, and a different choice of tool, the next time it runs. That makes the tests flaky, and flaky tests break builds at random. - Run each question several times and assert on how often the tool was chosen, not on one outcome.
- Treat a failure as information about the tool description, since that is what the model reads.
Tagging is two pieces. JUnit's @Tag puts the class in a named group, and any name will do:
@Tag("model")
class ToolChoiceTest {
// tests that call a real model
}
Maven's Surefire plugin then leaves that group out, which it does when the excludedGroups property names it. Set it in the root pom.xml, next to the properties already there:
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.0</spring-ai.version>
<excludedGroups>model</excludedGroups>
</properties>
mvn test then skips the tagged class, and one command runs it when we want it:
mvn test -Dgroups=model -DexcludedGroups=
Both flags are needed. -Dgroups=model selects the group, and -DexcludedGroups= clears the exclusion:
| Command | What runs |
|---|---|
mvn test | the five classes in this lesson and OrderServiceTest |
mvn test -Dgroups=model | nothing: the exclusion still applies, and Surefire prints Tests run: 0 |
mvn test -Dgroups=model -DexcludedGroups= | ToolChoiceTest only |
either of those, with <excludedGroups> written inside Surefire's <configuration> block | ToolChoiceTest stays skipped, whatever the command line says |
The last row is the difference between a <properties> entry and a plugin <configuration> entry. Surefire reads excludedGroups from a POM property or a -D flag only while its own configuration leaves that parameter unset.
The companion project does not carry a tagged suite, so these three snippets are the shape to copy when you add one. Class 3 makes the same point from the other direction: when a tool is not being called, look at its description first. A suite like this measures how well a description works; whether our code is correct is covered by the tests earlier in this class.
Run Them
From the repository root:
mvn test
Neither application needs to be running, and no API key is needed:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in McpRegistrationTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in OrderToolsContextTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in RefundPromptsTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in OrderToolsTest
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 -- in OrderServiceTest
Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in SupportAgentServiceTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
The two lines without a class name are the per-module totals, 21 for order-service and 2 for support-agent. OrderServiceTest came with the course project and has been there since Class 1. We added the other four in this class.
To run one module or one test:
mvn -pl order-service test
mvn -pl order-service test -Dtest=McpRegistrationTest
In CI
The suite does not need an API key or a network connection, as long as the MCP client is switched off. On GitHub Actions the job is three steps, and it needs a JDK 21 because that is the version the root pom.xml sets:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
java-version: '21'
distribution: temurin
- run: mvn test
That workflow is illustrative, and the companion project does not include one. Keep anything that calls a real model out of the build that has to pass before a change can be merged, and run those tests only when someone chooses to. If a merge can be blocked by the model wording an answer differently, people stop treating a failing build as a sign of a real bug.
What We Built
| Test class | Module | What it asserts | Tests |
|---|---|---|---|
OrderServiceTest | order-service | the service logic, unchanged since Class 1 | 10 |
OrderToolsTest | order-service | get_order returns the order, and an unknown ID is explained in the message | 2 |
RefundPromptsTest | order-service | the prompt carries the customer, the order ID and the total, in a USER message | 1 |
OrderToolsContextTest | order-service | progress and logging on recheck_shipments, and three cancellation branches, against a mocked request context | 4 |
McpRegistrationTest | order-service | six tools registered, cancel_order marked destructive, the request context and McpMeta kept out of the schema | 4 |
SupportAgentServiceTest | support-agent | the question and the returns policy reach the model | 2 |
None of these tests call a model or assert on anything a model said. One layer stays uncovered: every test here calls a Java method or reads registration metadata, so nothing travels over a transport as a real tools/call. The curl calls in Classes 2 and 3 are the check on that layer, and an end-to-end test would need the MCP client left on and order-service started.
Next: Class 15: Async and Stateless. The same service, reactive and stateless.
Further Reading
- Test Scope Dependencies, Spring Boot reference: everything
spring-boot-starter-testputs on the test classpath, JSONassert, JsonPath and Awaitility included. @Rollback, Spring Framework reference: how@Rollback(false)on one method, or@Commiton a class, keeps what a test wrote when the committed row is the point of the test.@MockitoBeanand@MockitoSpyBean, Spring Framework reference: how the annotation onMcpResourcespicks the bean it replaces, and why naming the field the same way across test classes avoids building extra application contexts.- Bean Overriding in Tests, Spring Framework reference: the
REPLACEandREPLACE_OR_CREATEstrategies that decide whether an override needs an existing bean. ArgumentCaptor, Mockito javadoc: theforClass,captureandgetValuecalls the client-side test uses, andgetAllValuesfor a method that was called more than once.- Apache Maven Surefire Plugin, running JUnit Platform tests: the "Filtering by Tags" section, including tag expressions such as
acceptance | !feature-afor selecting more than one group at a time.
Sources
- Managed Dependency Coordinates, Spring Boot: that Spring Boot manages
org.junit.jupiter:junit-jupiterat 6.0.3, sospring-boot-starter-testbrings JUnit 6. - Testing Spring Boot Applications, Spring Boot reference: that
@SpringBootTestdoes not start a server by default, and that data JPA tests are transactional and roll back at the end of each test. - Transaction Management, Spring Framework reference: that test-managed transactions roll back by default, and that Spring's own example flushes by hand to avoid a false positive in a test.
- Context Caching, Spring Framework reference: that Spring caches and reuses the application context across test classes configured the same way, which is why the seeded H2 database outlives one test class.
- Mockito javadoc: that a mock returns
null, a primitive value or an empty collection by default, which is why a bare mock hands backnullfor the callbacks array and the chat options. - Apache Maven Surefire Plugin, surefire:test: that
groupsandexcludedGroupseach have a user property of the same name, which is what lets a POM property be replaced from the command line. - Tagging and Filtering, JUnit user guide: that
@Tagcomes fromorg.junit.jupiter.apiand tags a class or method for filtering. - MCP specification: Schema Reference, ToolAnnotations: that
destructiveHintis a boolean meaningful only whenreadOnlyHintis false, and that clients should not base tool-use decisions on annotations from untrusted servers. - McpSchema.java at tag v2.0.0, Java MCP SDK: that the
Toolrecord declaresinputSchemaas aMap<String, Object>, so the test reads the properties withget("properties"). - MCP Client Boot Starter, Spring AI reference: that
spring.ai.mcp.client.toolcallback.enabledcontrols the tool-callback integration and defaults totrue, so the test configuration switches it off alongside the client. - MCP Server Annotations, Spring AI reference: the annotation scanner that turns
@McpTool,@McpResourceand@McpPromptmethods into the server specifications the registration test reads. - Spring pitfalls: transactional tests considered harmful, Tomasz Nurkiewicz: the case against
@Transactionaltests, built on a lazily fetched association that loads in the test and throws aLazyInitializationExceptionin production. - Should your tests manage transactions?, Thorben Janssen: that a test of business code which already manages its own transaction should not introduce transaction handling of its own.
- Should my tests be @Transactional?, Andreas Eisele on marcobehler.com: the "it depends" answer, and the manual flush offered as a way to keep the isolation without the blind spot.