<?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: Igor Potapenko</title>
    <description>The latest articles on DEV Community by Igor Potapenko (@igor_potapenko).</description>
    <link>https://dev.to/igor_potapenko</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%2F4066128%2Ff6290dee-50dc-48c8-b0c6-41a4b2158e1f.jpg</url>
      <title>DEV Community: Igor Potapenko</title>
      <link>https://dev.to/igor_potapenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/igor_potapenko"/>
    <language>en</language>
    <item>
      <title>Your idempotency test probably cannot fail</title>
      <dc:creator>Igor Potapenko</dc:creator>
      <pubDate>Wed, 19 Aug 2026 14:20:29 +0000</pubDate>
      <link>https://dev.to/igor_potapenko/your-idempotency-test-probably-cannot-fail-39g0</link>
      <guid>https://dev.to/igor_potapenko/your-idempotency-test-probably-cannot-fail-39g0</guid>
      <description>&lt;p&gt;I had a test called &lt;em&gt;"stays quiet when the plan is already what the event grants"&lt;/em&gt;. It passed on every run for two weeks.&lt;/p&gt;

&lt;p&gt;Then two identical "payment received" messages arrived on my phone, 142 milliseconds apart.&lt;/p&gt;

&lt;p&gt;The test was not weak. It could not have failed. And that is a more interesting problem than a missing test, because a missing test at least looks like a gap — this one looked like proof.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the code did
&lt;/h2&gt;

&lt;p&gt;Paddle sends &lt;code&gt;subscription.created&lt;/code&gt; and &lt;code&gt;subscription.activated&lt;/code&gt; for a single purchase, and it retries anything that does not answer 200. So two deliveries for one payment is not an edge case, it is Tuesday.&lt;/p&gt;

&lt;p&gt;I knew that. I had written the dedupe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyOnPlan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;alreadyOnPlan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyPayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the current plan, compare, write, announce only if it changed. It reads correctly. It &lt;em&gt;is&lt;/em&gt; correct, for one caller.&lt;/p&gt;

&lt;p&gt;Two deliveries arrive. Both read &lt;code&gt;free&lt;/code&gt;. Both see &lt;code&gt;alreadyOnPlan === false&lt;/code&gt;. Both write &lt;code&gt;basic&lt;/code&gt;. Both announce.&lt;/p&gt;

&lt;p&gt;The window between the read and the write is where the whole bug lives, and it is exactly as wide as one round trip to the database.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the test agreed with me
&lt;/h2&gt;

&lt;p&gt;Here is what I had written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays quiet when the plan is already what the event grants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;activation&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifyPayment&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it again with the bug in mind. It calls the handler &lt;strong&gt;once&lt;/strong&gt;, against a row that is already in the target state. It asks: &lt;em&gt;if I run this after the plan is already set, does it stay quiet?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is a real question. It is not the question the bug is about.&lt;/p&gt;

&lt;p&gt;A race requires two things to overlap. &lt;code&gt;await post(...)&lt;/code&gt; runs one handler to completion before the next line executes, so the two deliveries never coexist. I could have added a second call, a third, a hundred — sequentially they would all pass, forever, while production kept sending doubles.&lt;/p&gt;

&lt;p&gt;This is the part worth taking away. My test was not a bad test of concurrency. &lt;strong&gt;It was a test of something else that I had filed under concurrency&lt;/strong&gt;, and the passing green tick was doing active harm: it told me the case was covered.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making it fail first
&lt;/h2&gt;

&lt;p&gt;The fix in the product code is small, and I will get to it. But the test had to fail before the fix, or I would have no evidence the fix did anything.&lt;/p&gt;

&lt;p&gt;Two handlers have to overlap. In JavaScript that does not need threads — it needs the first handler to yield at an &lt;code&gt;await&lt;/code&gt; while the second one starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;announces once when two deliveries race each other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;activation&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;activation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifyPayment&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledTimes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Promise.all&lt;/code&gt; starts both, and the first one suspends at its first &lt;code&gt;await&lt;/code&gt; — the read. The second handler runs its own read against a row nobody has written yet. That is the production interleaving, reproduced deterministically, in a unit test, with no timing hacks.&lt;/p&gt;

