The first question about testing a Spring AI agent is "how do I avoid calling the model." The second one, the moment tools enter the picture, is scarier:
When my test makes the agent call
refundCustomer(...)orsendEmail(...)ordeleteOrder(...)— does the real method run? Will my test suite issue a real refund, send a real email, or write to a real database, on every CI run?
With most testing approaches the honest answer is "you have to make sure it doesn't, manually, and hope nobody forgets." This is about making the safe answer the default.
Two clarifications up front. First, this isn't a hand-written mock or a stubbed ChatModel — it records a real model-and-tool exchange once and replays it deterministically. Second, unlike Ruby's VCR or WireMock, which record at the HTTP socket, this intercepts at Spring AI's own ToolCallingManager bean — above the network layer, where Spring AI actually runs your tools.
How tool calling actually works in Spring AI
A tool call isn't one request — it's a loop:
- Your code sends a prompt, with tools registered.
- The model replies "I want to call
getOrderStatus("ORD-4471")." - Spring AI's
ToolCallingManagerruns your real@Toolmethod and feeds the result back to the model. - The model produces the final answer using that result.
So a naive recording of just the final answer is useless — you've lost the middle. And if you replay the model turns but let step 3 run for real every time, your side effects fire on every replay, forever. Both failure modes are exactly what you don't want in a test.
How the isolation actually works (no magic): the library replaces the Spring-managed ToolCallingManager with a thin wrapper, VcrToolCallingManager. On a cassette hit it returns the recorded (tool name, arguments) → result directly and never reaches the dispatcher that would invoke your @Tool; on a miss it delegates to the real manager, lets the tool run once, and records the arguments/result pair. That is the entire trick — interception at the same bean Spring AI already uses to run tools, not bytecode magic or a proxy around your method.
TL;DR — the default is already safe
Tool isolation is on by default; you don't configure anything to get it:
spring:
ai:
test:
vcr:
enabled: true
mode: RECORD_OR_REPLAY
tool:
mode: REPLAY_FROM_CASSETTE # default — full isolation
@SpringBootTest
class RefundAgentTest {
@Autowired ChatClient.Builder chatClientBuilder;
@Autowired RefundTools refundTools; // has the real @Tool methods
@Test
void agentRefundsUsingTheTool() {
String answer = chatClientBuilder.build().prompt()
.user("Refund order ORD-4471, it arrived damaged.")
.tools(refundTools)
.call().content();
assertThat(answer).contains("refund");
// On replay: the recorded getRefundStatus/issueRefund result is injected.
// The real @Tool body NEVER runs — no charge, no email, no DB write.
}
}
First run records two fixtures — the model's tool request and, separately, the tool's recorded arguments/result pair. Every run after replays both, offline, and the real tool method's body never executes.
One caveat worth stating here, not buried at the end: this interception lives on the Spring-managed ToolCallingManager bean, so it only applies inside a Spring context. Use @SpringBootTest and you get isolation; a hand-built ChatClient.builder(model) outside the context has no such bean, nothing is wrapped, and the real @Tool runs.
Configuring model replay vs. tool mocking
There are two independent switches, and that separation is the whole design:
-
spring.ai.test.vcr.mode— governs the model call (record/replay the conversation). -
spring.ai.test.vcr.tool.mode— governs the tool invocation:-
REPLAY_FROM_CASSETTE(default): on a cassette hit, the recorded(tool name, arguments) → resultis returned directly. The real@Toolbody never executes. A side-effecting tool fires at most once, ever, per distinct arguments — when it was first recorded. -
EXECUTE_REAL: the real tool runs on every call. Use it for the one test that specifically wants to assert the real method was invoked, with the right arguments, the right number of times:
-
@Test
@VcrTool(mode = VcrToolMode.EXECUTE_REAL) // opt-in, this test only
void theRealRefundMethodIsInvokedOnce() { ... }
@VcrTool is the same escape-hatch shape as @Vcr — you loosen isolation for exactly one test without weakening it for every other test in the same run.
Testing the agent loop vs. unit-testing domain logic
With isolation on, this verifies the model calls the right tool with the right arguments, and your code handles the tool's result correctly. That's a contract test of the agent loop, and it's the part that's genuinely hard to test any other way.
It does not test your @Tool method's own business logic — the refund calculation, the DB write, the email formatting. That's a plain unit test of that method, with no model involved. Two concerns, two tests.
When the tool changes: stale fixtures
The tool fixture is keyed by the tool name and the arguments the model called it with. So if a signature change alters the call — a new required parameter, a renamed tool — the key changes, and in REPLAY_ONLY (your CI mode) that is a loud cache miss, not a silent pass: the build fails and tells you to re-record. Drift in the call is caught.
What is not caught automatically: if the method's behaviour changes while the name and arguments stay identical — same call, different real result — replay keeps returning the old recorded result until you re-record. This is true of every record/replay tool, VCR included, and it is why fixtures are committed and reviewed in pull requests: a stale result is a visible diff when you re-record, not a hidden runtime surprise.
Try it
A complete, offline example — a tool that records a side effect, proven not to fire on replay under isolation and to fire under EXECUTE_REAL — is in the companion repo (ToolSideEffectIsolationTest, ToolCallingRecordReplayTest). Every test runs with no Docker and no network.
- Repo: https://github.com/rifatcakir/spring-ai-test-tools
- Docs: https://rifatcakir.github.io/spring-ai-test-tools
- Examples: https://github.com/rifatcakir/spring-ai-test-tools-example
Independent, community project (not affiliated with Spring/Broadcom), Apache-2.0, 0.1.0, tested against Java 21 · Spring Boot 4.0.0 · Spring AI 2.0.0.
Top comments (0)