<?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: Angelina Gupta</title>
    <description>The latest articles on DEV Community by Angelina Gupta (@angelina_gupta).</description>
    <link>https://dev.to/angelina_gupta</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%2F4144400%2F3c1ce4a4-9d59-42fa-ab10-f2fa00c24c7e.png</url>
      <title>DEV Community: Angelina Gupta</title>
      <link>https://dev.to/angelina_gupta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/angelina_gupta"/>
    <language>en</language>
    <item>
      <title>I ran Keploy on my MERN app. The scariest result was a green one.</title>
      <dc:creator>Angelina Gupta</dc:creator>
      <pubDate>Sun, 27 Sep 2026 10:17:26 +0000</pubDate>
      <link>https://dev.to/angelina_gupta/i-ran-keploy-on-my-mern-app-the-scariest-result-was-a-green-one-331e</link>
      <guid>https://dev.to/angelina_gupta/i-ran-keploy-on-my-mern-app-the-scariest-result-was-a-green-one-331e</guid>
      <description>&lt;p&gt;Nine of my API tests were getting &lt;code&gt;401 Unauthorized&lt;/code&gt; instead of &lt;code&gt;200&lt;/code&gt;. Keploy said the run passed. Exit code 0.&lt;/p&gt;

&lt;p&gt;That was the last thing I found. Here's how I got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I tried this
&lt;/h2&gt;

&lt;p&gt;Honestly, the first reason is that I'm applying for the DevRel internship at Keploy. The second is that I wanted to see how API testing actually works on a real app.&lt;/p&gt;

&lt;p&gt;TaskFlow is my MERN project management app. It has an eval harness for its AI features, but the Express API itself had no tests at all. So I pointed Keploy at it and wrote down everything that happened, including the parts that went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; TaskFlow backend (Express, MongoDB, JWT auth, and AI endpoints that call Groq)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keploy:&lt;/strong&gt; 3.8.44, Free, running natively on my MacBook Air (Apple Silicon), no Docker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; a throwaway local MongoDB, not my real Atlas database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic:&lt;/strong&gt; 12 requests covering register, login, workspace, project, task, read, update, delete, one AI call, and one request with a bad token
Recording is one command:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keploy record &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"node server.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;node&lt;/code&gt;, not &lt;code&gt;nodemon&lt;/code&gt;. A restarting process confuses the recorder.&lt;/p&gt;

&lt;p&gt;My first attempt just hung for five and a half minutes with no output. It turned out my Desktop folder syncs to iCloud, and most of &lt;code&gt;node_modules&lt;/code&gt; had been offloaded, so Node was waiting on iCloud to download files one by one. That's not Keploy's fault. But Keploy also never told me that nothing was listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: 5 out of 12
&lt;/h2&gt;

&lt;p&gt;When I stopped recording, Keploy immediately replayed what it had just recorded. I didn't ask it to; that's the default. 7 of 12 tests failed.&lt;/p&gt;

&lt;p&gt;I did not expect 7 of my endpoints to fail. Then I read the diffs, and none of them were bugs. They were values that are different every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB &lt;code&gt;_id&lt;/code&gt;s.&lt;/strong&gt; Mongoose generates them inside the app, so they can't come from the mocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWTs and the refresh-token cookie.&lt;/strong&gt; They're freshly signed, with new timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Etag&lt;/code&gt; headers.&lt;/strong&gt; Express hashes the response body, so if anything in the body changes, the Etag changes too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;joinedAt&lt;/code&gt;.&lt;/strong&gt; A timestamp.
The fix was nine lines of &lt;code&gt;globalNoise&lt;/code&gt; in &lt;code&gt;keploy.yml&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;globalNoise&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;project._id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{24}$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;task._id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{24}$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;workspace._id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{24}$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;workspace.members._id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{24}$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;user.id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^[0-9a-f]{24}$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;workspace.members.joinedAt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;Etag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^W/"[0-9a-f]+-[A-Za-z0-9+/]{27}"$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;Set-Cookie&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^refreshToken=eyJ[A-Za-z0-9_.-]+;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Max-Age=604800;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Path=/;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Expires=[^;]+;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HttpOnly;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SameSite=Lax$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used regex patterns instead of empty arrays on purpose. An empty array ignores the field completely. A pattern still checks that the new value looks right, so a &lt;code&gt;null&lt;/code&gt; or a malformed ID would still fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Keploy showed me about my own app
&lt;/h2&gt;