&lt;p&gt;Run that against the old code and it fails: two announcements. Which is what a test is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;It did not fail. It passed against the &lt;em&gt;broken&lt;/em&gt; code too.&lt;/p&gt;

&lt;p&gt;The mock database ignored the condition I was about to rely on. It recorded that an update happened and returned success; whether the row actually matched was not something it modelled. So a conditional write and an unconditional one produced identical results, and no test on earth could tell them apart.&lt;/p&gt;

&lt;p&gt;I want to be precise about how bad this is. A missing test leaves a known hole. A test double that quietly simplifies the thing you are testing produces &lt;strong&gt;confident wrong answers&lt;/strong&gt;, and it produces them in the exact area you thought you had covered. It is the same failure mode as the original bug, one level up.&lt;/p&gt;

&lt;p&gt;So the mock had to learn the one behaviour that matters here: a conditional update is a check and a write in a single step, and the row is changed before anyone else can read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingWrite&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;notEquals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notEquals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;writeValues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;writeValues&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the second caller in the &lt;code&gt;Promise.all&lt;/code&gt; sees what the first one wrote. Now the test fails on the old code and passes on the new one, which is the only property that makes a test worth keeping.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Once check-and-act has to be one step, the shape of the answer is forced. The condition moves out of the process and into the write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;neq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// ← the whole fix&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to update plan: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres decides who was first. The update that actually matched a row returns it; the loser gets an empty list. Only the winner announces.&lt;/p&gt;

&lt;p&gt;Note what did &lt;em&gt;not&lt;/em&gt; change: both deliveries still attempt the write. That matters — if the first one's write is lost to a network failure, the second one heals it. A dedupe that skips the write on the second delivery would turn a lost message into a lost purchase.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three questions I now ask my tests
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can this test fail?&lt;/strong&gt; Not "does it pass" — can I write a version of the product code, plausible enough that I might have written it, that this test does not catch? If the answer is no, the test is decoration. Deleting the fix and watching the test go red takes ten seconds and is the only way to know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the shape of the test match the shape of the bug?&lt;/strong&gt; Races need overlap. Retries need repetition. Ordering bugs need the wrong order. A sequential test cannot express a race, in the same way a unit test cannot express a deployment problem — not "is unlikely to catch", &lt;em&gt;cannot express&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does my test double model the property I am relying on?&lt;/strong&gt; I relied on a conditional write. My mock did not have conditions. Every assertion built on top of that was measuring the mock, not the code. If the fix depends on a database guarantee, the double has to implement that guarantee or the test is theatre.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;I write this product alongside an AI, and this bug is a good example of what that changes and what it does not.&lt;/p&gt;

&lt;p&gt;The dedupe it wrote was reasonable. Read, compare, write — that is what the task sounded like, and it is what most people write by hand too. The failure needed context nobody stated: that this specific provider fans one purchase into two events and retries on top of that. Nothing in the code says so. It is knowledge about the outside world, and it is exactly the kind of thing that will not be in the diff.&lt;/p&gt;

&lt;p&gt;The test was mine. I wrote it to feel covered, and it worked — I felt covered for two weeks, until my phone buzzed twice.&lt;/p&gt;

&lt;p&gt;The lesson is not "review AI code more carefully". It is narrower and more useful: &lt;strong&gt;a passing test is a claim, and claims about concurrency made by sequential code are worth nothing.&lt;/strong&gt; That was true before any of this, and it will be true after.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built while working on &lt;a href="https://prepair.app" rel="noopener noreferrer"&gt;prepair.app&lt;/a&gt;, an AI interviewer for practising before the real thing. If you have a favourite test that turned out to be incapable of failing, I would like to hear it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>Five bugs AI wrote for me that never threw an error</title>
      <dc:creator>Igor Potapenko</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:18:44 +0000</pubDate>
      <link>https://dev.to/igor_potapenko/five-bugs-ai-wrote-for-me-that-never-threw-an-error-2mih</link>
      <guid>https://dev.to/igor_potapenko/five-bugs-ai-wrote-for-me-that-never-threw-an-error-2mih</guid>
      <description>&lt;p&gt;I have been building an interview practice tool for the last two months, writing it alongside Claude. Not "generate me an app" — ordinary daily work where I set a task, look at the result, argue with it and rewrite.&lt;/p&gt;

