<?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>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>
