<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rıfat Çakır</title>
    <description>The latest articles on DEV Community by Rıfat Çakır (@rifatcakir).</description>
    <link>https://dev.to/rifatcakir</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071996%2F430867d9-8650-4bf5-98ce-8541e0b4963c.jpg</url>
      <title>DEV Community: Rıfat Çakır</title>
      <link>https://dev.to/rifatcakir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rifatcakir"/>
    <language>en</language>
    <item>
      <title>Mocking Spring AI @Tool calls via record/replay</title>
      <dc:creator>Rıfat Çakır</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:36:06 +0000</pubDate>
      <link>https://dev.to/rifatcakir/mocking-spring-ai-tool-calls-via-recordreplay-1lj4</link>
      <guid>https://dev.to/rifatcakir/mocking-spring-ai-tool-calls-via-recordreplay-1lj4</guid>
      <description>&lt;p&gt;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:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When my test makes the agent call &lt;code&gt;refundCustomer(...)&lt;/code&gt; or &lt;code&gt;sendEmail(...)&lt;/code&gt; or &lt;code&gt;deleteOrder(...)&lt;/code&gt; — does the &lt;strong&gt;real&lt;/strong&gt; method run? Will my test suite issue a real refund, send a real email, or write to a real database, on every CI run?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;em&gt;default&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Two clarifications up front. First, this isn't a hand-written mock or a stubbed &lt;code&gt;ChatModel&lt;/code&gt; — it records a &lt;strong&gt;real&lt;/strong&gt; 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 &lt;code&gt;ToolCallingManager&lt;/code&gt; bean — &lt;strong&gt;above the network layer&lt;/strong&gt;, where Spring AI actually runs your tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  How tool calling actually works in Spring AI
&lt;/h2&gt;

&lt;p&gt;A tool call isn't one request — it's a loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your code sends a prompt, with tools registered.&lt;/li&gt;
&lt;li&gt;The model replies "I want to call &lt;code&gt;getOrderStatus("ORD-4471")&lt;/code&gt;."&lt;/li&gt;
&lt;li&gt;Spring AI's &lt;code&gt;ToolCallingManager&lt;/code&gt; &lt;strong&gt;runs your real &lt;code&gt;@Tool&lt;/code&gt; method&lt;/strong&gt; and feeds the result back to the model.&lt;/li&gt;
&lt;li&gt;The model produces the final answer using that result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the isolation actually works&lt;/strong&gt; (no magic): the library replaces the Spring-managed &lt;code&gt;ToolCallingManager&lt;/code&gt; with a thin wrapper, &lt;code&gt;VcrToolCallingManager&lt;/code&gt;. On a cassette hit it returns the recorded &lt;code&gt;(tool name, arguments) → result&lt;/code&gt; directly and never reaches the dispatcher that would invoke your &lt;code&gt;@Tool&lt;/code&gt;; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the default is already safe
&lt;/h2&gt;

&lt;p&gt;Tool isolation is on by default; you don't configure anything to get it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;vcr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RECORD_OR_REPLAY&lt;/span&gt;
        &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REPLAY_FROM_CASSETTE&lt;/span&gt;   &lt;span class="c1"&gt;# default — full isolation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundAgentTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;chatClientBuilder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="nc"&gt;RefundTools&lt;/span&gt; &lt;span class="n"&gt;refundTools&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// has the real @Tool methods&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;agentRefundsUsingTheTool&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClientBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Refund order ORD-4471, it arrived damaged."&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refundTools&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"refund"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// On replay: the recorded getRefundStatus/issueRefund result is injected.&lt;/span&gt;
        &lt;span class="c1"&gt;// The real @Tool body NEVER runs — no charge, no email, no DB write.&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First run records &lt;strong&gt;two&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;One caveat worth stating here, not buried at the end: this interception lives on the Spring-managed &lt;code&gt;ToolCallingManager&lt;/code&gt; bean, so it only applies inside a Spring context. Use &lt;code&gt;@SpringBootTest&lt;/code&gt; and you get isolation; a hand-built &lt;code&gt;ChatClient.builder(model)&lt;/code&gt; outside the context has no such bean, nothing is wrapped, and the real &lt;code&gt;@Tool&lt;/code&gt; runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring model replay vs. tool mocking
&lt;/h2&gt;