&lt;p&gt;Plenty has been written about how fast AI writes code. I find the other question more interesting: &lt;strong&gt;what does it break in a way you cannot see?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All four bugs below are real, from my own project. What they have in common is that none of them throws. No exception, no 500, no red line in the logs. Every one of them looks correct in review. You find out when a user tells you — or you never find out at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The counter that never counted
&lt;/h2&gt;

&lt;p&gt;Free plan, three interviews a month. Simple logic: questions generated, counter goes up.&lt;/p&gt;

&lt;p&gt;Roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;incrementInterviewsUsed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;questions&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads fine. The error is swallowed deliberately, so a database hiccup does not break somebody's interview. The limit is checked above, the write happens below.&lt;/p&gt;

&lt;p&gt;Except the write does not happen.&lt;/p&gt;

&lt;p&gt;This is Vercel — serverless. The function returns its response, and the platform is entirely within its rights to freeze it immediately afterwards. A promise nobody awaited simply never reaches the database. Sometimes it lands, sometimes it does not. No pattern to it.&lt;/p&gt;

&lt;p&gt;Here is how it surfaced: a user runs their fourth interview and the dashboard says "1".&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;incrementInterviewsUsed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[Quota] increment failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not the interesting part. The interesting part is &lt;strong&gt;why it survived for weeks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had tests on the quota. They checked that a request is rejected when &lt;code&gt;used = 3&lt;/code&gt;. In other words they tested the read path. The broken thing was the write path — the one responsible for &lt;code&gt;used&lt;/code&gt; ever reaching three in the first place.&lt;/p&gt;

&lt;p&gt;The rule I took away and now keep written down: &lt;strong&gt;anything that enforces a limit or touches money gets tested on the write path, not on the logic surrounding it.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The model obeys the last thing it read
&lt;/h2&gt;

&lt;p&gt;The product generates questions for a role and a seniority level. The complaint: "I picked senior and I am getting junior questions."&lt;/p&gt;

