<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>驗收 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E9%A9%97%E6%94%B6/</link><description>Recent content in 驗收 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E9%A9%97%E6%94%B6/index.xml" rel="self" type="application/rss+xml"/><item><title>視覺完成 ≠ 功能完成</title><link>https://tarrragon.github.io/blog/report/visual-completion-vs-functional-completion/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/visual-completion-vs-functional-completion/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>視覺完成是「畫面看起來對」、功能完成是「使用者意圖真的被滿足」。&lt;/strong> 兩者在簡單情境下重合、在邊界情境下分裂。視覺完成出現得早（手動 happy path 一試就過）、功能完成需要刻意對照「使用者意圖完整集合」才看得出來。&lt;/p>
&lt;p>寫程式時把「畫面對了」當成完工訊號 = 把驗收標準降到視覺層、漏掉「功能在邊界情境下是否還對」這層。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼視覺驗收會早於功能驗收成立">為什麼視覺驗收會早於功能驗收成立&lt;/h2>
&lt;h3 id="驗收訊號的成本梯度">驗收訊號的成本梯度&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>驗收方式&lt;/th>
 &lt;th>觸發成本&lt;/th>
 &lt;th>覆蓋的失敗類型&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>手動視覺驗收&lt;/td>
 &lt;td>低 — 開頁、輸入一個 case&lt;/td>
 &lt;td>Happy path 的視覺正確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多 case 視覺驗收&lt;/td>
 &lt;td>中 — 想出邊界 case&lt;/td>
 &lt;td>視覺面的邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>功能對照（語意驗收）&lt;/td>
 &lt;td>高 — 列使用者意圖完整集&lt;/td>
 &lt;td>功能跟意圖之間的縫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨資料規模驗收&lt;/td>
 &lt;td>高 — 製造稀疏 / 大量資料&lt;/td>
 &lt;td>資料規模相依的功能失敗&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>成本低的訊號出現早 → 容易誤判完工。&lt;/p>
&lt;h3 id="視覺驗收的盲區">視覺驗收的盲區&lt;/h3>
&lt;p>視覺驗收只看「螢幕上呈現的」、不看「應該呈現但沒呈現的」。後者沒有視覺訊號 — 不會閃紅、不會報錯、只是「該有的東西沒出現」。&lt;/p>
&lt;p>這個盲區包括：&lt;/p>
&lt;ul>
&lt;li>Filter 把該顯示的藏掉了（見 #55 &lt;a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位&lt;/a>）&lt;/li>
&lt;li>Pagination 漏抓了某幾頁&lt;/li>
&lt;li>Sort 漏了某類元素&lt;/li>
&lt;li>Async race condition 把舊資料留在畫面&lt;/li>
&lt;/ul>
&lt;p>共通點：&lt;strong>錯誤的形式是「不該不在的不在」&lt;/strong>、不是「畫面壞了」。&lt;/p>
&lt;hr>
&lt;h2 id="多面向四類畫面對但功能漏">多面向：四類「畫面對但功能漏」&lt;/h2>
&lt;h3 id="面向-1filter--sort--count-跟-source-不同層">面向 1：Filter / Sort / Count 跟 source 不同層&lt;/h3>
&lt;p>見 #55。視覺層 filter 套在分批 source 上、稀疏 case 顯露語意縫。&lt;/p>
&lt;h3 id="面向-2async-race--競態">面向 2：Async race / 競態&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 慢的 query 後到、畫面是舊 query 的結果
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>畫面有結果、看起來對、但對應的不是當前 query。&lt;/p>
&lt;h3 id="面向-3empty-state--loading-state-不分">面向 3：Empty state / loading state 不分&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;results&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> {{ if results }}{{ for r }}{{ render r }}{{ end }}{{ end }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「還在 loading」跟「真的沒結果」共用同一個畫面 — 都是空。視覺對、功能上「使用者不知道狀態」。&lt;/p>
&lt;h3 id="面向-4form-submit-後狀態回饋失真">面向 4：Form submit 後狀態回饋失真&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">button&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">onclick&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">saveData&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="nx">button&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Saved&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">};&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>按了顯示 saved、但 saveData 是 async 還沒完成 / 失敗 — 畫面對、實際資料沒進 DB。&lt;/p>
&lt;p>四個面向共用結構：&lt;strong>動作有視覺回饋、但回饋的「時機」或「對象」跟「實際語意」對不上&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="畫面對屬於哪個-checkpoint">「畫面對」屬於哪個 checkpoint&lt;/h2>
&lt;p>驗收要分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）— 詳見 &lt;a href="../verification-timeline-checkpoints/">#68 驗收的時間軸：四個 checkpoint&lt;/a>。&lt;/p>
&lt;p>「畫面對」是 &lt;strong>開發中&lt;/strong> 的視覺驗收訊號 — 用來判斷「邏輯有跑、UI 沒崩」。它&lt;strong>不能&lt;/strong>取代：&lt;/p>
&lt;ul>
&lt;li>寫之前的「意圖完整集列舉」&lt;/li>
&lt;li>Ship 前的「邊界 / 規模 case」&lt;/li>
&lt;li>Ship 後的「真實使用者紀錄」&lt;/li>
&lt;/ul>
&lt;p>把「畫面對」當完工 = 把開發中的中介訊號當終點訊號 = 跳過後三個 checkpoint。&lt;/p>
&lt;hr>
&lt;h2 id="跟-422-次門檻的關係">跟 #42「2 次門檻」的關係&lt;/h2>
&lt;p>&lt;a href="../two-occurrence-threshold/">#42 2 次門檻&lt;/a> 講「第 1 次成功是低資訊量訊號、第 2 次（同方向 / 同類）才是真訊號」。&lt;/p>
&lt;p>「畫面對」就是 #42 在「驗收訊號」面向的應用：&lt;strong>「畫面對了一次」是低資訊量訊號、跟「程式跑通一次」「測試過一次」是同類&lt;/strong>。它告訴你「至少不是完全壞的」、不告訴你「對了」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>低資訊量訊號&lt;/th>
 &lt;th>真訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>畫面對了一次&lt;/td>
 &lt;td>跨多個 case、多個規模、跨時間後仍對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式跑通一次&lt;/td>
 &lt;td>跨多次執行、不同輸入仍跑通&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試過一次&lt;/td>
 &lt;td>涵蓋邊界 / 失敗 / 規模、CI 持續通過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者用過一次沒反映&lt;/td>
 &lt;td>多週多使用者沒累積反映&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把低資訊量訊號當完工 = 跨情境就是「同方向加碼到第 3 次」 — 都是「太早信任早期成功」的同個錯誤。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>視覺完成是「畫面看起來對」、功能完成是「使用者意圖真的被滿足」。</strong> 兩者在簡單情境下重合、在邊界情境下分裂。視覺完成出現得早（手動 happy path 一試就過）、功能完成需要刻意對照「使用者意圖完整集合」才看得出來。</p>
<p>寫程式時把「畫面對了」當成完工訊號 = 把驗收標準降到視覺層、漏掉「功能在邊界情境下是否還對」這層。</p>
<hr>
<h2 id="為什麼視覺驗收會早於功能驗收成立">為什麼視覺驗收會早於功能驗收成立</h2>
<h3 id="驗收訊號的成本梯度">驗收訊號的成本梯度</h3>
<table>
  <thead>
      <tr>
          <th>驗收方式</th>
          <th>觸發成本</th>
          <th>覆蓋的失敗類型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>手動視覺驗收</td>
          <td>低 — 開頁、輸入一個 case</td>
          <td>Happy path 的視覺正確</td>
      </tr>
      <tr>
          <td>多 case 視覺驗收</td>
          <td>中 — 想出邊界 case</td>
          <td>視覺面的邊界</td>
      </tr>
      <tr>
          <td>功能對照（語意驗收）</td>
          <td>高 — 列使用者意圖完整集</td>
          <td>功能跟意圖之間的縫</td>
      </tr>
      <tr>
          <td>跨資料規模驗收</td>
          <td>高 — 製造稀疏 / 大量資料</td>
          <td>資料規模相依的功能失敗</td>
      </tr>
  </tbody>
