<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Testing - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/testing/</link><description>Testing - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Tue, 17 Mar 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Testing - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/testing/</link></image><atom:link href="https://tracyatteberry.com/tags/testing/" rel="self" type="application/rss+xml"/><item><title>Building MockOpenAI: a weekend MVP story</title><link>https://tracyatteberry.com/posts/mockopenai/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="building-mockopenai-a-weekend-mvp-story">Building MockOpenAI: a weekend MVP story</h1>
<p>Last weekend I built and published a Ruby gem. From idea to published thing
in about four days. Here&rsquo;s how it went, including the part where I had to
reconsider whether I&rsquo;d built something useful at all.</p>
<h2 id="friday-20-ideas-one-bet">Friday: 20 ideas, one bet</h2>
<p>I&rsquo;m between jobs right now. Good position to be in if you like building things,
terrible position to be in if you like eating. So I&rsquo;ve been running a little
experiment: each weekend, pick one small idea and see if I can ship it.</p>
<p>Friday&rsquo;s job was to generate and pick an idea. I sat down with an AI and
brainstormed 20 candidates. Developer tools. Content products. Micro-SaaS. I
narrowed it down to one: a local mock server for testing OpenAI-compatible
APIs.</p>
<p>The pitch to myself was simple. I write a lot of Ruby. I write a lot of tests.
Testing code that talks to LLMs is a bit annoying because, while the happy
path is easy to mock, some of the failure modes and edge cases can be more of a
pain. There had to be a better way.</p>
<p>By end of day Friday I had a repo, a gemspec, and a clear plan.</p>
<h2 id="saturday-build-day">Saturday: build day</h2>
<p>The core idea for MockOpenAI is that it&rsquo;s a real HTTP server running on
localhost, not a mock object or a stub. Your application code talks to it
exactly the way it would talk to the LLM provider in production. You just point
your client at <code>http://localhost:4000</code> instead of the usaul API endpoint.</p>
<p>That distinction makes a difference, I think. With a real HTTP server you can
test things that object-level mocking can&rsquo;t touch: actual TCP timeouts,
truncated streaming responses, retry headers. The kind of failure modes that
bite you in production but never show up in your test suite because you stubbed
them away.</p>
<p>The architecture is deliberately simple. A Rack server reads a shared JSON
state file on every request. Your tests write rules to that file. The server is
stateless. No client wrapping, no monkey-patching, no magic.</p>
<p>Here&rsquo;s what using it looks like:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles a rate limit&#34;</span><span class="p">,</span> <span class="ss">:mock_openai_rate_limit</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">call_llm</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">RateLimitError</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step1</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;OK&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">step2</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">Timeout</span><span class="o">::</span><span class="no">Error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step3</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;Done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The failure modes are:</p>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>:timeout</code></td>
					<td>Sleeps then closes the connection without responding</td>
			</tr>
			<tr>
					<td><code>:rate_limit</code></td>
					<td>Returns HTTP 429 with an OpenAI-format error body</td>
			</tr>
			<tr>
					<td><code>:malformed_json</code></td>
					<td>Returns truncated JSON that causes a parse error in your client</td>
			</tr>
			<tr>
					<td><code>:internal_error</code></td>
					<td>Returns HTTP 500</td>
			</tr>
			<tr>
					<td><code>:truncated_stream</code></td>
					<td>Sends partial SSE chunks then closes the connection</td>
			</tr>
	</tbody>