&lt;p&gt;The prompt was assembled from blocks, roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roleGuide&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mixRule&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;                 // "60% theory, 40% practice"
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;levelGuide&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;  // "for senior: architecture, trade-offs..."
`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;mixRule&lt;/code&gt; said something along the lines of &lt;em&gt;"these proportions override the block above."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The level block was interpolated &lt;strong&gt;after&lt;/strong&gt; it.&lt;/p&gt;

&lt;p&gt;A model does not read a prompt as a specification with a hierarchy. It reads it as text. When one place says "this overrides the block above" and something further down says otherwise, the thing closest to the end wins.&lt;/p&gt;

&lt;p&gt;There was a second layer to the same problem. The proportions rule historically lived inside the branch for QA roles. Twenty-one roles out of twenty-three never saw it at all. That one was not the AI's mistake — it was mine, faithfully replicated when it added new roles by following the existing pattern.&lt;/p&gt;

&lt;p&gt;Fixed by reordering the blocks and lifting the rule out of the branch. The broader lesson: &lt;strong&gt;a prompt is code with no compiler.&lt;/strong&gt; Nothing will tell you two instructions contradict each other. The only way to find it is to read the assembled prompt end to end, with your eyes, the way the model sees it.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Zero interviews for every paying customer
&lt;/h2&gt;

&lt;p&gt;There is a B2B tier: a team, with an admin dashboard showing each member. Among other things, how many interviews each person has done.&lt;/p&gt;

&lt;p&gt;That number came from &lt;code&gt;interviews_used&lt;/code&gt; on the profile.&lt;/p&gt;

&lt;p&gt;The same field from bug one. The &lt;strong&gt;free plan&lt;/strong&gt; quota counter, which is deliberately never incremented for paid plans — why count something that is not limited.&lt;/p&gt;

&lt;p&gt;The team plan is a paid plan.&lt;/p&gt;

&lt;p&gt;So on the single screen a company pays for twenty seats to look at, every employee showed zero, permanently. The data existed — interviews were happening, scores were being saved. The dashboard was simply reading the wrong column.&lt;/p&gt;

&lt;p&gt;This one is not a technology mistake. It is a mistake about one field meaning two different things, with nothing in the name to say so. &lt;code&gt;interviews_used&lt;/code&gt; reads as "interviews this person has done". It actually means "how much has been drawn down from the free quota".&lt;/p&gt;

&lt;p&gt;Everything is now counted from the sessions themselves, and the quota field stayed a quota field.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The schema did not exist
&lt;/h2&gt;

&lt;p&gt;The worst of the four, even though it broke nothing.&lt;/p&gt;

&lt;p&gt;The product runs on two tables — interview sessions, and per-answer feedback. I created both by hand in the Supabase dashboard at some point. Fast, convenient, works.&lt;/p&gt;

&lt;p&gt;The migrations folder in the repository described &lt;strong&gt;different&lt;/strong&gt; tables. The ones the project started with and stopped using long ago.&lt;/p&gt;

&lt;p&gt;Nothing failed. Nothing could fail: production has the tables, the code queries them, all good. It would have diverged at exactly one moment — standing up a staging environment, or restoring the database after an incident. Then the application would go looking for tables that are not there and fail to come up.&lt;/p&gt;

&lt;p&gt;I went a long time without noticing precisely because there are no symptoms at all. It turned up by accident while I was writing team reports and went to check which columns a table actually had.&lt;/p&gt;

&lt;p&gt;One more detail from the same dig. The original migration had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'basic'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;team&lt;/code&gt; tier came later. I fixed the constraint in the dashboard and never carried it back to the repository. On a fresh database &lt;strong&gt;every B2B purchase would have been rejected by Postgres.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix: a migration describing what actually runs, plus a test that reads every table reference out of the source and requires each name to be created in a migration. The next hand-made table fails CI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;queriedTables&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`creates &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`create table (if not exists )?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;migrationSql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is queried in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; but no migration creates it`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a nice epilogue. The first time I ran that migration against the live database it failed with &lt;code&gt;cannot change return type of existing function&lt;/code&gt;. Two columns in my version of a function were in a different order from the real one. The application reads fields by name and does not care — but Postgres treats the set of output parameters as a row type and refuses outright. The database itself showed me how far the description had drifted from reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one that got a real user
&lt;/h2&gt;

&lt;p&gt;While writing this I found a fifth, and it is the one that actually cost something.&lt;/p&gt;

&lt;p&gt;The interview screen requested its questions on every mount. Generating questions charged a free interview. So a refresh, a back-and-forward, or the installed app being relaunched quietly spent another of somebody's three.&lt;/p&gt;

&lt;p&gt;The first stranger who ever found this product did exactly that: two sessions, forty seconds apart, zero answers in either. She left having lost two thirds of her free quota without seeing the tool work once.&lt;/p&gt;

&lt;p&gt;Generating questions is now free, and the interview is only charged when the first answer is sent. A reload picks up where it left off.&lt;/p&gt;

&lt;p&gt;And when I wrote the test for it, the test failed — because the database mock returned nothing from a write, while real PostgREST returns the rows it touched. The product code was right; the thing meant to verify it was wrong, and it failed in the most convincing way possible, by quietly reporting that nothing had happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I take from this
&lt;/h2&gt;

&lt;p&gt;What got me was not that the AI makes mistakes. Everyone does.&lt;/p&gt;

&lt;p&gt;It was &lt;strong&gt;how&lt;/strong&gt; it makes them. A person who does not understand the task writes code you can see is wrong — it is clumsy, it does not compile, it falls over on the first run. Code written by a model looks like code written by someone experienced. Sensible names, tidy structure, thoughtful error handling. And exactly where it lacks context — how a serverless function gets frozen, what a particular column has historically meant — it will confidently write something that looks reasonable and is wrong.&lt;/p&gt;

&lt;p&gt;Three things that actually work against this, in my experience:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the write path, not the logic around it.&lt;/strong&gt; My most expensive bug was in a line I wrote myself and verified myself — from the reading side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write tests that compare the code to the outside world.&lt;/strong&gt; Not "this function returns the right value", but "what the migrations declare matches what the code asks for". Those catch an entire class of problem that unit tests cannot see by definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it on the running product.&lt;/strong&gt; Four of the five above were impossible to spot in code. You only see them by opening the screen and looking at the number.&lt;/p&gt;