</table>
<p>成本低的訊號出現早 → 容易誤判完工。</p>
<h3 id="視覺驗收的盲區">視覺驗收的盲區</h3>
<p>視覺驗收只看「螢幕上呈現的」、不看「應該呈現但沒呈現的」。後者沒有視覺訊號 — 不會閃紅、不會報錯、只是「該有的東西沒出現」。</p>
<p>這個盲區包括：</p>
<ul>
<li>Filter 把該顯示的藏掉了（見 #55 <a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位</a>）</li>
<li>Pagination 漏抓了某幾頁</li>
<li>Sort 漏了某類元素</li>
<li>Async race condition 把舊資料留在畫面</li>
</ul>
<p>共通點：<strong>錯誤的形式是「不該不在的不在」</strong>、不是「畫面壞了」。</p>
<hr>
<h2 id="多面向四類畫面對但功能漏">多面向：四類「畫面對但功能漏」</h2>
<h3 id="面向-1filter--sort--count-跟-source-不同層">面向 1：Filter / Sort / Count 跟 source 不同層</h3>
<p>見 #55。視覺層 filter 套在分批 source 上、稀疏 case 顯露語意縫。</p>
<h3 id="面向-2async-race--競態">面向 2：Async race / 競態</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">render</span><span class="p">(</span><span class="nx">r</span><span class="p">);</span>  <span class="c1">// 慢的 query 後到、畫面是舊 query 的結果
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>畫面有結果、看起來對、但對應的不是當前 query。</p>
<h3 id="面向-3empty-state--loading-state-不分">面向 3：Empty state / loading state 不分</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  {{ if results }}{{ for r }}{{ render r }}{{ end }}{{ end }}
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>「還在 loading」跟「真的沒結果」共用同一個畫面 — 都是空。視覺對、功能上「使用者不知道狀態」。</p>
<h3 id="面向-4form-submit-後狀態回饋失真">面向 4：Form submit 後狀態回饋失真</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">button</span><span class="p">.</span><span class="nx">onclick</span> <span class="o">=</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">saveData</span><span class="p">();</span> <span class="nx">button</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s2">&#34;Saved&#34;</span><span class="p">;</span> <span class="p">};</span></span></span></code></pre></div><p>按了顯示 saved、但 saveData 是 async 還沒完成 / 失敗 — 畫面對、實際資料沒進 DB。</p>
<p>四個面向共用結構：<strong>動作有視覺回饋、但回饋的「時機」或「對象」跟「實際語意」對不上</strong>。</p>
<hr>
<h2 id="畫面對屬於哪個-checkpoint">「畫面對」屬於哪個 checkpoint</h2>
<p>驗收要分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）— 詳見 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸：四個 checkpoint</a>。</p>
<p>「畫面對」是 <strong>開發中</strong> 的視覺驗收訊號 — 用來判斷「邏輯有跑、UI 沒崩」。它<strong>不能</strong>取代：</p>
<ul>
<li>寫之前的「意圖完整集列舉」</li>
<li>Ship 前的「邊界 / 規模 case」</li>
<li>Ship 後的「真實使用者紀錄」</li>
</ul>
<p>把「畫面對」當完工 = 把開發中的中介訊號當終點訊號 = 跳過後三個 checkpoint。</p>
<hr>
<h2 id="跟-422-次門檻的關係">跟 #42「2 次門檻」的關係</h2>
<p><a href="../two-occurrence-threshold/">#42 2 次門檻</a> 講「第 1 次成功是低資訊量訊號、第 2 次（同方向 / 同類）才是真訊號」。</p>
<p>「畫面對」就是 #42 在「驗收訊號」面向的應用：<strong>「畫面對了一次」是低資訊量訊號、跟「程式跑通一次」「測試過一次」是同類</strong>。它告訴你「至少不是完全壞的」、不告訴你「對了」。</p>
<table>
  <thead>
      <tr>
          <th>低資訊量訊號</th>
          <th>真訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>畫面對了一次</td>
          <td>跨多個 case、多個規模、跨時間後仍對</td>
      </tr>
      <tr>
          <td>程式跑通一次</td>
          <td>跨多次執行、不同輸入仍跑通</td>
      </tr>
      <tr>
          <td>測試過一次</td>
          <td>涵蓋邊界 / 失敗 / 規模、CI 持續通過</td>
      </tr>
      <tr>
          <td>使用者用過一次沒反映</td>
          <td>多週多使用者沒累積反映</td>
      </tr>
  </tbody>
</table>
<p>把低資訊量訊號當完工 = 跨情境就是「同方向加碼到第 3 次」 — 都是「太早信任早期成功」的同個錯誤。</p>
<hr>
<h2 id="識別視覺完成但功能未完成的訊號">識別「視覺完成但功能未完成」的訊號</h2>
<h3 id="訊號-1驗收靠再點一下試試">訊號 1：驗收靠「再點一下試試」</h3>
<p>如果發現 bug 的方式是「我再操作一次就看出來了」 — 表示 happy path 過了、邊界 case 沒過。看到這個訊號要主動列邊界 case。</p>
<h3 id="訊號-2使用者描述的-bug-含有時候偶爾我以為">訊號 2：使用者描述的 bug 含「有時候」「偶爾」「我以為」</h3>
<p>「有時候 load more 沒動」「我以為都篩過了」 — 這類語言反映的是「畫面跟意圖之間有縫、使用者用視覺驗收結果跟意圖對不上」。</p>
<h3 id="訊號-3實作時下意識覺得先這樣晚點補">訊號 3：實作時下意識覺得「先這樣、晚點補」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// TODO: 處理 cache 跟 fresh 的合併
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">cached</span> <span class="o">||</span> <span class="nx">fresh</span><span class="p">;</span></span></span></code></pre></div><p>「晚點補」的部分通常就是視覺看不見的功能缺口。如果視覺驗收會過、TODO 會被忘記到 production。</p>
<h3 id="訊號-4測試只有-happy-path-截圖">訊號 4：測試只有 happy path 截圖</h3>
<p>PR / commit 附的截圖只有「最常見的 case」 — 沒有「沒結果」「載入中」「失敗」「資料規模特別大 / 特別小」的截圖 → 驗收層級停在視覺。</p>
<hr>
<h2 id="設計取捨怎麼把驗收從視覺升到功能">設計取捨：怎麼把驗收從視覺升到功能</h2>
<p>四種做法、不同情境合理。</p>
<h3 id="a寫之前列使用者意圖的完整-case-集合實作後逐一對照">A：寫之前列「使用者意圖的完整 case 集合」、實作後逐一對照</h3>
<ul>
<li><strong>機制</strong>：開工前列 happy path / 邊界 case / 失敗 case 三類、實作完逐一檢查</li>
<li><strong>選 A 的理由</strong>：把驗收標準從「能用」升到「對齊意圖」</li>
<li><strong>代價</strong>：需要主動想 case、寫之前花時間</li>
</ul>
<h3 id="b靠自動化測試unit--e2e覆蓋邊界">B：靠自動化測試（unit / e2e）覆蓋邊界</h3>
<ul>
<li><strong>機制</strong>：每個 case 寫一個測試、CI 跑</li>
<li><strong>跟 A 的取捨</strong>：B 持續性更好、但成本高、且測試是寫的人決定的、漏想 case 一樣會漏</li>
<li><strong>B 才合理的情境</strong>：大專案、團隊協作、回歸風險高</li>
</ul>
<h3 id="c靠使用者回報">C：靠使用者回報</h3>
<ul>
<li><strong>機制</strong>：先 ship、使用者反映再修</li>
<li><strong>跟 A 的取捨</strong>：C 工程量最低、但 trust 損失高、bug 進 production 才被發現</li>
<li><strong>C 才合理的情境</strong>：原型期、使用者願意幫忙找 bug、易回滾</li>
</ul>
<h3 id="d只做視覺驗收反模式">D：只做視覺驗收（反模式）</h3>
<ul>
<li><strong>為什麼是反模式</strong>：把驗收標準降到視覺層、漏掉「功能跟意圖之間的縫」這層 — 而那層的失敗最常見也最貴</li>
<li><strong>看起來吸引人的原因</strong>：成本最低、happy path 過了就 OK、不需要列邊界 case</li>
<li><strong>實際發生的代價</strong>：silent 缺口累積、系統性使用者不信任、ship 後發現修起來比早期貴 N 倍（見 <a href="../verification-timeline-checkpoints/#%e7%80%91%e5%b8%83%e5%8e%9f%e5%89%87%e6%bc%8f%e4%b8%80%e5%b1%a4%e4%bb%a3%e5%83%b9%e6%8c%87%e6%95%b8%e6%94%be%e5%a4%a7">#68 瀑布原則</a>）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>驗收只看了 happy path 截圖</td>
          <td>補：邊界 case + 失敗 case + 規模 case</td>
      </tr>
      <tr>
          <td>內心 OS：「畫面對了應該就 OK」</td>
          <td>停 — 列「使用者意圖完整集合」對照</td>
      </tr>
      <tr>
          <td>Bug report 含「有時候」「偶爾」「我以為」</td>
          <td>是「畫面跟意圖之間有縫」的訊號</td>
      </tr>
      <tr>
          <td>實作時寫了 TODO 但視覺驗收會過</td>
          <td>TODO 會在 production 被遺忘、必須補完</td>
      </tr>
      <tr>
          <td>Filter / sort / async / cache 等「狀態相依」的功能完成</td>
          <td>主動跑「規模 / 稀疏 / 競態」三類 case</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：視覺驗收是必要、不是充分。功能驗收要對照「使用者意圖完整集合」、不只是「畫面對」。視覺對 + 意圖縫 = 比畫面壞更危險、因為它不會觸發任何訊號。</p>