&lt;p&gt;This was the part I didn't expect. Keploy records every database and network call, so reading the mocks felt like reading my backend's diary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A background write I'd forgotten about.&lt;/strong&gt; When a task is created, TaskFlow computes an embedding and saves it to MongoDB, but it doesn't wait for that before responding. Keploy caught these writes landing in the &lt;em&gt;next&lt;/em&gt; test's time window. When they failed, my app just logged &lt;code&gt;embedding failed&lt;/code&gt; and moved on. No error, nothing visible to the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;PUT /api/tasks/:id&lt;/code&gt; makes 33 database calls.&lt;/strong&gt; The next-highest endpoint makes 9. I have no idea why yet, and I'm going to find out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A 37 MB download hiding at runtime.&lt;/strong&gt; The first time a task is created, TaskFlow downloads an embedding model from HuggingFace. Keploy captured all of it, and that one download made up about 98% of &lt;code&gt;mocks.yaml&lt;/code&gt;. After I pre-downloaded the model outside Keploy, &lt;code&gt;mocks.yaml&lt;/code&gt; went from 37.7 MB to 286 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Green: 12 out of 12
&lt;/h2&gt;

&lt;p&gt;With the noise config and the model cached, I re-recorded and ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keploy &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"node server.js"&lt;/span&gt; &lt;span class="nt"&gt;--delay&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;12 out of 12 passed.&lt;/p&gt;

&lt;p&gt;The part I'd actually use every day: the Groq call is mocked too. The AI endpoint test replays the recorded LLM response instead of calling Groq. So it's deterministic, it's free, and it doesn't break because the model phrased something differently today.&lt;/p&gt;

&lt;p&gt;One honest caveat: the background embedding write from point 1 is still a race. It only lined up on this run because the cached model made everything faster. The test is green, but the race is still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green run that wasn't
&lt;/h2&gt;

&lt;p&gt;My access tokens expire after 15 minutes. Keploy replays the exact token it recorded. So I waited and ran the tests again, 16 minutes after recording.&lt;/p&gt;

&lt;p&gt;Nine tests got &lt;code&gt;401 Not authorized&lt;/code&gt; instead of the recorded &lt;code&gt;200&lt;/code&gt;/&lt;code&gt;201&lt;/code&gt;. Here's what Keploy reported:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftm8ulyq4pcq5jlptlheo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftm8ulyq4pcq5jlptlheo.png" alt="Keploy test summary: 12 tests, 3 passed, 0 failed, 9 obsolete" width="798" height="222"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;)&lt;br&gt;
&lt;em&gt;Default run, 16 minutes after recording.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The other nine were marked &lt;strong&gt;obsolete&lt;/strong&gt;, not failed. The report said &lt;code&gt;PASSED&lt;/code&gt;, and the process exited with code 0. In CI, that's a green check on top of nine broken endpoints.&lt;/p&gt;

&lt;p&gt;Keploy's summary said the mocks were probably stale and suggested re-recording, or running with &lt;code&gt;--update-test-mapping&lt;/code&gt;. But the real cause was the 401. Updating the mappings would have quietly rewritten the tests to match the broken behavior.&lt;/p&gt;

&lt;p&gt;Here's why it happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My auth middleware rejects the expired token before touching the database.&lt;/li&gt;
&lt;li&gt;So the test uses none of the database mocks it recorded.&lt;/li&gt;
&lt;li&gt;Keploy sees "different mocks than expected" and marks the test obsolete.&lt;/li&gt;
&lt;li&gt;By default, obsolete doesn't count as a failure.
This is documented. The &lt;code&gt;--help&lt;/code&gt; text for &lt;code&gt;--assert-dependencies&lt;/code&gt; says such a test is "demoted to OBSOLETE today and the run still exits 0." I just hadn't read it until I went looking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's what stuck with me: not all bugs are easy to see, and they can still be fatal. A test suite that can't go red isn't protecting you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about &lt;code&gt;--freezeTime&lt;/code&gt;?&lt;/strong&gt; It's meant for exactly this: it pins the app's clock to the recording time. But the docs list it as an Enterprise feature for Linux, WSL and Docker. On native macOS, Keploy tried to inject a Linux library, logged an error saying time freezing couldn't be verified, and ran the tests anyway. Same result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one flag
&lt;/h2&gt;