&lt;p&gt;None of this is new advice. What is new is how much it matters now, when the volume of plausible-looking code you did not write yourself goes up by an order of magnitude.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>testing</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The junior QA bar keeps rising — and here is what each requirement is actually for</title>
      <dc:creator>Igor Potapenko</dc:creator>
      <pubDate>Tue, 11 Aug 2026 22:17:37 +0000</pubDate>
      <link>https://dev.to/igor_potapenko/the-junior-qa-bar-keeps-rising-and-here-is-what-each-requirement-is-actually-for-2ab6</link>
      <guid>https://dev.to/igor_potapenko/the-junior-qa-bar-keeps-rising-and-here-is-what-each-requirement-is-actually-for-2ab6</guid>
      <description>&lt;p&gt;Twelve years ago, a route into testing could look like this: turn up at a conference, listen to a talk, walk up to the speaker afterwards and ask one good question. An offer arrives the following week.&lt;/p&gt;

&lt;p&gt;That is not nostalgia talking. It was ordinary. There were more openings than people, the industry was growing faster than anyone could train for, and companies hired for the ability to think and planned to teach the rest on the job.&lt;/p&gt;

&lt;p&gt;Today the same position asks for test design techniques, an understanding of HTTP, the ability to write a SQL query with a join, some idea of what XSS and SQL injection are, experience with Postman, and increasingly "automation is a plus".&lt;/p&gt;

&lt;p&gt;The first reaction to that list is usually: is this reasonable? Let us be honest about it — some of those requirements are earned, some are not, and the difference is worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the bar went up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The ratio of applicants to openings inverted.&lt;/strong&gt; In 2013 someone with English and a willingness to learn was scarce. Now a junior posting draws hundreds of applications. The interview stopped being a conversation and became a filter, and filters need criteria — preferably formal ones that are easy to compare across candidates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The job itself changed.&lt;/strong&gt; A QA engineer twelve years ago mostly clicked through a UI against a script and filed bugs. Today the same junior opens devtools, watches the Network tab, goes to Postman to see what the backend actually returned, and to the database to confirm it was stored. Not because it is fashionable, but because products became distributed and half the defects no longer live on the screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The runway for learning got shorter.&lt;/strong&gt; Teams are smaller and release more often. A junior used to have six quiet months growing next to a senior. Now they are expected to be useful by the second sprint, and companies try to buy that readiness at the door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And part of it is simply requirement inflation.&lt;/strong&gt; Once every posting says "SQL knowledge", the next one says it too, because leaving it out looks like advertising for someone weaker. That is how things a junior will never touch in the role end up in the requirements for it.&lt;/p&gt;

&lt;p&gt;That last point matters and we will come back to it. First, the parts of the list that are there for a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each requirement is actually for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Test design techniques
&lt;/h3&gt;

&lt;p&gt;These get treated as theory for the sake of theory: learn equivalence partitioning, recite it, forget it.&lt;/p&gt;

&lt;p&gt;In practice the technique answers the one question you will be asked constantly at work: &lt;strong&gt;why did you test that, and why is that enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without a technique the answer is "well, I clicked through the main flows". That is not an argument — it cannot be checked or challenged. With one it becomes: "the field takes 18 to 65, I tested one value from each class plus the boundaries — six tests instead of a hundred and twenty, and here is why the rest add nothing."&lt;/p&gt;

&lt;p&gt;The second, less obvious point: techniques are how you fit into the time you have. Testing everything is never an option. Test design is a tool for cutting scope defensibly, not an academic subject.&lt;/p&gt;

&lt;h3&gt;
  
  
  SQL
&lt;/h3&gt;

&lt;p&gt;The usual objection is "why does a tester need the database, I test the interface".&lt;/p&gt;

&lt;p&gt;The interface shows you what the frontend rendered. It does not show you what was stored.&lt;/p&gt;

&lt;p&gt;The classic case: a user saves their profile, the page says "saved", you move on. In the database the field is still empty, because the backend returned 200 for a request that changed nothing. A customer finds it a week later.&lt;/p&gt;

