Class 12: Testing the Spring AI MCP Server
Duration: 30 minutes | Difficulty: Intermediate | Prerequisites: Class 11.
This lesson carries on from Class 11. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_11 branch to start from the same place:
git clone --branch mcp_class_11 https://github.com/graphqlguy/graphQLMovieDB-MCP.git
By the end of this lesson your moviedb MCP server has tests at four levels:
- Unit tests cover the tool methods.
- Integration tests call the live MCP endpoint through a real MCP client.
- Contract tests save the tool schemas to a file, so a rename breaks the build before it breaks an agent integration.
- Property-based tests cover the tools with bounded inputs.
This lesson closes Track B. You will have a server you can ship, and a test suite that tells you when something an agent depends on has changed.
Four Levels of Tests
Tools are Spring beans, so we test them like other Spring beans, with a few details specific to MCP. Which level to use depends on the kind of failure you want to catch.
Unit tests for tool methods are the cheapest and the fastest. The tool method is plain Java: mock ExecutionGraphQlService, return a fake response, and assert on the tool's output. Run them on every commit. They catch the same regressions as any other unit test, such as bad enum mappings, off-by-one errors and missing null checks. Most of your tests are at this level.
Integration tests against the MCP endpoint start the full Spring context and call tools over HTTP with a real MCP client. They are slower, and they are the only level that catches wiring problems. Examples are auto-configuration that misses your tool, a security filter chain that blocks the MCP path, or the SyncToolSpecification bean from Class 8 losing a description. Run them on pull requests.
Contract tests for tool schemas save the tools/list response, or each tool's JSON Schema on its own, to files in the repository. They fail the build when the output changes. Renaming an @McpTool method silently breaks every agent that uses the old name, and a contract test is the only test that catches it before an agent does. Run them in CI as a separate check. A change is acceptable only when it is deliberate and the snapshot file is updated in the same commit.
Property-based tests for argument validation try every value of a bounded input and check that the tool handles each one. We use the term loosely. Property-based testing usually means checking rules against random inputs, and shrinking failing cases to minimal examples. Trying every value of a small, finite input is a simpler and stronger form of the same idea. The Mood enum has six values, so the test calls the tool with each of the six and checks that every call succeeds. This helps for the few tools where testing every value is cheap.
Unit Tests for Tool Methods
We start with the simplest case. Mock ExecutionGraphQlService with Mockito, build an ExecutionGraphQlResponse by hand, and assert on the tool's return value:
package com.graphqlguy.moviedb.mcp;
import com.graphqlguy.moviedb.mcp.MovieMcpTools.MovieSummary;
import com.graphqlguy.moviedb.mcp.MovieMcpTools.RecommendInput;
import com.graphqlguy.moviedb.recommendation.Mood;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.ResponseField;
import reactor.core.publisher.Mono;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
class MovieMcpToolsUnitTest {
@Test
void recommendMoviesForMood_shouldReturnCuratedMovies() {
ExecutionGraphQlService graphql = Mockito.mock(ExecutionGraphQlService.class);
ExecutionGraphQlResponse response = Mockito.mock(ExecutionGraphQlResponse.class);
ResponseField field = Mockito.mock(ResponseField.class);
// Raw Maps are exactly what the GraphQL engine hands back for a list of
// objects; note genre arriving as the serialized enum name "ROMANCE".
when(field.getValue()).thenReturn(List.of(
Map.of("id", "1", "title", "Amelie", "releaseYear", 2001,
"genre", "ROMANCE", "rating", 8.3),
Map.of("id", "2", "title", "Paddington 2", "releaseYear", 2017,
"genre", "COMEDY", "rating", 8.0)
));
when(response.field("recommendMoviesForMood")).thenReturn(field);
when(graphql.execute(any(ExecutionGraphQlRequest.class))).thenReturn(Mono.just(response));
MovieMcpTools tools = new MovieMcpTools(graphql, new ObjectMapper());
List<MovieSummary> result = tools.recommendMoviesForMood(
new RecommendInput(Mood.COMFORT, false));
assertThat(result)
.extracting(MovieSummary::title)
.containsExactly("Amelie", "Paddington 2");
}
@Test
void recommendMoviesForMood_excludeWatchedMissing_shouldSendFalse() {
ExecutionGraphQlService graphql = Mockito.mock(ExecutionGraphQlService.class);
ExecutionGraphQlResponse response = Mockito.mock(ExecutionGraphQlResponse.class);
ResponseField field = Mockito.mock(ResponseField.class);
when(field.getValue()).thenReturn(List.of());
when(response.field("recommendMoviesForMood")).thenReturn(field);
ArgumentCaptor<ExecutionGraphQlRequest> request = ArgumentCaptor.forClass(ExecutionGraphQlRequest.class);
when(graphql.execute(request.capture())).thenReturn(Mono.just(response));
MovieMcpTools tools = new MovieMcpTools(graphql, new ObjectMapper());
tools.recommendMoviesForMood(new RecommendInput(Mood.COMFORT, null));
// A caller that leaves the optional flag out gets the GraphQL default.
assertThat(request.getValue().getVariables()).containsEntry("excludeWatched", false);
}
}
The first test runs in milliseconds, without starting Spring. Some parts are mocked and some are real. The mocked ResponseField.getValue() returns raw Maps, which is what the GraphQL engine returns for a list of objects, with genre as the serialized enum name "ROMANCE". The real ObjectMapper passed to the tool then does the convertValue step from Class 7. So the test runs the same extraction code that production uses, without the schema, the database or the MCP layer. Write one for each non-trivial tool method, covering the main branches: the normal case, an empty result, and a result with some fields missing.
The second test covers an input the agent is allowed to leave out. It builds a RecommendInput without excludeWatched and captures the request the tool sends to the GraphQL engine. The assertion checks that the request carries excludeWatched: false, which is the default the GraphQL operation declares. A later change that passed the missing flag through as null would fail this test.
Integration Tests Through a Real MCP Client
Wiring problems need the real server and a real client. The streamable HTTP transport is more than a plain JSON endpoint:
- it requires an
initializehandshake - it issues an
Mcp-Session-Idheader that the client must send back on every later request - it requires the header
Accept: application/json, text/event-stream - it sends its responses as server-sent events by default
A request that misses any of these gets HTTP 400 before your tool code runs. Writing the client side of the protocol inside a test also adds bugs of its own. Use the MCP Java SDK's client instead. It is already on your classpath, because the Spring AI starter is built on it (io.modelcontextprotocol.sdk:mcp). The test then calls the endpoint through the same protocol code that a real client uses.
@SpringBootTest(webEnvironment = RANDOM_PORT) starts the full app on a random port, and the SDK client connects to it over real HTTP:
package com.graphqlguy.moviedb.mcp;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
class McpEndpointIntegrationTest {
@LocalServerPort
int port;
McpSyncClient client;
@BeforeEach
void connect() {
var transport = HttpClientStreamableHttpTransport
.builder("http://localhost:" + port)
.endpoint("/mcp")
.build();
client = McpClient.sync(transport).build();
client.initialize();
}
@AfterEach
void disconnect() {
client.closeGracefully();
}
@Test
void toolsList_shouldIncludeAllRegisteredTools() {
McpSchema.ListToolsResult tools = client.listTools();
assertThat(tools.tools())
.extracting(McpSchema.Tool::name)
.contains("recommendMoviesForMood", "summarizeMovieReviews",
"addToWatchlist", "ping");
}
@Test
void recommendMoviesForMood_shouldWorkEndToEnd() {
McpSchema.CallToolResult result = client.callTool(
new McpSchema.CallToolRequest("recommendMoviesForMood",
Map.of("input", Map.of("mood", "COMFORT", "excludeWatched", false))));
assertThat(result.isError()).isNotEqualTo(true);
assertThat(result.content()).isNotEmpty();
}
}
The connect() method does the protocol work. The transport builder points at the /mcp endpoint on the random port. client.initialize() runs the full handshake: the initialize request, the notifications/initialized notification, and storing the session header. Every test then starts after the handshake.
The first test catches tools that fail to register. For example, someone removes a tool bean from the SyncMcpToolProvider call in Class 8's configuration, or a refactor stops a specification from being built. The tool then disappears from tools/list, and only this test notices.
The second test checks that a tool runs end to end. It goes through the transport layer, the JSON-RPC handler, the security filter chain, the ExecutionGraphQlService execution and the response shaping. If any of those breaks, the test fails with a useful diff.
If your MCP server requires authentication, which Class 15 adds, attach the token at the transport level. HttpClientStreamableHttpTransport.builder(...) accepts a request customizer, which can add an Authorization header to every outgoing request. The integration test is the right place to check that the auth wiring works.
Contract Tests for Tool Schemas
A renamed @McpTool method silently breaks every agent integration that uses the old name. A change to a tool's input schema is the same problem with a different cause. Contract tests catch both.
The pattern is to fetch tools/list through the same SDK client the integration tests use, then serialize each tool's name, description and input schema to pretty-printed JSON. Write that to a file, check the file in, and fail the test when a later run produces different output:
package com.graphqlguy.moviedb.mcp;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.io.ClassPathResource;
import tools.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ToolSchemaContractTest {
@LocalServerPort int port;
@Autowired ObjectMapper mapper;
@Test
void toolSchemas_shouldMatchTheSnapshot() throws Exception {
var transport = HttpClientStreamableHttpTransport
.builder("http://localhost:" + port)
.endpoint("/mcp")
.build();
McpSyncClient client = McpClient.sync(transport).build();
client.initialize();
McpSchema.ListToolsResult tools = client.listTools();
client.closeGracefully();
String actual = mapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(tools.tools().stream()
.sorted(Comparator.comparing(McpSchema.Tool::name))
.toList());
String expected = new ClassPathResource("contracts/tools-list.json")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(actual)
.as("If this fails, the tool schema changed. Either revert the change, or update the contract file.")
.isEqualTo(expected);
}
}
Serializing the SDK's McpSchema.Tool records gives a stable snapshot of exactly what an agent sees: tool names, descriptions and input schemas, without any transport framing. We sort by name before serializing on purpose. Registration order depends on the order in which the JVM lists methods through reflection, and that order can change between JDK releases. A snapshot in registration order could then fail after a JDK upgrade, even though the tools stayed the same.
Creating the first snapshot has one step that is easy to get wrong. Create the file empty first:
mkdir -p src/test/resources/contracts
touch src/test/resources/contracts/tools-list.json
Then run the test. It fails, and because the file exists, the AssertJ diff prints the actual value. Paste that value into the file and run the test again. If you skip the touch, getContentAsString throws FileNotFoundException: class path resource [contracts/tools-list.json] cannot be opened because it does not exist before the assertion runs. Without a value to copy, the failure looks like a broken test. From then on, any change to a tool name, description or input schema fails the test until you update the contract file.
Updating the file is the step this test forces on you, and that stays true for the rest of the course. Class 16 adds two tools, so this file has to grow with them, or the suite fails.
In code review, a change to the contract file needs attention. If the change is deliberate, the reviewer confirms that the agent integrations were updated to match. If it is an accident, it gets reverted before merge. The cost is one extra file in the repository, and in return agent integrations cannot break silently.
Property-Based Tests for Argument Validation
The Mood enum has six values. A property-based test tries each value and checks that the tool handles all of them:
package com.graphqlguy.moviedb.mcp;
import com.graphqlguy.moviedb.mcp.MovieMcpTools.RecommendInput;
import com.graphqlguy.moviedb.recommendation.Mood;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.assertj.core.api.Assertions.assertThatCode;
class MoodToolPropertyTest {
@ParameterizedTest
@EnumSource(Mood.class)
void recommendMoviesForMood_shouldAcceptEveryMoodValue(Mood mood) {
assertThatCode(() -> {
MovieMcpTools tools = withMockedGraphQl();
tools.recommendMoviesForMood(new RecommendInput(mood, false));
}).doesNotThrowAnyException();
}
private MovieMcpTools withMockedGraphQl() {
return new MovieMcpTools(MockGraphQlServiceFactory.empty());
}
}
MockGraphQlServiceFactory is a small test helper. This test and the progress test later in the lesson both use it, so we write it once:
package com.graphqlguy.moviedb.mcp;
import org.mockito.Mockito;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.ExecutionGraphQlService;
import org.springframework.graphql.ResponseField;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* Class 12 test helper. {@link #empty()} returns a Mockito-mocked
* ExecutionGraphQlService whose response yields an empty list for any
* operation and any field path, so any tool built on it runs its full
* extraction path against empty data. {@link #returning(Object)} is the
* variant with a canned value.
*/
final class MockGraphQlServiceFactory {
private MockGraphQlServiceFactory() {}
static ExecutionGraphQlService empty() {
return returning(List.of());
}
static ExecutionGraphQlService returning(Object value) {
ExecutionGraphQlService graphql = Mockito.mock(ExecutionGraphQlService.class);
ExecutionGraphQlResponse response = Mockito.mock(ExecutionGraphQlResponse.class);
ResponseField field = Mockito.mock(ResponseField.class);
when(field.getValue()).thenReturn(value);
when(response.field(anyString())).thenReturn(field);
when(response.getErrors()).thenReturn(List.of());
when(graphql.execute(any(ExecutionGraphQlRequest.class))).thenReturn(Mono.just(response));
return graphql;
}
}
empty() returns an empty list for any operation and any field path, so a tool built on it runs its full extraction code against empty data. returning(value) does the same with a prepared value, which the progress test uses. We stub response.field(anyString()) for every path on purpose. These tests check the tool's own logic. Stubbing one specific path would break them whenever an operation name changes, which is unrelated to what they test.
The factory leaves out the ObjectMapper that this code needs. It comes from the single-argument MovieMcpTools constructor, which passes a new ObjectMapper to the two-argument constructor from the unit test earlier. Class 9 added both as convenience constructors. The two-argument one calls the primary constructor, which has four parameters since Class 11, and fills in a ToolResults built on the ObjectMapper and a new SamplingReviewSummarizer.
@ParameterizedTest with @EnumSource(Mood.class) runs the test six times, once for each Mood value. If you add a seventh value to the enum before the recommendation engine can handle it, the test fails for that new value. For fuller property-based testing, with random integers, random strings or generated input combinations, use jqwik. For inputs limited to an enum, plain JUnit parameterized tests are enough.
Testing Tools That Report Progress
Class 10 gave summarizeMovieReviews a signature that reports progress: a McpSyncRequestContext parameter and a plain blocking return. The usual test libraries are enough. Mock the context, call the method, assert on the result, and verify the progress calls if the progress checkpoints are part of what you want to guarantee:
package com.graphqlguy.moviedb.mcp;
import com.graphqlguy.moviedb.mcp.MovieMcpTools.MovieReviewSummary;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.ai.mcp.annotation.context.McpRequestContextTypes.ProgressSpec;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
class SummarizeProgressTest {
@Test
void summarizeMovieReviews_shouldReturnSummary() {
MovieMcpTools tools = withMockedDependencies();
McpSyncRequestContext context = Mockito.mock(McpSyncRequestContext.class);
MovieReviewSummary summary =
tools.summarizeMovieReviews(context, "1");
assertThat(summary.reviewCount()).isGreaterThanOrEqualTo(5);
// progress() is overloaded (Consumer vs ProgressNotification); the type
// witness pins the matcher to the Consumer overload the tool actually calls.
Mockito.verify(context, Mockito.atLeastOnce())
.progress(Mockito.<Consumer<ProgressSpec>>any());
}
/**
* Same Mockito setup as the unit test: a mocked ExecutionGraphQlService
* returning a canned review list, paired with a real ObjectMapper. The
* mocked context makes every context.progress(...) call a recorded no-op,
* mirroring production behavior when a client did not send a progress
* token, and sampleEnabled() defaults to false so the server-side
* summarizer runs.
*/
private MovieMcpTools withMockedDependencies() {
List<Map<String, Object>> cannedReviews = List.of(
Map.of("score", 9, "comment", "Beautiful cinematography and a warm story"),
Map.of("score", 8, "comment", "Charming characters"),
Map.of("score", 7, "comment", "A cozy watch"),
Map.of("score", 9, "comment", "Gorgeous soundtrack"),
Map.of("score", 8, "comment", "Feel-good from start to finish"));
return new MovieMcpTools(MockGraphQlServiceFactory.returning(cannedReviews), new ObjectMapper());
}
}
The test uses five prepared reviews, because Class 11's tool returns early with a null summary when a movie has fewer than three, and the test needs the path that runs the summarizer. On the mocked context, every context.progress(...) call is only recorded. That matches production when a client sends a request without a progress token. sampleEnabled() on a Mockito mock returns false by default, so the test runs the server-side fallback and stays away from any model. The final verify checks that the tool reported progress at least once.
This course's server stays on the SYNC server type (Class 10 explains the trade-off). Every tool method returns a plain value, and the tests work without reactor-test. If you run an ASYNC server with reactive tool methods, use StepVerifier from reactor-test to test those pipelines.
CI Strategy
- Unit tests run on every commit. They are fast, and they catch the most common regressions.
- Integration tests run on pull requests. They take longer, and they catch broken wiring before merge.
- Contract tests run as a separate CI job, because a schema change calls for a different discussion from a failing test.
- Property-based tests run with the unit tests, because they are just as fast.
Each of the four levels catches a different kind of regression, and each costs about as much to run as it is worth. Test tools as carefully as any other Spring bean. Without these tests, a refactor that renames a tool or changes its schema passes the build, and you find out only when an agent integration fails.
Summary
The moviedb MCP server now has tests at four levels, and each one catches a different kind of failure:
- unit tests catch logic regressions in the tool methods
- integration tests call the live endpoint through a real MCP Java SDK client and catch broken wiring
- contract tests snapshot the tool schemas, so a rename or schema change fails the build instead of an agent integration
- property-based tests try every value of bounded inputs such as the
Moodenum
The server is SYNC throughout, so the tests work without reactive testing libraries. The one tool that reports progress is tested with a mocked McpSyncRequestContext and plain assertions.
Further Reading
- Spring Boot Test reference -
@SpringBootTest, random ports, slice tests. - MCP Java SDK - the SDK whose sync client drives the integration and contract tests; the docs cover
McpClient.sync(...)and the streamable HTTP transport builder. - Spring for GraphQL: Testing -
GraphQlTesterfor integration tests against the live schema. - JUnit ParameterizedTest -
@EnumSourcefor the property-based Mood test. - jqwik - property-based testing on the JUnit platform, for randomized inputs beyond bounded enums.
- Mockito - the mocking library used in the unit tests.
- Reactor Test: StepVerifier - only needed if you run an
ASYNCserver with reactive tool methods.
What's Next
Track B is done. We now have a Spring AI MCP server with:
- three real tools
- descriptions taken from the schema
- structured outputs and errors
- progress reporting on the slow tool
- elicitation and sampling
- a test suite at four levels
Class 13 starts Track C: the same MCP tools built by hand in a Spring @RestController that handles JSON-RPC 2.0. Track C shows what the starter has been doing for you, by working directly with the protocol. After Class 14 you will have seen all three tracks side by side, and you can choose between them based on the trade-offs you observed.