<p>延伸到測試驗收：「測試 PASS」也是視覺訊號的同類 — 沒看過該測試 RED 過、不知道它有沒有 catch 能力。詳見 <a href="../test-first-red-before-green/">#69 Test-First：先看到 RED 才相信 GREEN</a>。</p>
]]></content:encoded></item><item><title>驗收的時間軸：四個 checkpoint</title><link>https://tarrragon.github.io/blog/report/verification-timeline-checkpoints/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/verification-timeline-checkpoints/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;blockquote>
&lt;p>驗收不是單一動作、是分散在四個時點的累積判斷。&lt;/p>&lt;/blockquote>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Checkpoint&lt;/th>
 &lt;th>時點&lt;/th>
 &lt;th>能驗收的失敗類型&lt;/th>
 &lt;th>成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>寫之前&lt;/td>
 &lt;td>開工前列「使用者意圖完整集」&lt;/td>
 &lt;td>漏掉的 case、誤解的需求&lt;/td>
 &lt;td>低 — 列清單&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開發中&lt;/td>
 &lt;td>寫一塊測一塊&lt;/td>
 &lt;td>邏輯錯誤、視覺錯誤、單元失敗&lt;/td>
 &lt;td>中 — 小範圍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ship 前&lt;/td>
 &lt;td>E2E 跑邊界 / 規模 / 失敗 case&lt;/td>
 &lt;td>跨 case 整合錯、規模相依失敗、競態&lt;/td>
 &lt;td>高 — 設計 case&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Ship 後&lt;/td>
 &lt;td>真實使用者紀錄、log monitor&lt;/td>
 &lt;td>silent 缺口、長尾 case、罕見組合&lt;/td>
 &lt;td>最高 — 反應慢&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個 checkpoint 抓的失敗類型不同、跳過任一個 = 那類失敗會在更晚的 checkpoint 出現（或不出現、變成 silent bug）。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼分散驗收而不是集中">為什麼分散驗收、而不是集中&lt;/h2>
&lt;h3 id="集中驗收的問題">集中驗收的問題&lt;/h3>
&lt;p>「寫完一次驗收完整」這個想法看似省事、實際撞兩個牆：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>失敗類型不在同一時點&lt;/strong>：開發中發現的是邏輯 bug、ship 前發現的是整合 bug、ship 後發現的是 silent 缺口 — 用同一種驗收方法不能 catch 全部&lt;/li>
&lt;li>&lt;strong>成本指數爆炸&lt;/strong>：到 ship 前才發現「需求理解錯」要重做整個 feature；到 ship 後才發現邏輯 bug 要熱修。早期 checkpoint 修一個 case 用 5 分鐘、ship 後修同個 case 用 5 小時&lt;/li>
&lt;/ol>
&lt;p>分散驗收 = 在每個 checkpoint catch 「該時點獨有的失敗類型」、累積成完整覆蓋。&lt;/p>
&lt;h3 id="早期-checkpoint-的槓桿">早期 checkpoint 的槓桿&lt;/h3>
&lt;p>「寫之前」的成本最低（列清單 5 分鐘）但能 catch 最貴的失敗類型（需求理解錯 = 整個 feature 重做）。&lt;strong>ROI 最高&lt;/strong>。&lt;/p>
&lt;p>「Ship 後」的成本最高（使用者反映、需要熱修）但只能 catch 最罕見的失敗類型。ROI 最低。&lt;/p>
&lt;p>實務上常常 collapse 成「寫的時候 + ship 後出問題才修」、跳過寫之前 / ship 前。這是把 ROI 倒過來。&lt;/p>
&lt;hr>
&lt;h2 id="四個-checkpoint-各自驗收什麼">四個 Checkpoint 各自驗收什麼&lt;/h2>
&lt;h3 id="checkpoint-1寫之前">Checkpoint 1：寫之前&lt;/h3>
&lt;p>&lt;strong>動作&lt;/strong>：列「使用者意圖完整集合」 — happy path、邊界 case、失敗 case、規模 case 各列幾條。&lt;/p>
&lt;p>&lt;strong>能 catch&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>需求理解跟使用者意圖不同&lt;/li>
&lt;li>邊界 case 從一開始就忘了想&lt;/li>
&lt;li>規模 case 沒考慮（10 筆 vs 10 萬筆行為不同）&lt;/li>
&lt;li>隱含假設沒攤開（「應該都會有 title」「永遠不會空」）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>範例&lt;/strong>：寫 filter 之前列：「title 含 X、content 含 X、兩者都含、都不含、source 全空、source 全是、稀疏 case、密集 case」 — 8 個 case 寫之前看見、實作時主動處理。&lt;/p>
&lt;h3 id="checkpoint-2開發中">Checkpoint 2：開發中&lt;/h3>
&lt;p>&lt;strong>動作&lt;/strong>：寫一塊測一塊 — 單元跑通、視覺看一眼、邊改邊試。&lt;/p>
&lt;p>&lt;strong>能 catch&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>邏輯錯誤（branch 寫錯、迴圈邊界錯）&lt;/li>
&lt;li>視覺錯誤（layout 跑掉、樣式套錯）&lt;/li>
&lt;li>API 用錯（呼叫順序錯、參數錯）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>不能 catch&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>跨多個 case 的整合錯&lt;/li>
&lt;li>規模相依失敗&lt;/li>
&lt;li>競態 / async race&lt;/li>
&lt;li>跨環境差異&lt;/li>
&lt;/ul>
&lt;h3 id="checkpoint-3ship-前">Checkpoint 3：Ship 前&lt;/h3>
&lt;p>&lt;strong>動作&lt;/strong>：E2E 跑邊界 / 規模 / 失敗 case。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<blockquote>
<p>驗收不是單一動作、是分散在四個時點的累積判斷。</p></blockquote>
<table>
  <thead>
      <tr>
          <th>Checkpoint</th>
          <th>時點</th>
          <th>能驗收的失敗類型</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫之前</td>
          <td>開工前列「使用者意圖完整集」</td>
          <td>漏掉的 case、誤解的需求</td>
          <td>低 — 列清單</td>
      </tr>
      <tr>
          <td>開發中</td>
          <td>寫一塊測一塊</td>
          <td>邏輯錯誤、視覺錯誤、單元失敗</td>
          <td>中 — 小範圍</td>
      </tr>
      <tr>
          <td>Ship 前</td>
          <td>E2E 跑邊界 / 規模 / 失敗 case</td>
          <td>跨 case 整合錯、規模相依失敗、競態</td>
          <td>高 — 設計 case</td>
      </tr>
      <tr>
          <td>Ship 後</td>
          <td>真實使用者紀錄、log monitor</td>
          <td>silent 缺口、長尾 case、罕見組合</td>
          <td>最高 — 反應慢</td>
      </tr>
  </tbody>