&lt;p&gt;The second use is more frequent still — test data. You need a user with an expired subscription, three orders and one returned item. Through the UI that is half a day of clicking. As a query it is a minute.&lt;/p&gt;

&lt;p&gt;The volume a junior actually needs is small: &lt;code&gt;select&lt;/code&gt; with &lt;code&gt;where&lt;/code&gt;, sorting, a &lt;code&gt;join&lt;/code&gt; across two tables, &lt;code&gt;count&lt;/code&gt; with &lt;code&gt;group by&lt;/code&gt;. That is not "knowing SQL", it is four constructs.&lt;/p&gt;

&lt;h3&gt;
  
  
  API and HTTP
&lt;/h3&gt;

&lt;p&gt;The reason here is the simplest of all. The core of a junior QA's job is not finding the defect. It is &lt;strong&gt;localising&lt;/strong&gt; it.&lt;/p&gt;

&lt;p&gt;"It does not work" is not a bug report. A developer will spend an hour establishing what you could have established in a minute: did the request go out, what came back, is this a 400 caused by your input or a 500 caused by their code.&lt;/p&gt;

&lt;p&gt;That is where the questions about status codes, devtools and Postman come from. It is not trivia, it is being able to say whose side the problem is on. A tester who can do that saves the team hours every week, which is exactly why it is asked at the door.&lt;/p&gt;

&lt;p&gt;There is also functionality with no interface at all. A payment provider integration, a webhook from an external service, a background job — the only way to check those is with a request.&lt;/p&gt;

&lt;h3&gt;
  
  
  XSS, injection and basic security
&lt;/h3&gt;

&lt;p&gt;This looks like the least junior item on the list. But the reason is not depth, it is &lt;strong&gt;the cost of the miss&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A missed layout bug is a bug. A missed XSS is an incident: leaked data, a notification to users, sometimes a fine. The difference is not difficulty, it is consequences.&lt;/p&gt;

&lt;p&gt;And the cheapest checks cost nothing. Paste &lt;code&gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&lt;/code&gt; into a name field and see whether it executes. Put a single quote in a search box and see whether the query falls over with a database error. It is a five-item checklist that takes ten minutes and closes off the cheapest class of attack there is.&lt;/p&gt;

&lt;p&gt;Nobody expects a junior to run a penetration test. They expect you not to walk past the obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environments and logs
&lt;/h3&gt;

&lt;p&gt;Questions about dev, staging and production, and about how to read logs, get the same puzzled reaction. The reason is practical again: half of all "bugs" are not bugs but the wrong environment, a stale build, or a config difference.&lt;/p&gt;

&lt;p&gt;A tester who checks which build reproduces it and what the logs say before filing files less noise. And noise in the tracker costs the team more than it looks like it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  And what is genuinely excess
&lt;/h2&gt;

&lt;p&gt;Now the honest part.&lt;/p&gt;

&lt;p&gt;Asking a junior manual QA about the layers of the OSI model, replication in SQL Server, or the difference between SIP and PRI is not an assessment of readiness for the job. It is either a list copied from somewhere or an attempt to filter &lt;em&gt;somehow&lt;/em&gt; when two hundred people applied.&lt;/p&gt;

&lt;p&gt;The test for a healthy interview is simple: &lt;strong&gt;for every question, the interviewer can say how it will come up in the first month of work&lt;/strong&gt;. "You will be checking data in the database" — they can. "You will be configuring replication" — not in a junior role.&lt;/p&gt;

&lt;p&gt;If most of the questions fail that test, it tells you something about the company's hiring process rather than about you. That is a perfectly good reason not to regret the rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;The bar is not coming down; the old ratio of people to openings is not coming back. But the list of what you genuinely need is far shorter than the postings suggest.&lt;/p&gt;