&lt;p&gt;Same recording, same expired tokens:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Passed&lt;/th&gt;
&lt;th&gt;Failed&lt;/th&gt;
&lt;th&gt;Obsolete&lt;/th&gt;
&lt;th&gt;Report&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;PASSED&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--strict-failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;FAILED&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--assert-dependencies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;FAILED&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66rj0qfwwdpbakigzpxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66rj0qfwwdpbakigzpxu.png" alt="Keploy test summary with --strict-failure: 12 tests, 3 passed, 9 failed" width="746" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;)&lt;br&gt;
&lt;em&gt;Same tests, same expired tokens, with &lt;code&gt;--strict-failure&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--strict-failure&lt;/code&gt; fails a test when its &lt;strong&gt;response&lt;/strong&gt; is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--assert-dependencies&lt;/code&gt; fails a test when its expected &lt;strong&gt;database or network calls never happened&lt;/strong&gt;. That catches cases where the response still matches but the logic behind it has vanished.
With &lt;code&gt;--strict-failure&lt;/code&gt;, Keploy's summary also named the real cause (&lt;code&gt;expected=200 got=401&lt;/code&gt;) instead of blaming stale mocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run Keploy in CI, add one of these. I'd use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch your secrets
&lt;/h2&gt;

&lt;p&gt;A short checklist, because I learned this the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outbound calls are recorded with their headers.&lt;/strong&gt; My Groq API key ended up in &lt;code&gt;mocks.yaml&lt;/code&gt; in plain text. Check your mocks before you commit or share anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report upload and mock upload are separate settings.&lt;/strong&gt; I turned off report upload, but my mocks were still uploaded to Keploy's registry on the green run. If you don't want that, set &lt;code&gt;test.disableMockUpload: true&lt;/code&gt; as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mocks.yaml&lt;/code&gt; is gitignored by default.&lt;/strong&gt; That's good for git, but a search tool that respects &lt;code&gt;.gitignore&lt;/code&gt; will skip the one file most likely to contain your keys.
I rotated my Groq key. Twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would I recommend it?
&lt;/h2&gt;

&lt;p&gt;Yes, to check your code's reliability before it hits production. Recording real traffic and getting tests plus mocks back, including for LLM calls, is genuinely useful, and the logs explained almost everything I ran into.&lt;/p&gt;

&lt;p&gt;My one warning: don't trust the default green. Run with &lt;code&gt;--strict-failure&lt;/code&gt;, and read your mocks for secrets.&lt;/p&gt;

&lt;p&gt;My last big bug was a token cap silently truncating JSON in 26% of TaskFlow's AI requests, and the lesson here was the same: a green check isn't proof that things work. &lt;a href="https://dev.to/angelina_gupta/my-ai-feature-was-failing-26-of-the-time-nothing-looked-broken-3c5f"&gt;I wrote about that one here.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>node</category>
      <category>mongodb</category>
      <category>api</category>
    </item>
    <item>
      <title>My AI feature was failing 26% of the time. Nothing looked broken.</title>
      <dc:creator>Angelina Gupta</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:44:43 +0000</pubDate>
      <link>https://dev.to/angelina_gupta/my-ai-feature-was-failing-26-of-the-time-nothing-looked-broken-3c5f</link>
      <guid>https://dev.to/angelina_gupta/my-ai-feature-was-failing-26-of-the-time-nothing-looked-broken-3c5f</guid>
      <description>&lt;p&gt;I built TaskFlow, a project management app with an AI feature called Quick Add. You type &lt;em&gt;"assign the API docs to Priya by Friday"&lt;/em&gt; and it creates a task with a title, an assignee, and a due date.&lt;/p&gt;

&lt;p&gt;For twelve days in August, it quietly stopped doing that for about a quarter of requests. Users got a task titled &lt;em&gt;"assign the API docs to Priya by Friday"&lt;/em&gt; — no assignee, no date. No error. No crash. Just a worse product.&lt;/p&gt;

&lt;p&gt;My test suite caught it on day one. I didn't look until day twelve. This post is about both of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLM features need their own tests
&lt;/h2&gt;

&lt;p&gt;A normal unit test checks that a function returned. It can't tell you whether the model got the date right. LLM output looks fluent even when it's wrong, so "it didn't crash" means very little.&lt;/p&gt;

&lt;p&gt;So I built an eval harness: &lt;strong&gt;71 test cases across 4 prompt suites&lt;/strong&gt; — quick-add (50), extract-tasks (9), decompose (6), and today (6). Each case is an input plus the fields the model should return.&lt;/p&gt;

&lt;p&gt;A few decisions that turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It imports the real production prompts&lt;/strong&gt;, not copies. If a prompt changes, the tests test the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring is deterministic.&lt;/strong&gt; No LLM grading another LLM — dates and assignees either match or they don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The clock is pinned&lt;/strong&gt;, so "Friday" means the same date on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It mirrors production's token limits.&lt;/strong&gt; This one ends up being the whole story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It runs in GitHub Actions&lt;/strong&gt;: a small subset on every pull request, the full suite nightly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It separates a wrong answer from a failed request.&lt;/strong&gt; Rate-limit errors get retried, so a 429 doesn't show up as a quality failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;Groq retired the model I was using, &lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt;, on August 16. I switched to &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; on August 24.&lt;/p&gt;