</table>
<p>每個 checkpoint 抓的失敗類型不同、跳過任一個 = 那類失敗會在更晚的 checkpoint 出現（或不出現、變成 silent bug）。</p>
<hr>
<h2 id="為什麼分散驗收而不是集中">為什麼分散驗收、而不是集中</h2>
<h3 id="集中驗收的問題">集中驗收的問題</h3>
<p>「寫完一次驗收完整」這個想法看似省事、實際撞兩個牆：</p>
<ol>
<li><strong>失敗類型不在同一時點</strong>：開發中發現的是邏輯 bug、ship 前發現的是整合 bug、ship 後發現的是 silent 缺口 — 用同一種驗收方法不能 catch 全部</li>
<li><strong>成本指數爆炸</strong>：到 ship 前才發現「需求理解錯」要重做整個 feature；到 ship 後才發現邏輯 bug 要熱修。早期 checkpoint 修一個 case 用 5 分鐘、ship 後修同個 case 用 5 小時</li>
</ol>
<p>分散驗收 = 在每個 checkpoint catch 「該時點獨有的失敗類型」、累積成完整覆蓋。</p>
<h3 id="早期-checkpoint-的槓桿">早期 checkpoint 的槓桿</h3>
<p>「寫之前」的成本最低（列清單 5 分鐘）但能 catch 最貴的失敗類型（需求理解錯 = 整個 feature 重做）。<strong>ROI 最高</strong>。</p>
<p>「Ship 後」的成本最高（使用者反映、需要熱修）但只能 catch 最罕見的失敗類型。ROI 最低。</p>
<p>實務上常常 collapse 成「寫的時候 + ship 後出問題才修」、跳過寫之前 / ship 前。這是把 ROI 倒過來。</p>
<hr>
<h2 id="四個-checkpoint-各自驗收什麼">四個 Checkpoint 各自驗收什麼</h2>
<h3 id="checkpoint-1寫之前">Checkpoint 1：寫之前</h3>
<p><strong>動作</strong>：列「使用者意圖完整集合」 — happy path、邊界 case、失敗 case、規模 case 各列幾條。</p>
<p><strong>能 catch</strong>：</p>
<ul>
<li>需求理解跟使用者意圖不同</li>
<li>邊界 case 從一開始就忘了想</li>
<li>規模 case 沒考慮（10 筆 vs 10 萬筆行為不同）</li>
<li>隱含假設沒攤開（「應該都會有 title」「永遠不會空」）</li>
</ul>
<p><strong>範例</strong>：寫 filter 之前列：「title 含 X、content 含 X、兩者都含、都不含、source 全空、source 全是、稀疏 case、密集 case」 — 8 個 case 寫之前看見、實作時主動處理。</p>
<h3 id="checkpoint-2開發中">Checkpoint 2：開發中</h3>
<p><strong>動作</strong>：寫一塊測一塊 — 單元跑通、視覺看一眼、邊改邊試。</p>
<p><strong>能 catch</strong>：</p>
<ul>
<li>邏輯錯誤（branch 寫錯、迴圈邊界錯）</li>
<li>視覺錯誤（layout 跑掉、樣式套錯）</li>
<li>API 用錯（呼叫順序錯、參數錯）</li>
</ul>
<p><strong>不能 catch</strong>：</p>
<ul>
<li>跨多個 case 的整合錯</li>
<li>規模相依失敗</li>
<li>競態 / async race</li>
<li>跨環境差異</li>
</ul>
<h3 id="checkpoint-3ship-前">Checkpoint 3：Ship 前</h3>
<p><strong>動作</strong>：E2E 跑邊界 / 規模 / 失敗 case。</p>
<p><strong>能 catch</strong>：</p>
<ul>
<li>跨 case 整合錯（filter 切換 + load more 互動）</li>
<li>規模相依（500 筆時 jank）</li>
<li>競態（快速切換 query 時）</li>
<li>真實環境 case（slow network、large data）</li>
</ul>
<p><strong>不能 catch</strong>：</p>
<ul>
<li>罕見組合（特定 user pattern）</li>
<li>真實使用者意外行為</li>
<li>長尾邊界（千分之一機率的狀態）</li>
</ul>
<p><strong>這個 checkpoint 最常被跳過</strong> — 因為設計 E2E case 成本高、要刻意製造規模 / 失敗 / 競態場景。但跳過 = ship 後才發現。</p>
<h3 id="checkpoint-4ship-後">Checkpoint 4：Ship 後</h3>
<p><strong>動作</strong>：log monitor、error tracking、使用者行為紀錄。</p>
<p><strong>能 catch</strong>：</p>
<ul>
<li>silent 缺口（沒人 report、log 看出來）</li>
<li>罕見組合</li>
<li>真實使用者意外行為</li>
<li>跨時間退化（穩定 vs 漸變）</li>
</ul>
<p><strong>特性</strong>：成本最高、反應最慢、只能 catch 前三個 checkpoint 都漏的失敗。<strong>價值在於「保底」、不是主力驗收</strong>。</p>
<hr>
<h2 id="為什麼-ship-前-checkpoint-最常被跳過">為什麼 Ship 前 checkpoint 最常被跳過</h2>
<p>四個 checkpoint 中、Ship 前是被跳過機率最高的一個。原因是結構性的、不是隨機的：</p>
<table>
  <thead>
      <tr>
          <th>Checkpoint</th>
          <th>觸發機制</th>
          <th>是否有便利路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫之前</td>
          <td>外部驅動（需求 / spec）</td>
          <td>有 — 別人推著走</td>
      </tr>
      <tr>
          <td>開發中</td>
          <td>內建在寫的動作裡</td>
          <td>有 — 寫一塊看一眼是反射動作</td>
      </tr>
      <tr>
          <td><strong>Ship 前</strong></td>
          <td><strong>要主動設計 case</strong></td>
          <td><strong>沒有 — 需要刻意停下來想邊界</strong></td>
      </tr>
      <tr>
          <td>Ship 後</td>
          <td>被動（使用者反映）</td>
          <td>有 — 別人推著走</td>
      </tr>
  </tbody>