&lt;p&gt;The minimum that clears most junior interviews:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test design&lt;/strong&gt; — equivalence partitioning, boundaries, decision tables. With worked examples on concrete fields, not definitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP&lt;/strong&gt; — methods, status code classes, the Network tab, being able to say whose side an error is on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API&lt;/strong&gt; — Postman, GET and POST, reading a response, checking the things the UI does not show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL&lt;/strong&gt; — &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;where&lt;/code&gt;, &lt;code&gt;order by&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt; with &lt;code&gt;group by&lt;/code&gt;. Four constructs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt; — a five-item checklist: XSS in input fields, a quote in the search box, another user's record by id in the URL, password masking, and an error message that does not reveal whether a login exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug reports&lt;/strong&gt; — and understanding that the preconditions matter more than the screenshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a few weeks of focused preparation, not a year of study. Most rejections happen not because the candidate has never heard of these things, but because they know them as "I have read about that" rather than "I can apply it and say why it matters".&lt;/p&gt;

&lt;p&gt;One last thing. Every item above is worth being able to say &lt;strong&gt;out loud&lt;/strong&gt;. Written down, your reasoning looks complete. Said for the first time in an interview, it comes out in a different order and half of it goes missing. That is not a knowledge problem, and reading does not fix it.&lt;/p&gt;




&lt;p&gt;We put together &lt;a href="https://prepair.app/questions/qa-manual?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=junior-bar" rel="noopener noreferrer"&gt;Manual QA interview questions with model answers&lt;/a&gt; — split by junior and middle, free to read. And we built &lt;a href="https://prepair.app/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=junior-bar" rel="noopener noreferrer"&gt;Prepair&lt;/a&gt;, where you answer those questions instead of reading them and get scored on each one. Three interviews a month are free.&lt;/p&gt;

</description>
      <category>qa</category>
      <category>testing</category>
      <category>career</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The DevOps interview question that predicts an outage</title>
      <dc:creator>Igor Potapenko</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:16:25 +0000</pubDate>
      <link>https://dev.to/igor_potapenko/the-devops-interview-question-that-predicts-an-outage-53od</link>
      <guid>https://dev.to/igor_potapenko/the-devops-interview-question-that-predicts-an-outage-53od</guid>
      <description>&lt;p&gt;There is a specific way to turn a slow database into a total outage, and it fits in one line of YAML.&lt;/p&gt;

&lt;p&gt;You point your Kubernetes liveness probe at an endpoint that checks the database. The database gets slow. The probe times out. Kubernetes concludes the container is wedged and restarts it. It restarts every replica, all at once, because they all failed for the same reason at the same time. What was a degraded service — slow, but serving — is now nothing at all, and the restarts keep the database busy enough that it cannot recover.&lt;/p&gt;

&lt;p&gt;I have never met an engineer who did this on purpose. It happens because liveness and readiness probes look interchangeable in the documentation, and the difference only becomes obvious at three in the morning.&lt;/p&gt;

&lt;p&gt;That is exactly why it is a good interview question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming tools is not the same as knowing what to do with them
&lt;/h2&gt;

&lt;p&gt;Most DevOps interviews are a vocabulary test. Do you know what Terraform is. Have you used Kubernetes. Can you list the stages of a pipeline. A candidate who has read the docs passes. A candidate who has run things in production passes identically, and you learn nothing about the difference between them.&lt;/p&gt;

&lt;p&gt;The job is not remembering what a Deployment is. The job is deciding what to roll back first when two changes went out together and you can only undo one. It is deciding what deserves to wake someone up. It is deciding what to leave broken until morning — which is a real decision that real engineers make, and one that never appears in a tutorial.&lt;/p&gt;

&lt;p&gt;So the useful questions are the ones with a fork in them, where a confident answer either way is fine and the reasoning is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five questions that actually separate people
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "What is the difference between a liveness and a readiness probe?"
&lt;/h3&gt;

&lt;p&gt;Everyone gets the definitions. Readiness controls whether traffic arrives; liveness controls whether the container is restarted. The follow-up is where it gets interesting: &lt;em&gt;what happens if your liveness probe checks a dependency?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What you are listening for is whether the candidate sees the amplification. A readiness failure removes one pod from rotation and the rest carry on. A liveness failure kills the pod. Point liveness at something shared and you have built a mechanism that converts partial degradation into simultaneous, cluster-wide restarts. An engineer who has been burned by this answers immediately and with feeling.&lt;/p&gt;

&lt;h3&gt;
  
  
  "A pod is in CrashLoopBackOff. Give me your first three commands."
&lt;/h3&gt;