&lt;p&gt;The nightly suite went red that same night. It stayed red for twelve consecutive nights before I looked properly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04yl4du1mn4vflezb7gx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04yl4du1mn4vflezb7gx.png" alt="GitHub Actions run history showing the nightly AI evals workflow failing on every scheduled run from August 25 to September 3" width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things kept it hidden:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The fallback worked too well.&lt;/strong&gt; When Quick Add couldn't parse the model's output, it used the raw text as the task title. That's the right design — the app stays usable — but it meant there was no error to notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error looked like someone else's problem.&lt;/strong&gt; 13 of the 15 failures were &lt;code&gt;400 Failed to validate JSON&lt;/code&gt;. That reads like an infrastructure issue, so my first instinct was to blame the provider.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Finding the cause
&lt;/h2&gt;

&lt;p&gt;The error message was a dead end. The distribution wasn't.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;&lt;code&gt;max_tokens&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;quick-add&lt;/td&gt;
&lt;td&gt;35/50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;extract-tasks&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decompose&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;1800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;today&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every failure was in the one suite with the smallest token limit.&lt;/p&gt;

&lt;p&gt;The token data confirmed it. Of the quick-add cases that returned, the five largest completions were &lt;strong&gt;283, 284, 288, 298 and 298&lt;/strong&gt; tokens — jammed right up against the 300 limit. The old model's largest was 64.&lt;/p&gt;

&lt;p&gt;The new model is a &lt;strong&gt;reasoning model&lt;/strong&gt;. It spends tokens thinking before it writes any JSON. With a 300-token cap, it was running out mid-object and returning truncated JSON, which the provider then rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — and what it uncovered
&lt;/h2&gt;

&lt;p&gt;The first fix was one number: &lt;code&gt;max_tokens&lt;/code&gt; from 300 to 900, in two places that must always match — the production controller and the eval harness.&lt;/p&gt;

&lt;p&gt;That exposed three more bugs the 400s had been hiding. Once the model could finish its answers, three date cases returned &lt;code&gt;due: null&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"by end of week"&lt;/li&gt;
&lt;li&gt;"by July 20"&lt;/li&gt;
&lt;li&gt;"in two weeks"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt gave the model a 10-day calendar and told it never to compute weekdays itself. So anything outside that window came back empty. The proof: "in 3 days" and "by July 9" already passed, because both fall inside it.&lt;/p&gt;

&lt;p&gt;I split the rule. Weekday phrases still resolve from the calendar; absolute dates and offsets are computed forward from today. The prompt now says the calendar is &lt;em&gt;"a 10-day window, not a limit on what you may answer."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Priority had a similar gap — it only recognised words like "asap" or "critical", so "this is blocking the release" came back as unstated. It now judges described impact, not tone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick-add went from 35/50 to 50/50. The full suite went from 78.9% to 98.6%.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands today
&lt;/h2&gt;

&lt;p&gt;As of the latest nightly, it's &lt;strong&gt;69/71 — 97.2%&lt;/strong&gt;. Two cases fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One is a genuine quality miss.&lt;/strong&gt; The "today" planner includes a task it should filter out. I haven't fixed it because it's a ranking judgment, not a rule I can state cleanly — and I'd rather keep one honest failure than overfit the prompt to a single test case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other is the same bug coming back.&lt;/strong&gt; Same truncated-JSON signature, now in the "today" suite at its 900-token cap. My fix was too narrow: I raised the limit where it was failing, instead of asking which other endpoints had limits sized for a non-reasoning model. One endpoint is still at 400 tokens with no eval coverage at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell other developers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Graceful fallbacks hide bugs.&lt;/strong&gt; Make your fallback path loud — log it, count it, alert on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the distribution, not the error.&lt;/strong&gt; The error text pointed at the provider. The pattern across suites pointed at the real cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you fix a limit, audit every place with the same assumption.&lt;/strong&gt; A model change breaks assumptions everywhere at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A red test nobody reads isn't a test.&lt;/strong&gt; The harness did its job on night one. The gap was mine.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;TaskFlow is live at &lt;a href="https://taskflow-dpsa.vercel.app/" rel="noopener noreferrer"&gt;taskflow-dpsa.vercel.app&lt;/a&gt;. Code: &lt;a href="https://github.com/angelina10504/taskflow" rel="noopener noreferrer"&gt;github.com/angelina10504/taskflow&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