</table>
<p>寫之前跟 Ship 後都是「被外部 / 別人推著」、有現成觸發；開發中是反射動作、不需要刻意。<strong>只有 Ship 前需要寫的人主動停下、設計 E2E case、執行 case</strong> — 沒有現成觸發、沒有便利路徑。</p>
<p>這正是 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> 在驗收動作上的應用：跟「便利路徑」對齊的 checkpoint 會被做、要「主動設計」的 checkpoint 會被跳。</p>
<p>修這個結構性偏差的方法：</p>
<ul>
<li>把 Ship 前 case 設計列進開工前的「使用者意圖完整集合」（推到 Checkpoint 1、有便利路徑）</li>
<li>用 layout test / E2E test 把 case 固化（<a href="../layout-tests-with-playwright/">#15</a>）— 寫一次、之後 CI 自動跑、不需要主動觸發</li>
<li>公司 / 團隊建立「Ship 前 checkpoint review」會議 — 把它變成外部驅動</li>
</ul>
<hr>
<h2 id="為什麼-checkpoint-1寫之前也常被跳過--同個結構性偏差">為什麼 Checkpoint 1（寫之前）也常被跳過 — 同個結構性偏差</h2>
<p>Checkpoint 1 跟 Ship 前 checkpoint 共享同一個結構性問題：<strong>沒有便利路徑、需要刻意停下來</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Checkpoint</th>
          <th>該做的事</th>
          <th>為什麼會被跳過</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫之前</td>
          <td>列「使用者意圖完整集合」</td>
          <td>沒既有觸發、要刻意停 5 分鐘想</td>
      </tr>
      <tr>
          <td>Ship 前</td>
          <td>設計 E2E case + 執行</td>
          <td>沒既有觸發、要刻意設計</td>
      </tr>
  </tbody>
</table>
<p><strong>真實案例（這個 blog 的 search filter bug 修復）</strong>：</p>
<p>修 #55 層錯位 bug 時、跳過了 Checkpoint 1。直接從 bug 描述進策略選擇 + 實作。Phase 1-4 都做完、跑了 Playwright tests 過 4/4 — 看起來完工。</p>
<p>事後 retrospective Checkpoint 1（user 提醒「需求確認是該 skill 最重要功能之一」）才發現遺漏：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Checkpoint 1 漏掉的 case</th>
          <th>跑驗證才發現</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>URL state</td>
          <td><code>?q=X&amp;scope=Y</code> 持久化</td>
          <td>既有實作完全沒處理 URL state（<a href="../url-as-state-container/">#70</a>）</td>
      </tr>
      <tr>
          <td>A11y</td>
          <td>Tab order 跟 mental model 對齊</td>
          <td>scope 在 search input 之前、反 mental model（<a href="../tab-order-mental-model-alignment/">#71</a>）</td>
      </tr>
      <tr>
          <td>Filter UX</td>
          <td>Type/tag filter 在 sub-mode 完全消失</td>
          <td>Silent 限制、使用者可能誤以為 bug</td>
      </tr>
  </tbody>
</table>
<p>修完 bug + ship test = 表面完成。但 Checkpoint 1 本來該 catch 的 3 個 case 都漏到後期 retrospective 才被發現。<strong>Test 過 ≠ 對齊使用者完整意圖</strong>。</p>
<p>修這個結構性偏差的方法（同 Ship 前）：</p>
<ul>
<li>把「列使用者意圖完整集」做成 checklist 模板、寫之前 5 分鐘填、外化成觸發</li>
<li>用 <a href="../decide-vs-confirm-boundary/">#21 visible 三問</a> 強迫自己列出「使用者會看到的維度」</li>
<li>修 bug 不止修 bug、也檢視該 feature 的所有相關意圖維度</li>
</ul>
<p><a href="../test-first-red-before-green/">#69 Test-First</a> 是 Checkpoint 2/3 的具體協議；本卡是 Checkpoint 1 + 為什麼前後兩個 checkpoint 都被結構性跳過的解釋。</p>
<p>更上位的「為什麼跳過」解釋見 <a href="../external-trigger-for-high-roi-work/">#72 高 ROI 無外部觸發的工作會被結構性跳過</a> — 本卡的 Checkpoint 1 + Ship 前是 #72 在「驗收動作」面向的展現、修法（外化觸發到 PR template / CI / pair）對應 #72 的 L3-L5 對策。</p>
<hr>
<h2 id="瀑布原則漏一層代價指數放大">瀑布原則：漏一層代價指數放大</h2>
<p>漏掉一個 checkpoint 不是線性影響、是指數放大：</p>
<table>
  <thead>
      <tr>
          <th>漏掉哪個 checkpoint</th>
          <th>該失敗會在哪 checkpoint 才被發現</th>
          <th>修復成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫之前</td>
          <td>Ship 前（甚至 ship 後）</td>
          <td>重做整個 feature（×100）</td>
      </tr>
      <tr>
          <td>開發中</td>
          <td>Ship 前</td>
          <td>改一個 module（×10）</td>
      </tr>
      <tr>
          <td>Ship 前</td>
          <td>Ship 後</td>
          <td>熱修 + 信任損失（×100）</td>
      </tr>
      <tr>
          <td>Ship 後</td>
          <td>永遠不修</td>
          <td>累積技術債（不可估）</td>
      </tr>
  </tbody>