&lt;p&gt;This one is unfakeable. There is no clever answer, only a practised one.&lt;/p&gt;

&lt;p&gt;A good answer starts with &lt;code&gt;kubectl describe pod&lt;/code&gt;, because the events section holds image pull failures, failed mounts, OOMKilled, and probe failures — most of the answer, before you have read a single log line. Then &lt;code&gt;kubectl logs --previous&lt;/code&gt;, because the container currently running may be seconds old and have logged nothing useful. Then a look at the exit code, and if it smells like OOM, a comparison of the memory limit against actual usage.&lt;/p&gt;

&lt;p&gt;What is telling is not the exact commands but the order. People who debug from experience narrow the space before they start reading. People who have only read about it start with logs and get lost.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What is an error budget, and how would you use it in an argument with a product manager?"
&lt;/h3&gt;

&lt;p&gt;The definitions part is easy: an SLI is what you measure, an SLO is the target over a window, and the error budget is the gap you are allowed to spend.&lt;/p&gt;

&lt;p&gt;The second half of the question is the real one. An error budget is not a metric, it is a negotiating device. It converts "we should slow down and fix reliability" from an opinion — which loses to a roadmap every time — into arithmetic that both sides agreed to in advance. Candidates who have used one describe it that way. Candidates who have only read the SRE book describe the maths and stop.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What is Terraform state, and why is it dangerous?"
&lt;/h3&gt;

&lt;p&gt;The trap here is answering only the first half. State maps your configuration to real resources so Terraform knows what to create, change, or destroy.&lt;/p&gt;

&lt;p&gt;Why it is dangerous is the part that matters. It is authoritative: lose it and Terraform no longer knows it owns your infrastructure, so it offers to build everything again. It frequently contains secrets in plaintext. And two engineers running apply at the same time without locking can corrupt it. A candidate who has actually operated Terraform gets to remote backends, encryption, versioning and state locking without being prompted, because they have felt the fear.&lt;/p&gt;

&lt;h3&gt;
  
  
  "You are deploying a service with a database migration. Rolling, blue-green, or canary?"
&lt;/h3&gt;

&lt;p&gt;There is no correct answer, which is the point.&lt;/p&gt;

&lt;p&gt;Rolling needs almost no spare capacity, but two versions serve traffic during the roll — so the migration has to be backward compatible whether you like it or not. Blue-green gives instant rollback and doubles your infrastructure, and the migration problem gets worse rather than better, because the two environments share one database. Canary catches problems on real users but only pays off if your observability is good enough to notice within the canary window.&lt;/p&gt;

&lt;p&gt;Anyone who picks one instantly without mentioning the migration has not thought about it. Anyone who says "it depends" and stops has not either. The answer you want names a choice and then names what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are the one preparing
&lt;/h2&gt;

&lt;p&gt;The pattern across all five is the same: the question sounds like it is about a tool, and it is actually about a decision.&lt;/p&gt;

&lt;p&gt;So when you prepare, do not stop at the definition. For every concept you revise, ask yourself what it looks like when it goes wrong, what you would check first, and what you would give up to fix it. If you can only recite what something is, you will answer the first half of the question and go quiet on the second — and the second half is the one being graded.&lt;/p&gt;

&lt;p&gt;The other thing worth saying: reading answers is not the same as saying them. Written down, your reasoning looks complete. Out loud, under mild pressure, with someone waiting, it comes out in a different order and half of it goes missing. That gap is not a knowledge problem, and revising harder does not close it. Practice does.&lt;/p&gt;

&lt;p&gt;If you want the longer list, I keep &lt;a href="https://prepair.app/questions/devops-sre?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devops-outage" rel="noopener noreferrer"&gt;DevOps and SRE interview questions with model answers&lt;/a&gt; written up, free to read.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://prepair.app/?utm_source=devto&amp;amp;utm_medium=bio&amp;amp;utm_campaign=devops-outage" rel="noopener noreferrer"&gt;Prepair&lt;/a&gt; — an AI interviewer that asks these questions out loud and scores the answers. Writing this list is how I work out what the product should ask.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Good luck. And check where your liveness probe is pointing.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>sre</category>
      <category>career</category>
    </item>
  </channel>
</rss>
