You wire up Spring AI, the ChatClient fluent API feels great, your feature works. Then you sit down to write a test — and every good option is bad.
A test that calls a real model is:
- Slow. A hosted call is a second or two; run it on every branch and it adds up. Run inference locally (Ollama + Testcontainers) and a cold call is ~47 s. Either way it's not a loop you run on save.
- Expensive. Every run of every test is billable tokens — times every developer, times every CI job.
- Unrepeatable. The same prompt can answer differently tomorrow. A test that asserts on model output is flaky by construction.
- Untestable in CI. No GPU, no model container, and putting a provider API key in a pipeline to run unit tests is a security problem, not a testing strategy.
The usual workarounds all hurt: Mockito means hand-building Spring AI's nested ChatResponse → Generation → AssistantMessage graph and asserting against a response you wrote; WireMock/MockWebServer means owning each provider's exact wire JSON, SSE frames, and tool-call envelopes, and rewriting it all when you switch providers; the real model is the four problems above, accepted rather than solved.
There's a well-worn answer from the HTTP world — Ruby's VCR, Python's vcrpy: record the real interaction once, replay it deterministically after. The catch is those work at the HTTP layer, and Spring AI's value is the abstraction above HTTP. So I built the same idea where Spring AI actually lives.
TL;DR — the whole integration
One dependency:
<dependency>
<groupId>io.github.rifatcakir</groupId>
<artifactId>spring-ai-test-tools</artifactId>
<version>0.1.0</version>
<scope>test</scope>
</dependency>
One property (src/test/resources/application-test.yml):
spring:
ai:
test:
vcr:
enabled: true
mode: RECORD_OR_REPLAY # REPLAY_ONLY in CI
Your test doesn't change at all — you write it exactly as you would against a real model:
@SpringBootTest
class OrderStatusTest {
@Autowired ChatClient.Builder chatClientBuilder;
@Test
void answersAQuestionAboutTheOrder() {
String answer = chatClientBuilder.build().prompt()
.user("What's the status of order ORD-4471?")
.call().content();
assertThat(answer).contains("shipped");
}
}
First run reaches a real model and writes src/test/resources/llm-cache/{sha256}.json — you commit that file. Every run after replays it in under a millisecond, offline.
Record once. Replay forever.
FIRST RUN slow · costs tokens · needs network
Your test ──▶ ChatClient ──▶ Real LLM ──writes──▶ cassette.json (committed)
EVERY RUN AFTER instant · $0 · fully offline
Your test ──▶ ChatClient ◀──reads── cassette.json (~0.8 ms)
The advisor attaches to every ChatClient.Builder in the context via ChatClientBuilderCustomizer — so nothing under test, and nothing in production, knows the cache exists. In CI you seal it with mode: REPLAY_ONLY: now a cache miss is a loud failure, not a silent call to a live model. The cache key is an exact SHA-256 over the canonical request; there is no fuzzy matching, ever. (This is why Spring AI's production semantic cache doesn't solve the testing problem — it matches on similarity thresholds, which is exactly backwards for a test.)
What you actually get
The point isn't a benchmark number — it's what disappears:
- No network call on replay. Zero HTTP requests (asserted by a request counter in the suite) — no latency, no timeouts, no rate limits, no flakiness.
- No tokens. Zero spend, every run, forever.
- Runs in a keyless, GPU-less CI. The thing that was impossible becomes the default.
- Deterministic. The same committed response, the same assertion, every run.
And yes, replay is ~0.8 ms (median over 200 timed iterations in a real Spring context) versus a warm hosted call of ~1–2 s or a local cold call of ~47 s — but treat that as a side effect. The real win is that the network, the cost, and the rate limits are simply gone.
When this is the wrong tool
Up front, because senior engineers rightly distrust silver bullets — this sits above the HTTP layer, so it cannot test that layer:
- Retry/backoff, timeouts, a 429 with
Retry-After, connection pooling, a body arriving malformed mid-stream → that's WireMock/MockWebServer, and they're the right tool. - Anything that isn't a model call → Mockito, as always.
- Proving the integration really works against a real provider → a genuine integration test before you ship. This doesn't replace that; it replaces running it on every commit.
It isn't just plain text
Each of these is verified against a real model, not assumed:
-
Tool / function calling. A
@Toolcall's name and arguments are part of the cache key, and on replay the recorded tool result is injected without re-running the real method — so a test can't accidentally write to your database or send an email. When you do want the real method to run, there's an opt-in mode. -
Streaming. A
Flux<ChatResponse>replays chunk-for-chunk — not a single-chunk fake — tool-call fragments included. -
Structured output. An
.entity(MyDto.class)call's target schema is part of the cache key, so two output types with the same prompt never collide. -
Embeddings.
EmbeddingModelcalls cache independently of chat; a replayed vector is exactly, not approximately, what was recorded. -
Spring AI's own evaluators.
RelevancyEvaluator/FactCheckingEvaluatorrun deterministically in CI (the judge call itself is recorded), or live on demand for a drift check.
Try it
- Repo: https://github.com/rifatcakir/spring-ai-test-tools
- Docs: https://rifatcakir.github.io/spring-ai-test-tools
- Runnable examples: https://github.com/rifatcakir/spring-ai-test-tools-example
Independent, community project (not affiliated with Spring/Broadcom), Apache-2.0, currently 0.1.0 and early — tested against Java 21 · Spring Boot 4.0.0 · Spring AI 2.0.0. If you try it, issues and feedback are genuinely wanted.
Top comments (0)