</table>
<p>「Ship 後修 bug 多」不是「ship 後驗收做得好」、是「上游 checkpoint 沒做好把 bug 全推下來」 — 看起來在做事、實際在付出指數成本。</p>
<h3 id="為什麼指數放大">為什麼指數放大</h3>
<p>每個 checkpoint 漏掉的失敗、進入下一個 checkpoint 時：</p>
<ol>
<li><strong>Context 已經消失</strong>：下一個 checkpoint 才發現時、寫的人可能已經在做其他事、要重建上下文</li>
<li><strong>依賴已經建立</strong>：別的代碼已經依賴這個有 bug 的 feature、改一處要連帶改五處</li>
<li><strong>使用者已經受影響</strong>：ship 後修還要處理使用者信任 / 資料一致性 / 通知</li>
</ol>
<p>每多漏一層、上述三個因素都疊加、成本翻 N 倍而不是 +N。</p>
<h3 id="防線概念每個-checkpoint-是獨立防線">防線概念：每個 checkpoint 是獨立防線</h3>
<p>把驗收看成 <strong>defense in depth</strong> — 每個 checkpoint 是一道防線、漏掉一道下一道接住。但每道防線的修復成本不同、越上游越便宜。</p>
<p>跟 a11y 三道防線（<a href="../focus-management-on-dom-move/">#37 動態 focus</a> / <a href="../aria-live-for-dynamic-content/">#38 aria-live</a> / <a href="../native-html-over-aria-role/">#39 native HTML</a>）共骨：分散獨立防線比集中單一防線更穩、因為單點失效不會打穿全系統。</p>
<hr>
<h2 id="checkpoint-之間的累積關係">Checkpoint 之間的累積關係</h2>
<p>每個 checkpoint 都該補前面的洞 — 不是等量分配、是優先填上游：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[寫之前 ROI: 高]   抓需求 / 邊界 / 規模意圖
</span></span><span class="line"><span class="ln">2</span><span class="cl">       ↓ 漏掉的進入下一層
</span></span><span class="line"><span class="ln">3</span><span class="cl">[開發中 ROI: 中]   抓邏輯 / 視覺 / 單元
</span></span><span class="line"><span class="ln">4</span><span class="cl">       ↓ 漏掉的進入下一層
</span></span><span class="line"><span class="ln">5</span><span class="cl">[Ship 前 ROI: 中-低] 抓整合 / 規模 / 競態
</span></span><span class="line"><span class="ln">6</span><span class="cl">       ↓ 漏掉的進入下一層
</span></span><span class="line"><span class="ln">7</span><span class="cl">[Ship 後 ROI: 低]   抓罕見 / silent / 長尾</span></span></code></pre></div><p>「Ship 後修 bug 多」= 上游 checkpoint 沒做好、不是「ship 後驗收做得好」。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<h3 id="跟-42-2-次門檻">跟 <a href="../two-occurrence-threshold/">#42 2 次門檻</a></h3>
<p>「畫面對一次」「測試過一次」「使用者沒反映一次」都是低資訊量訊號 — 對應「開發中 checkpoint 過了一次」。第 2 次（跨多個 case / 規模 / 時間）才是真訊號 — 對應「ship 前 checkpoint 也過了」。</p>
<p><a href="../visual-completion-vs-functional-completion/">#56 視覺完成 ≠ 功能完成</a> 是這個關係在「視覺驗收」面向的應用。</p>
<h3 id="跟-67-寫作便利度跟意圖對齊反相關">跟 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></h3>
<p>寫之前 checkpoint 列「意圖完整集」 = 跟便利度脫鉤、強制看見意圖。跳過 = 接受被便利驅動。</p>
<h3 id="跟-56-視覺完成--功能完成">跟 <a href="../visual-completion-vs-functional-completion/">#56 視覺完成 ≠ 功能完成</a></h3>
<p>「畫面對」是開發中 checkpoint 的訊號、不是終點訊號。把它當完工 = 跳過 ship 前 / ship 後 checkpoint。</p>
<hr>
<h2 id="不該套用本原則的情境">不該套用本原則的情境</h2>
<p>「驗收分散在四個時點」這條原則在 ship 給其他人的開發情境成立、但有合理例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不該套用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>純 research / 實驗</td>
          <td>不會 ship 給別人、ship 前 / ship 後 checkpoint 都不存在</td>
      </tr>
      <tr>
          <td>一次性 script</td>
          <td>跑完就丟、沒有「ship」這個階段、四 checkpoint 概念不適用</td>
      </tr>
      <tr>
          <td>純 prototype</td>
          <td>預期會被丟掉、ship 後 monitor 沒意義、開發中 checkpoint 夠</td>
      </tr>
      <tr>
          <td>個人玩具專案</td>
          <td>失敗只影響自己、信任損失成本 ≈ 0、可放寬</td>
      </tr>
  </tbody>
</table>
<p>四類共同特徵：<strong>「ship 後的失敗成本」≈ 0</strong> — 因為沒有真實使用者、沒有信任損失、沒有累積技術債。本原則的瀑布原則建立在「漏一層代價指數放大」上、ship 後成本為 0 時自然不放大。</p>
<p>判讀：寫之前自問「失敗會不會影響別人」 — 否 → 本原則可放寬；是 → 本原則嚴格適用。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫之前沒列「使用者意圖完整集合」</td>
          <td>補 — 5 分鐘列、可以避免 5 小時重做</td>
      </tr>
      <tr>
          <td>開發中只測了 happy path</td>
          <td>補邊界 / 失敗 / 規模 case</td>
      </tr>
      <tr>
          <td>Ship 前沒設計 E2E case、預設「能 build 就 OK」</td>
          <td>加：規模 case + 競態 case + 失敗 case</td>
      </tr>
      <tr>
          <td>Ship 後沒 log / monitor</td>
          <td>加 — 保底 checkpoint 沒設 = 永遠不知道有 silent bug</td>
      </tr>
      <tr>
          <td>Bug report 含「ship 後一週才被發現」</td>
          <td>表示前三個 checkpoint 漏了、要回頭加固</td>
      </tr>
      <tr>
          <td>內心 OS：「之後 QA / 使用者會發現」</td>
          <td>是「集中驗收」幻覺、跳過早期 checkpoint</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：驗收的價值在「分散在多個時點」、每個 checkpoint catch 不同類型的失敗。把驗收 collapse 成單一時點 = 接受該時點之外的失敗都 silent 通過。早期 checkpoint ROI 最高、跳過代價最大。</p>
<p>Checkpoint 2「開發中」+ Checkpoint 3「Ship 前」內部的具體協議：<a href="../test-first-red-before-green/">#69 Test-First：先看到 RED 才相信 GREEN</a> — 寫測試 + 跑兩次（RED-buggy + GREEN-fixed）才能驗證測試本身有用。跳過 RED = 接受測試可能是壞的。</p>
]]></content:encoded></item><item><title>Test-First：先看到 RED 才相信 GREEN</title><link>https://tarrragon.github.io/blog/report/test-first-red-before-green/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/test-first-red-before-green/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>測試本身需要被驗證。&lt;/strong> 一個從沒看過 RED 的測試 = 未驗證的訊號、不是「會抓回歸的測試」。&lt;/p>
&lt;p>驗證一個測試真的有用、需要看到兩個訊號：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>RED&lt;/strong>：測試在「該失敗的版本」上失敗（buggy code → 紅）&lt;/li>
&lt;li>&lt;strong>GREEN&lt;/strong>：測試在「該通過的版本」上通過（fixed code → 綠）&lt;/li>
&lt;/ol>
&lt;p>只看過 GREEN = 不知道測試有沒有 catch 能力；只看過 RED = 不知道修復有沒有真的解問題。&lt;strong>兩個都看到 = 測試 + 修復都被驗證&lt;/strong>。&lt;/p>
&lt;p>跳過 RED 把驗收標準降到「測試跑得通」、漏掉「測試自己有沒有 bug」這層。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼測試需要被驗證">為什麼測試需要被驗證&lt;/h2>
&lt;h3 id="測試是程式-about-程式會有-bug">測試是程式 about 程式、會有 bug&lt;/h3>
&lt;p>測試本身是程式碼、跟其他程式碼一樣會有 bug：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試 bug 類型&lt;/th>
 &lt;th>症狀&lt;/th>
 &lt;th>為什麼跳過 RED 看不到&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Selector 寫錯&lt;/td>
 &lt;td>永遠抓不到目標元素、assertion always 過&lt;/td>
 &lt;td>GREEN（因為沒 assert 到任何東西）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Assertion 太寬&lt;/td>
 &lt;td>&lt;code>expect(x).toBeDefined()&lt;/code> 對 buggy / fixed 都過&lt;/td>
 &lt;td>GREEN（assertion 通過範圍太大）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Setup / fixture 錯&lt;/td>
 &lt;td>測試根本沒跑、報告假性綠&lt;/td>
 &lt;td>GREEN（測試被 skip 但沒人注意）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Race condition / 時機錯&lt;/td>
 &lt;td>Buggy 時剛好在 race window 過、fixed 時也過&lt;/td>
 &lt;td>GREEN（取決於非常規 case）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試對象選錯&lt;/td>
 &lt;td>測 happy path、bug 在邊界&lt;/td>
 &lt;td>GREEN（沒覆蓋 bug 所在的範圍）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這五種都會讓「跑測試一次就 GREEN」是個假訊號 — 測試 pass 不代表測試 catch 到該 catch 的東西。&lt;/p>