&lt;p&gt;There are two independent switches, and that separation is the whole design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;spring.ai.test.vcr.mode&lt;/code&gt; — governs the &lt;strong&gt;model&lt;/strong&gt; call (record/replay the conversation).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spring.ai.test.vcr.tool.mode&lt;/code&gt; — governs the &lt;strong&gt;tool&lt;/strong&gt; invocation:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REPLAY_FROM_CASSETTE&lt;/code&gt;&lt;/strong&gt; (default): on a cassette hit, the recorded &lt;code&gt;(tool name, arguments) → result&lt;/code&gt; is returned directly. The real &lt;code&gt;@Tool&lt;/code&gt; body &lt;strong&gt;never executes&lt;/strong&gt;. A side-effecting tool fires at most once, ever, per distinct arguments — when it was first recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EXECUTE_REAL&lt;/code&gt;&lt;/strong&gt;: 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:
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="nd"&gt;@VcrTool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VcrToolMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EXECUTE_REAL&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// opt-in, this test only&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;theRealRefundMethodIsInvokedOnce&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@VcrTool&lt;/code&gt; is the same escape-hatch shape as &lt;code&gt;@Vcr&lt;/code&gt; — you loosen isolation for exactly one test without weakening it for every other test in the same run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the agent loop vs. unit-testing domain logic
&lt;/h2&gt;

&lt;p&gt;With isolation on, this verifies &lt;strong&gt;the model calls the right tool with the right arguments, and your code handles the tool's result correctly.&lt;/strong&gt; That's a contract test of the agent loop, and it's the part that's genuinely hard to test any other way.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; test your &lt;code&gt;@Tool&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the tool changes: stale fixtures
&lt;/h2&gt;

&lt;p&gt;The tool fixture is keyed by the &lt;strong&gt;tool name and the arguments&lt;/strong&gt; 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 &lt;code&gt;REPLAY_ONLY&lt;/code&gt; (your CI mode) that is a &lt;strong&gt;loud cache miss, not a silent pass&lt;/strong&gt;: the build fails and tells you to re-record. Drift in the &lt;em&gt;call&lt;/em&gt; is caught.&lt;/p&gt;

&lt;p&gt;What is &lt;strong&gt;not&lt;/strong&gt; caught automatically: if the method's &lt;strong&gt;behaviour&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;A complete, offline example — a tool that records a side effect, proven &lt;em&gt;not&lt;/em&gt; to fire on replay under isolation and &lt;em&gt;to&lt;/em&gt; fire under &lt;code&gt;EXECUTE_REAL&lt;/code&gt; — is in the companion repo (&lt;code&gt;ToolSideEffectIsolationTest&lt;/code&gt;, &lt;code&gt;ToolCallingRecordReplayTest&lt;/code&gt;). Every test runs with no Docker and no network.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/rifatcakir/spring-ai-test-tools" rel="noopener noreferrer"&gt;https://github.com/rifatcakir/spring-ai-test-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://rifatcakir.github.io/spring-ai-test-tools" rel="noopener noreferrer"&gt;https://rifatcakir.github.io/spring-ai-test-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Examples: &lt;a href="https://github.com/rifatcakir/spring-ai-test-tools-example" rel="noopener noreferrer"&gt;https://github.com/rifatcakir/spring-ai-test-tools-example&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Independent, community project (not affiliated with Spring/Broadcom), Apache-2.0, &lt;code&gt;0.1.0&lt;/code&gt;, tested against Java 21 · Spring Boot 4.0.0 · Spring AI 2.0.0.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Spring AI tests are slow, flaky, and cost money. Here's how to make them deterministic.</title>
      <dc:creator>Rıfat Çakır</dc:creator>
      <pubDate>Tue, 11 Aug 2026 10:07:48 +0000</pubDate>
      <link>https://dev.to/rifatcakir/your-spring-ai-tests-are-slow-flaky-and-cost-money-heres-how-to-make-them-deterministic-1og7</link>
      <guid>https://dev.to/rifatcakir/your-spring-ai-tests-are-slow-flaky-and-cost-money-heres-how-to-make-them-deterministic-1og7</guid>
      <description>&lt;p&gt;You wire up Spring AI, the &lt;code&gt;ChatClient&lt;/code&gt; fluent API feels great, your feature works. Then you sit down to write a test — and every good option is bad.&lt;/p&gt;

&lt;p&gt;A test that calls a real model is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expensive.&lt;/strong&gt; Every run of every test is billable tokens — times every developer, times every CI job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unrepeatable.&lt;/strong&gt; The same prompt can answer differently tomorrow. A test that asserts on model output is flaky &lt;em&gt;by construction&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untestable in CI.&lt;/strong&gt; No GPU, no model container, and putting a provider API key in a pipeline to run &lt;em&gt;unit tests&lt;/em&gt; is a security problem, not a testing strategy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual workarounds all hurt: &lt;strong&gt;Mockito&lt;/strong&gt; means hand-building Spring AI's nested &lt;code&gt;ChatResponse → Generation → AssistantMessage&lt;/code&gt; graph and asserting against a response &lt;em&gt;you&lt;/em&gt; wrote; &lt;strong&gt;WireMock/MockWebServer&lt;/strong&gt; means owning each provider's exact wire JSON, SSE frames, and tool-call envelopes, and rewriting it all when you switch providers; &lt;strong&gt;the real model&lt;/strong&gt; is the four problems above, accepted rather than solved.&lt;/p&gt;