</table>
<p>You can also mix success and failure in a single test:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Saturday was productive. By end of day I had all the core classes written
TDD-style: <code>Config</code>, <code>State</code>, <code>Matcher</code>, <code>ResponseBuilder</code>,
<code>TemplateRenderer</code>, all five failure mode classes. The code was written, the
tests passed, and life was good.</p>
<h2 id="sunday-documentation-and-shipping">Sunday: documentation and shipping</h2>
<p>Sunday was docs day. I set up a Jekyll site and wrote the README. I added an
Anthropic endpoint too, because my personal projects use both.</p>
<p>I also migrated the first personal project to use MockOpenAI. That went
smoothly. The HTTP-level fidelity made a few tests a little more honest than
they&rsquo;d been with simple stubs at the client level.</p>
<h2 id="monday-the-uncomfortable-question">Monday: the uncomfortable question</h2>
<p>Monday I migrated a second personal project. This one used a helper module I&rsquo;d
written a while back to stub LLM calls. Just a few lines of code. It worked fine
for that project.</p>
<p>I stared at that code for a while. Here it is:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">RubyLLMMocks</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">error</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_raise</span><span class="p">(</span><span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_response</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Message</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">inspect</span><span class="p">:</span> <span class="s2">&#34;RubyLLM::Message(content: </span><span class="si">#{</span><span class="n">content</span><span class="o">.</span><span class="n">inspect</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_chat</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="p">,</span> <span class="ss">ask</span><span class="p">:</span> <span class="n">mock_response</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_return</span><span class="p">(</span><span class="n">mock_chat</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Example:</span>
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles general ruby_llm errors gracefully&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">error</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Error</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="kp">nil</span><span class="p">,</span> <span class="s2">&#34;Unexpected error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">error</span><span class="p">:</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">generator</span> <span class="o">=</span> <span class="n">described_class</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="n">generator</span><span class="o">.</span><span class="n">generate</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">to</span> <span class="n">output</span><span class="p">(</span><span class="sr">/Error.*Unexpected error/m</span><span class="p">)</span><span class="o">.</span><span class="n">to_stdout</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">and</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">SystemExit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s it. Fifteen lines, no gem dependency, works perfectly for a project that
uses RubyLLM as a wrapper. The error case is handled with <code>and_raise</code>. Clean.</p>
<p>So the question had to be asked: did I just build a solution in search of a
problem?</p>
<p>After sitting with it, I don&rsquo;t think so. But I did have to sharpen my thinking
about <em>when</em> MockOpenAI actually earns its place versus when a helper method is
the right call.</p>
<p>The short version: if you&rsquo;re using a wrapper library like RubyLLM for all your
LLM calls, and you only need happy-path responses and exception simulation in
unit tests, the 15-line helper is probably the right answer. It&rsquo;s less to
maintain, has no extra dependencies, and does the job.</p>
<p>MockOpenAI is the right call when you need the actual HTTP layer in the
picture. When you&rsquo;re using the raw OpenAI or Anthropic client directly. When
you&rsquo;re running integration or system tests that make real HTTP connections.
When you need to test what happens when TCP actually times out, or when a
streaming response gets cut off halfway through, or when your retry logic
processes a <code>Retry-After</code> header.</p>
<p>Those are real problems. They&rsquo;re just not every project&rsquo;s problems. I added a
<a href="https://grymoire7.github.io/mockopenai/when-not-to-use.html" target="_blank" rel="noopener noreffer ">when not to use</a>
page to the docs to make the tradeoffs explicit.</p>
<h2 id="what-id-do-differently">What I&rsquo;d do differently</h2>
<p>One thing I&rsquo;d change: I&rsquo;d research the problem space a bit more to make sure I
had a better understanding of problem scope and existing solution. (Especially
ones I wrote myself!) The tool is solid, but I made some assumptions about the
breadth of problems it would solve for. That&rsquo;s a classic weekend MVP trap I
suppose. You&rsquo;re so focused on building that you skip the a bit of due diligence
you think you don&rsquo;t need.</p>
<p>The gem is published, the docs are live, and it works. The scope is narrower
than I originally thought, but the use case is real. That feels like an honest
result for a long weekend.</p>
<hr>
<p><em>MockOpenAI is a Ruby gem for testing OpenAI-compatible and Anthropic APIs
locally. References:</em></p>
<ul>
<li>Source: <a href="https://github.com/grymoire7/mockopenai" target="_blank" rel="noopener noreffer ">github.com/grymoire7/mockopenai</a>.</li>
<li>Docs: <a href="https://grymoire7.github.io/mockopenai" target="_blank" rel="noopener noreffer ">grymoire7.github.io/mockopenai</a>.</li>
<li>Landing page: <a href="https://tracyatteberry.com/mockopenai" target="_blank" rel="noopener noreffer ">tracyatteberry.com/mockopenai</a>.</li>
</ul>
]]></description></item></channel></rss>