&lt;h3 id="red-是測試的使用者驗收">RED 是測試的「使用者驗收」&lt;/h3>
&lt;p>對使用者代碼、我們會用「驗收訊號」（功能跑得對）證明它有用。測試也需要驗收訊號。&lt;/p>
&lt;p>「測試 catch 到 bug」這個能力的驗收訊號 = &lt;strong>「在有 bug 的代碼上失敗」&lt;/strong>。沒看過這個訊號就相信測試 = 跳過驗收。&lt;/p>
&lt;p>對應 &lt;a href="../two-occurrence-threshold/">#42 2 次門檻&lt;/a>：一次 GREEN 是低資訊量訊號、RED → GREEN 是 2 次跑（一次 fail 一次 pass）的高資訊量訊號。&lt;/p>
&lt;hr>
&lt;h2 id="多面向四種情境的-red-green-應用">多面向：四種情境的 RED-GREEN 應用&lt;/h2>
&lt;h3 id="情境-1修-bug">情境 1：修 bug&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1. 先寫一個 test 重現 bug 為失敗 — 例：「filter 後 0 筆但 source 還有未載入時、應該顯示 explicit empty 而非 silent」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. 跑測試 → RED（證明測試抓到 bug、bug 真的存在）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. 修 code
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. 跑測試 → GREEN（證明修對了 + 測試會抓回歸）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跳過第 2 步 = 不知道測試會不會抓到、不知道 bug 真的有沒有。&lt;/p>
&lt;h3 id="情境-2加-feature">情境 2：加 feature&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1. 寫 acceptance test 描述新 feature 該有的行為
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. 跑測試 → RED（feature 還沒實作、應該 fail；如果 GREEN 就表示 feature 已經存在或測試太寬）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. 實作 feature
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. 跑測試 → GREEN&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加 feature 時跳過 RED 風險：feature 被誤以為實作但實際是 stub、或測試根本沒驗到 feature。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>測試本身需要被驗證。</strong> 一個從沒看過 RED 的測試 = 未驗證的訊號、不是「會抓回歸的測試」。</p>
<p>驗證一個測試真的有用、需要看到兩個訊號：</p>
<ol>
<li><strong>RED</strong>：測試在「該失敗的版本」上失敗（buggy code → 紅）</li>
<li><strong>GREEN</strong>：測試在「該通過的版本」上通過（fixed code → 綠）</li>
</ol>
<p>只看過 GREEN = 不知道測試有沒有 catch 能力；只看過 RED = 不知道修復有沒有真的解問題。<strong>兩個都看到 = 測試 + 修復都被驗證</strong>。</p>
<p>跳過 RED 把驗收標準降到「測試跑得通」、漏掉「測試自己有沒有 bug」這層。</p>
<hr>
<h2 id="為什麼測試需要被驗證">為什麼測試需要被驗證</h2>
<h3 id="測試是程式-about-程式會有-bug">測試是程式 about 程式、會有 bug</h3>
<p>測試本身是程式碼、跟其他程式碼一樣會有 bug：</p>
<table>
  <thead>
      <tr>
          <th>測試 bug 類型</th>
          <th>症狀</th>
          <th>為什麼跳過 RED 看不到</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Selector 寫錯</td>
          <td>永遠抓不到目標元素、assertion always 過</td>
          <td>GREEN（因為沒 assert 到任何東西）</td>
      </tr>
      <tr>
          <td>Assertion 太寬</td>
          <td><code>expect(x).toBeDefined()</code> 對 buggy / fixed 都過</td>
          <td>GREEN（assertion 通過範圍太大）</td>
      </tr>
      <tr>
          <td>Setup / fixture 錯</td>
          <td>測試根本沒跑、報告假性綠</td>
          <td>GREEN（測試被 skip 但沒人注意）</td>
      </tr>
      <tr>
          <td>Race condition / 時機錯</td>
          <td>Buggy 時剛好在 race window 過、fixed 時也過</td>
          <td>GREEN（取決於非常規 case）</td>
      </tr>
      <tr>
          <td>測試對象選錯</td>
          <td>測 happy path、bug 在邊界</td>
          <td>GREEN（沒覆蓋 bug 所在的範圍）</td>
      </tr>
  </tbody>
</table>
<p>這五種都會讓「跑測試一次就 GREEN」是個假訊號 — 測試 pass 不代表測試 catch 到該 catch 的東西。</p>
<h3 id="red-是測試的使用者驗收">RED 是測試的「使用者驗收」</h3>
<p>對使用者代碼、我們會用「驗收訊號」（功能跑得對）證明它有用。測試也需要驗收訊號。</p>
<p>「測試 catch 到 bug」這個能力的驗收訊號 = <strong>「在有 bug 的代碼上失敗」</strong>。沒看過這個訊號就相信測試 = 跳過驗收。</p>
<p>對應 <a href="../two-occurrence-threshold/">#42 2 次門檻</a>：一次 GREEN 是低資訊量訊號、RED → GREEN 是 2 次跑（一次 fail 一次 pass）的高資訊量訊號。</p>
<hr>
<h2 id="多面向四種情境的-red-green-應用">多面向：四種情境的 RED-GREEN 應用</h2>
<h3 id="情境-1修-bug">情境 1：修 bug</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 先寫一個 test 重現 bug 為失敗 — 例：「filter 後 0 筆但 source 還有未載入時、應該顯示 explicit empty 而非 silent」
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 跑測試 → RED（證明測試抓到 bug、bug 真的存在）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 修 code
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 跑測試 → GREEN（證明修對了 + 測試會抓回歸）</span></span></code></pre></div><p>跳過第 2 步 = 不知道測試會不會抓到、不知道 bug 真的有沒有。</p>
<h3 id="情境-2加-feature">情境 2：加 feature</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 寫 acceptance test 描述新 feature 該有的行為
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 跑測試 → RED（feature 還沒實作、應該 fail；如果 GREEN 就表示 feature 已經存在或測試太寬）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 實作 feature
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 跑測試 → GREEN</span></span></code></pre></div><p>加 feature 時跳過 RED 風險：feature 被誤以為實作但實際是 stub、或測試根本沒驗到 feature。</p>
<h3 id="情境-3refactor">情境 3：Refactor</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 確認當前測試 GREEN（baseline）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. Refactor（不改 behavior）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 跑測試 → 仍 GREEN</span></span></code></pre></div><p>Refactor <strong>不需要</strong> RED — 因為 behavior 沒變。如果 refactor 後變 RED、表示 refactor 改到了 behavior（變成隱性 bug）、要回頭看。</p>
<h3 id="情境-4偵錯不確定-bug-是什麼">情境 4：偵錯（不確定 bug 是什麼）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 寫一個 test 嘗試重現問題
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 跑測試 → 看是 RED 還是 GREEN：
</span></span><span class="line"><span class="ln">3</span><span class="cl">   - RED → 重現成功、現在可以著手修
</span></span><span class="line"><span class="ln">4</span><span class="cl">   - GREEN → 沒重現到 / 測試寫錯 / bug 在別處 → 重新理解 bug
</span></span><span class="line"><span class="ln">5</span><span class="cl">3. 修
</span></span><span class="line"><span class="ln">6</span><span class="cl">4. 跑測試 → GREEN</span></span></code></pre></div><p>「看是 RED 還是 GREEN」這個動作本身是 debug 訊號 — 比單純猜根因有用。</p>
<hr>
<h2 id="只看-green-不看-red是反模式">「只看 GREEN 不看 RED」是反模式</h2>
<h3 id="反模式-1修完才補測試test-after">反模式 1：修完才補測試（Test-after）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 修 bug code
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 寫測試
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 跑測試 → GREEN
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. ship</span></span></code></pre></div><p>問題：測試從沒跑過 buggy code、不知道它能不能抓到 bug。未來 regression 進來、測試可能仍然 GREEN（測試本身有 bug）。</p>
<h3 id="反模式-2快速跑一下測試沒看訊號">反模式 2：「快速跑一下測試」沒看訊號</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 寫測試
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 跑「應該 pass 吧」、不仔細看輸出
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 看到 PASS → 安心</span></span></code></pre></div><p>問題：可能測試 skip 了、可能測試 zero assertions、可能環境錯了。需要看「具體 catch 到什麼」、不只是「是否 PASS」。</p>
<h3 id="反模式-3測試-pass-但-coverage-是-0">反模式 3：測試 PASS 但 coverage 是 0</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 寫測試 file
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. CI 跑、看到「all green」
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 沒看 coverage report</span></span></code></pre></div><p>問題：測試文件存在但實際沒 import / 沒執行、CI 報告 GREEN 是因為「沒 fail」不是「有 catch」。</p>
<hr>
<h2 id="不該套用本原則的情境">不該套用本原則的情境</h2>
<p>「先看 RED 再看 GREEN」原則在大多數情境成立、但有合理例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不該套用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pure refactor</td>
          <td>沒 behavior 變更、本來就 GREEN、RED 反而表示出問題</td>
      </tr>
      <tr>
          <td>純探索 / spike</td>
          <td>不寫測試、用 console / 手動驗證、不在「測試驗收」範圍</td>
      </tr>
      <tr>
          <td>Build / config 改動沒邏輯</td>
          <td>沒 testable behavior、沒測試可言</td>
      </tr>
      <tr>
          <td>顯眼的 syntax 錯誤修復</td>
          <td>改一個 typo、測試會在 build 階段就 fail、不需要刻意 RED</td>
      </tr>
  </tbody>