&lt;p&gt;There's a well-worn answer from the HTTP world — Ruby's VCR, Python's &lt;code&gt;vcrpy&lt;/code&gt;: 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 &lt;em&gt;above&lt;/em&gt; HTTP. So I built the same idea where Spring AI actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the whole integration
&lt;/h2&gt;

&lt;p&gt;One dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.github.rifatcakir&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-test-tools&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;scope&amp;gt;&lt;/span&gt;test&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One property (&lt;code&gt;src/test/resources/application-test.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;vcr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RECORD_OR_REPLAY&lt;/span&gt;   &lt;span class="c1"&gt;# REPLAY_ONLY in CI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your test doesn't change at all — you write it exactly as you would against a real model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;chatClientBuilder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;answersAQuestionAboutTheOrder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClientBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What's the status of order ORD-4471?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"shipped"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First run reaches a real model and writes &lt;code&gt;src/test/resources/llm-cache/{sha256}.json&lt;/code&gt; — &lt;strong&gt;you commit that file.&lt;/strong&gt; Every run after replays it in under a millisecond, offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record once. Replay forever.
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advisor attaches to every &lt;code&gt;ChatClient.Builder&lt;/code&gt; in the context via &lt;code&gt;ChatClientBuilderCustomizer&lt;/code&gt; — so &lt;strong&gt;nothing under test, and nothing in production, knows the cache exists.&lt;/strong&gt; In CI you seal it with &lt;code&gt;mode: REPLAY_ONLY&lt;/code&gt;: now a cache miss is a &lt;em&gt;loud failure&lt;/em&gt;, 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 &lt;em&gt;production&lt;/em&gt; semantic cache doesn't solve the testing problem — it matches on similarity thresholds, which is exactly backwards for a test.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;The point isn't a benchmark number — it's what disappears:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No network call on replay.&lt;/strong&gt; Zero HTTP requests (asserted by a request counter in the suite) — no latency, no timeouts, no rate limits, no flakiness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tokens.&lt;/strong&gt; Zero spend, every run, forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs in a keyless, GPU-less CI.&lt;/strong&gt; The thing that was impossible becomes the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic.&lt;/strong&gt; The same committed response, the same assertion, every run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Up front, because senior engineers rightly distrust silver bullets — this sits &lt;em&gt;above&lt;/em&gt; the HTTP layer, so it cannot test that layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retry/backoff, timeouts, a 429 with &lt;code&gt;Retry-After&lt;/code&gt;, connection pooling, a body arriving malformed mid-stream → that's &lt;strong&gt;WireMock/MockWebServer&lt;/strong&gt;, and they're the right tool.&lt;/li&gt;
&lt;li&gt;Anything that isn't a model call → &lt;strong&gt;Mockito&lt;/strong&gt;, as always.&lt;/li&gt;
&lt;li&gt;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 &lt;em&gt;every&lt;/em&gt; commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  It isn't just plain text
&lt;/h2&gt;

&lt;p&gt;Each of these is verified against a real model, not assumed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool / function calling.&lt;/strong&gt; A &lt;code&gt;@Tool&lt;/code&gt; call's name and arguments are part of the cache key, and on replay the recorded tool result is injected &lt;strong&gt;without re-running the real method&lt;/strong&gt; — so a test can't accidentally write to your database or send an email. When you &lt;em&gt;do&lt;/em&gt; want the real method to run, there's an opt-in mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming.&lt;/strong&gt; A &lt;code&gt;Flux&amp;lt;ChatResponse&amp;gt;&lt;/code&gt; replays chunk-for-chunk — not a single-chunk fake — tool-call fragments included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured output.&lt;/strong&gt; An &lt;code&gt;.entity(MyDto.class)&lt;/code&gt; call's target schema is part of the cache key, so two output types with the same prompt never collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings.&lt;/strong&gt; &lt;code&gt;EmbeddingModel&lt;/code&gt; calls cache independently of chat; a replayed vector is exactly, not approximately, what was recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring AI's own evaluators.&lt;/strong&gt; &lt;code&gt;RelevancyEvaluator&lt;/code&gt; / &lt;code&gt;FactCheckingEvaluator&lt;/code&gt; run deterministically in CI (the judge call itself is recorded), or live on demand for a drift check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/rifatcakir/spring-ai-test-tools" rel="noopener noreferrer"&gt;https://github.com/rifatcakir/spring-ai-test-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://rifatcakir.github.io/spring-ai-test-tools" rel="noopener noreferrer"&gt;https://rifatcakir.github.io/spring-ai-test-tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Runnable examples: &lt;a href="https://github.com/rifatcakir/spring-ai-test-tools-example" rel="noopener noreferrer"&gt;https://github.com/rifatcakir/spring-ai-test-tools-example&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Independent, community project (not affiliated with Spring/Broadcom), Apache-2.0, currently &lt;code&gt;0.1.0&lt;/code&gt; 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.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>testing</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