</table>
<p>四類共同特徵：<strong>沒有「行為差異」可被測試 catch</strong> — 本原則建立在「測試該 catch 的事」上、沒事可 catch 時自然不適用。</p>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>跟本卡的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../two-occurrence-threshold/">#42 2 次門檻</a></td>
          <td>一次 GREEN 是低資訊量訊號、RED → GREEN 是 2 次跑（一次 fail 一次 pass）的真訊號</td>
      </tr>
      <tr>
          <td><a href="../visual-completion-vs-functional-completion/">#56 視覺完成 ≠ 功能完成</a></td>
          <td>測試 PASS ≠ 測試 verified；同個「訊號需要驗證」結構</td>
      </tr>
      <tr>
          <td><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></td>
          <td>跳過 RED 是便利（不用切 branch / 不重 build）、走 RED-GREEN 是對齊</td>
      </tr>
      <tr>
          <td><a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a></td>
          <td>本卡是 Checkpoint 2「開發中」+ Checkpoint 3「Ship 前」內部的具體協議</td>
      </tr>
  </tbody>
</table>
<p>本卡是把「測試這個動作本身」放進驗收體系：寫測試是動作、跑測試的訊號才是驗收。動作完成 ≠ 驗收完成。</p>
<hr>
<h2 id="對應的實作篇">對應的實作篇</h2>
<p>把測試固化的實作 case 都該套用本卡：</p>
<ul>
<li><a href="../playwright-early-in-loop/">#11 playwright-early-in-loop</a> — 第 2 次推理失敗切 playwright；切過去後寫的 evaluate query 跑 RED-GREEN 才驗證</li>
<li><a href="../layout-tests-with-playwright/">#15 layout-tests-with-playwright</a> — 版型 debug 兩次以上寫測試固化；測試該先在「未修版型」跑 RED 才相信</li>
<li><a href="../verification-method-timing/">#23 verification-method-timing</a> — 驗證方法選對之後、實際驗證需要 RED-GREEN</li>
</ul>
<hr>
<h2 id="retrospective-補驗證的協議">Retrospective 補驗證的協議</h2>
<p>如果已經修完才寫測試（test-after）、可以 retrospectively 補 RED-GREEN 驗證：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. Stash 現有變動 / 切到修前 commit</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">git checkout &lt;pre-fix-commit&gt;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 2. Cherry-pick 測試 commit（或手動複製 test files）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">git cherry-pick &lt;test-commit&gt;
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 或：cp ../tests/foo.spec.ts tests/  # 複製測試檔過來</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 3. Build + 跑測試</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">make site <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 預期：RED（測試抓到 bug）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 4. 切回 main / 修後版本</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">git checkout main
</span></span><span class="line"><span class="ln">15</span><span class="cl">git stash pop
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 5. 跑測試</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 預期：GREEN</span></span></span></code></pre></div><p>兩次跑 + 兩個訊號（RED + GREEN）都對、測試才被驗證。<strong>Retrospective 補驗證 ≠ 不能補</strong> — 比完全跳過 RED 好、比 test-first 弱。</p>
<p>協議已 codify 為 <code>make verify-red-green PRE_FIX=&lt;commit-sha&gt;</code>（見 Makefile）— 五步驟自動化、不需要每次手動 stash / checkout / build / restore。</p>
<h3 id="self-case本卡誕生過程的-dogfooding-失敗">Self-case：本卡誕生過程的 dogfooding 失敗</h3>
<p>本卡是從一次真實的 dogfooding 失敗抽出來的。修 <a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a> bug 時、流程是：</p>
<ol>
<li>修 code（multi-index 策略）</li>
<li>寫 4 個 Playwright tests</li>
<li>跑測試 → 4/4 GREEN</li>
<li>看起來完工</li>
</ol>
<p>User 問「修改之前有先寫測試確保符合預測狀態嗎」— 才意識到沒走 RED。Retrospective 補驗證後發現：<strong>4 個測試只有 1 個真的 catch 到 bug、其他 3 個對 buggy code 也 PASS</strong>（placebo 測試）。</p>
<p>強化後（用 network-level + structural assertion 替換弱 invariant）：buggy code 上 1/4 PASS、3/4 FAIL。Fixed code 上 4/4 PASS。RED-GREEN 兩個訊號都看到、測試才真的驗證。</p>
<p>如果不做 retrospective、會帶著 3/4 placebo 測試 ship — 表面 4/4 GREEN、實際只有 1 個真的防回歸。<strong>「跑得通」≠「會 catch」這個區別、只有走過 RED 才知道</strong>。</p>
<p>跳過 RED 是 <a href="../external-trigger-for-high-roi-work/">#72 高 ROI 無外部觸發的工作</a> 在測試協議的展現 — 修法不是「下次記得」（L1 紀律會失敗）、是 <code>make verify-red-green PRE_FIX=&lt;sha&gt;</code>（L3 工具觸發）+ pre-commit hook 提醒（L3 結構觸發）。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫完測試第一次跑就 GREEN</td>
          <td>警訊 — 確認測試是不是真的有 catch 能力（覆蓋 bug case 嗎？）</td>
      </tr>
      <tr>
          <td>修了 bug 但沒看過該測試 RED 過</td>
          <td>補 retrospective 驗證、或下次採 test-first</td>
      </tr>
      <tr>
          <td>「我等下會跑一下」但沒實際跑</td>
          <td>跟「我等下會 refactor」同類謊言、補不回來</td>
      </tr>
      <tr>
          <td>CI 永遠 GREEN、沒有人改過測試</td>
          <td>看 coverage、可能測試沒在跑</td>
      </tr>
      <tr>
          <td>加了 feature、測試一寫就 GREEN</td>
          <td>feature 可能已經存在、或測試太寬</td>
      </tr>
      <tr>
          <td>測試環境跟 production 環境差太多</td>
          <td>RED 在 dev 但 prod 仍 fail = 測試環境沒 catch 真實 case</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：測試不是「跑得通就有用」、是「跑出該有的訊號才有用」。RED 是測試的驗收訊號、跳過 = 接受測試本身可能是壞的。RED → GREEN 兩次跑、才證明「測試真的會 catch + 修復真的解掉 bug」。</p>
]]></content:encoded></item></channel></rss>