<?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>Comment on Tarragon</title><link>https://tarrragon.github.io/blog/tags/comment/</link><description>Recent content in Comment on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 08 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/comment/index.xml" rel="self" type="application/rss+xml"/><item><title>測試註解與命名紀律</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/</guid><description>&lt;p>測試是會被反覆閱讀的規格文件。這一章整理一套測試文字的紀律——每一條都來自實際 review 中被糾正的寫法，附改寫前後的對照。核心原則一句話：&lt;strong>測試名稱與斷言負責「說內容」，註解只負責「說操作約束」，其他文字都是雜訊&lt;/strong>。&lt;/p>
&lt;h2 id="一測試內容由斷言自述不寫敘述性註解">一、測試內容由斷言自述，不寫敘述性註解&lt;/h2>
&lt;p>測試名稱就是主張、斷言就是內容——讀了名稱和斷言還無法判斷測試驗證什麼，該修的是名稱和斷言，不是加「本測試對應／驗證什麼」的說明註解。&lt;/p>
&lt;p>名稱還承擔一個失敗訊息給不了的東西：失敗輸出只有症狀（Expected X、Actual Y），拿到紅燈的人要逆推「為什麼這個行為是刻意的」，而回答它的位置就是名稱。「直接進入下一階段：不做編輯流程的收尾」讓紅燈的人知道自己撞到的是一條刻意的規則、不是漏網的 bug；名稱說不出這件事為什麼成立，先改名稱，改完仍說不出來才考慮註解。&lt;/p>
&lt;p>允許留下的註解只有兩種：&lt;/p>
&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>「本檔不可初始化 UI 測試綁定——它會把 HTTP client 換成假件、擋掉真實網路」&lt;/td>
 &lt;td>違反會壞、且原因不可能從程式碼看出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>非顯而易見的 setup 原因&lt;/td>
 &lt;td>「先復原現場再下判定，斷言失敗也不留佔用的資源」「延遲重讀，容許後端非同步生效」&lt;/td>
 &lt;td>不解釋的話，這些步驟看起來像多餘或錯序&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="二reason-寫失敗的後果與處置不寫感想">二、reason 寫失敗的後果與處置，不寫感想&lt;/h2>
&lt;p>斷言的失敗訊息是「未來某個紅燈時刻」的第一線資訊，它的讀者正急著知道兩件事：這代表什麼、接下來做什麼。&lt;/p>
&lt;ul>
&lt;li>改寫前：&lt;code>reason: '狀態應該是 1'&lt;/code>&lt;/li>
&lt;li>改寫後：&lt;code>reason: '後端未釋放資源（即刻=X、延遲=Y）——前端刪除編排與假後端都依「同步釋放」設計，需與後端確認並同步修正兩處。'&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>把變數值內插進訊息（實際讀到的狀態、id），紅燈時不用重跑加 log。&lt;/p>
&lt;h2 id="三檔頭陳述目的不論證需求">三、檔頭陳述目的，不論證需求&lt;/h2>
&lt;p>檔頭回答三件事就夠：這是什麼、怎麼用、維護時要做什麼。存在理由的論證（「因為 stub 只會回放假設，所以需要這個假後端，否則⋯⋯」）屬於教材或 PR 說明，不屬於程式碼——讀程式的人需要的是操作資訊，不是被說服。&lt;/p>
&lt;p>同理，&lt;strong>彙整清單不放檔頭&lt;/strong>。「本假後端已模擬的行為：合併＝⋯、更新＝⋯、刪除＝⋯」這種清單，每一條在對應的 handler 方法上都有自己的說明——檔頭清單是重複，而且沒有任何機制守著它與 handler 同步，終將過期。&lt;/p>
&lt;h2 id="四取證出處日期開發過程不入程式碼">四、取證出處、日期、開發過程不入程式碼&lt;/h2>
&lt;p>取證出處、日期與開發過程回答的是「怎麼知道的」；測試文字只需要回答「行為是什麼」。&lt;/p>
&lt;ul>
&lt;li>改寫前：「合併後保留記錄 id（某年某月實測證實）」「本測試曾以相反順序抓到此問題」&lt;/li>
&lt;li>改寫後：「合併後記錄 id 不變（後端只改外鍵）」／刪除&lt;/li>
&lt;/ul>
&lt;p>取證紀錄和開發史寫進版本控制的 commit 訊息、團隊的知識庫即可。程式碼裡的註解只陳述當前為真的行為——帶日期的註解從寫下那刻就開始腐化，「曾經抓到」的敘述在下一個讀者眼中只是噪音。&lt;/p>
&lt;h2 id="五分析詞彙不入測試內容">五、分析詞彙不入測試內容&lt;/h2>
&lt;p>團隊在討論問題時會發展出後設詞彙——「語意」「契約」「漂移」這類幫助人類對齊理解的抽象詞。它們屬於對話，不屬於測試：&lt;/p>
&lt;ul>
&lt;li>改寫前：測試名「後端語意：刪除單據釋放資源」、reason「語意漂移：⋯」&lt;/li>
&lt;li>改寫後：測試名「刪除單據一併釋放關聯資源」、reason「後端未釋放資源——與前端編排依據的行為不符⋯」&lt;/li>
&lt;/ul>
&lt;p>判斷法：這個詞刪掉之後句子有沒有變得更直接？「刪除單據一併釋放關聯資源」是可以直接對後端行為驗證的陳述句；「後端語意：⋯」是套在陳述句外面的分類標籤，資訊量為零。&lt;/p>
&lt;h2 id="六用動作結果的白話取代自創行話">六、用動作＋結果的白話取代自創行話&lt;/h2>
&lt;p>白話的標準是「動作＋結果」：句子直接說出做了什麼、預期看到什麼，讀者不需要先學會團隊內部的簡稱。&lt;/p>
&lt;ul>
&lt;li>改寫前：「驗證早退情境不打後端」&lt;/li>
&lt;li>改寫後：「讓測試能斷言『提前結束的路徑（狀態未推進、空清單）沒有發出請求』」&lt;/li>
&lt;/ul>
&lt;p>判斷法：這個詞在程式碼或團隊詞彙表裡存在嗎？讀者第一次看到需要停下來猜嗎？「早退」「打後端」「塞 mock」這類壓縮語，寫的人省了五個字，每個讀者各付一次理解成本。&lt;/p>
&lt;h2 id="七跳過訊息要可行動">七、跳過訊息要可行動&lt;/h2>
&lt;p>測試被跳過時，輸出裡那行 skip 訊息是讀者唯一的線索。&lt;/p>
&lt;ul>
&lt;li>改寫前：&lt;code>skip: '未提供憑證'&lt;/code>&lt;/li>
&lt;li>改寫後：&lt;code>skip: '未提供憑證，跳過真實後端驗證——帶 &amp;lt;具體參數&amp;gt; 執行（見檔頭）'&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>原則與 reason 相同：告訴讀者這代表什麼、怎麼讓它跑起來。&lt;/p>
&lt;h2 id="彙總判斷表">彙總判斷表&lt;/h2>
&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>測試名稱&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗代表什麼、怎麼處置&lt;/td>
 &lt;td>expect 的 reason&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>違反會壞的環境約束&lt;/td>
 &lt;td>檔頭註解&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不解釋會看不懂的 setup 步驟&lt;/td>
 &lt;td>該行上方一行註解&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>存在理由的論證、設計取捨&lt;/td>
 &lt;td>教材／PR 說明&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>取證過程、日期、開發史&lt;/td>
 &lt;td>commit 訊息／知識庫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分析用的後設詞彙&lt;/td>
 &lt;td>對話裡，用完就留在對話&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>怕有人改壞某個約束&lt;/td>
 &lt;td>一條會紅的測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>最後一列跟其他列的性質不同。前面幾列都在分配「這段文字該放哪個 surface」，而它是在說這段內容根本不是文字問題——寫下它的動機是防護，而註解不參與執行、改壞的當下不會發聲。本篇第一節允許留在檔頭的那類註解（「本檔不可初始化 UI 測試綁定」）就是這個形態的邊界案例：它守的是環境約束，而環境約束沒有任何測試接得住，所以留在檔頭是對的。判定方式與當場可執行的驗證見 &lt;a href="https://tarrragon.github.io/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束&lt;/a>。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>斷言本身的品質 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">斷言品質三問&lt;/a>&lt;/li>
&lt;li>那條會紅的測試該測什麼、與註解分工的全景 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-as-change-guard/" data-link-title="測試的價值發生在它變紅的那一刻——建立、變更與註解分工的防護視角" data-link-desc="為一段邏輯決定第一條測試該測什麼、變更或重構前評估既有測試會擋什麼、review 裡爭論某個約束該寫註解還是測試、或拿到紅燈要判讀它是刻意規則還是漏網 bug 時使用。這些問題共用同一個視角：測試的價值在未來的紅燈、不在當下的綠燈。">測試的價值發生在它變紅的那一刻&lt;/a>&lt;/li>
&lt;li>想寫的其實是防護註解、該不該改成一條測試 → &lt;a href="https://tarrragon.github.io/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束&lt;/a>&lt;/li>
&lt;li>這套紀律誕生的測試形態 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>「沒有機制守著就終將過期」推廣到整條文件鏈（spec / 設計文件 / 追溯表）→ &lt;a href="https://tarrragon.github.io/blog/report/doc-sync-needs-mechanism-or-demotion/" data-link-title="多份文件必然漂移：同步期待要嘛有機制承接、要嘛明示降級" data-link-desc="設計開發流程的文件鏈（proposal、spec、UC、設計文件、追溯表、測試、註解）、或審查一套流程的文件模型時使用。判準是每份文件的同步期待與守護機制是否匹配：期待最新就要有機制守著、沒有機制就降級為一次性 scaffold 或 append-only 記錄。">#256 多份文件必然漂移&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>測試是會被反覆閱讀的規格文件。這一章整理一套測試文字的紀律——每一條都來自實際 review 中被糾正的寫法，附改寫前後的對照。核心原則一句話：<strong>測試名稱與斷言負責「說內容」，註解只負責「說操作約束」，其他文字都是雜訊</strong>。</p>
<h2 id="一測試內容由斷言自述不寫敘述性註解">一、測試內容由斷言自述，不寫敘述性註解</h2>
<p>測試名稱就是主張、斷言就是內容——讀了名稱和斷言還無法判斷測試驗證什麼，該修的是名稱和斷言，不是加「本測試對應／驗證什麼」的說明註解。</p>
<p>名稱還承擔一個失敗訊息給不了的東西：失敗輸出只有症狀（Expected X、Actual Y），拿到紅燈的人要逆推「為什麼這個行為是刻意的」，而回答它的位置就是名稱。「直接進入下一階段：不做編輯流程的收尾」讓紅燈的人知道自己撞到的是一條刻意的規則、不是漏網的 bug；名稱說不出這件事為什麼成立，先改名稱，改完仍說不出來才考慮註解。</p>
<p>允許留下的註解只有兩種：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>例子</th>
          <th>為什麼留</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>操作性約束</td>
          <td>「本檔不可初始化 UI 測試綁定——它會把 HTTP client 換成假件、擋掉真實網路」</td>
          <td>違反會壞、且原因不可能從程式碼看出</td>
      </tr>
      <tr>
          <td>非顯而易見的 setup 原因</td>
          <td>「先復原現場再下判定，斷言失敗也不留佔用的資源」「延遲重讀，容許後端非同步生效」</td>
          <td>不解釋的話，這些步驟看起來像多餘或錯序</td>
      </tr>
  </tbody>
</table>
<h2 id="二reason-寫失敗的後果與處置不寫感想">二、reason 寫失敗的後果與處置，不寫感想</h2>
<p>斷言的失敗訊息是「未來某個紅燈時刻」的第一線資訊，它的讀者正急著知道兩件事：這代表什麼、接下來做什麼。</p>
<ul>
<li>改寫前：<code>reason: '狀態應該是 1'</code></li>
<li>改寫後：<code>reason: '後端未釋放資源（即刻=X、延遲=Y）——前端刪除編排與假後端都依「同步釋放」設計，需與後端確認並同步修正兩處。'</code></li>
</ul>
<p>把變數值內插進訊息（實際讀到的狀態、id），紅燈時不用重跑加 log。</p>
<h2 id="三檔頭陳述目的不論證需求">三、檔頭陳述目的，不論證需求</h2>
<p>檔頭回答三件事就夠：這是什麼、怎麼用、維護時要做什麼。存在理由的論證（「因為 stub 只會回放假設，所以需要這個假後端，否則⋯⋯」）屬於教材或 PR 說明，不屬於程式碼——讀程式的人需要的是操作資訊，不是被說服。</p>
<p>同理，<strong>彙整清單不放檔頭</strong>。「本假後端已模擬的行為：合併＝⋯、更新＝⋯、刪除＝⋯」這種清單，每一條在對應的 handler 方法上都有自己的說明——檔頭清單是重複，而且沒有任何機制守著它與 handler 同步，終將過期。</p>
<h2 id="四取證出處日期開發過程不入程式碼">四、取證出處、日期、開發過程不入程式碼</h2>
<p>取證出處、日期與開發過程回答的是「怎麼知道的」；測試文字只需要回答「行為是什麼」。</p>
<ul>
<li>改寫前：「合併後保留記錄 id（某年某月實測證實）」「本測試曾以相反順序抓到此問題」</li>
<li>改寫後：「合併後記錄 id 不變（後端只改外鍵）」／刪除</li>
</ul>
<p>取證紀錄和開發史寫進版本控制的 commit 訊息、團隊的知識庫即可。程式碼裡的註解只陳述當前為真的行為——帶日期的註解從寫下那刻就開始腐化，「曾經抓到」的敘述在下一個讀者眼中只是噪音。</p>
<h2 id="五分析詞彙不入測試內容">五、分析詞彙不入測試內容</h2>
<p>團隊在討論問題時會發展出後設詞彙——「語意」「契約」「漂移」這類幫助人類對齊理解的抽象詞。它們屬於對話，不屬於測試：</p>
<ul>
<li>改寫前：測試名「後端語意：刪除單據釋放資源」、reason「語意漂移：⋯」</li>
<li>改寫後：測試名「刪除單據一併釋放關聯資源」、reason「後端未釋放資源——與前端編排依據的行為不符⋯」</li>
</ul>
<p>判斷法：這個詞刪掉之後句子有沒有變得更直接？「刪除單據一併釋放關聯資源」是可以直接對後端行為驗證的陳述句；「後端語意：⋯」是套在陳述句外面的分類標籤，資訊量為零。</p>
<h2 id="六用動作結果的白話取代自創行話">六、用動作＋結果的白話取代自創行話</h2>
<p>白話的標準是「動作＋結果」：句子直接說出做了什麼、預期看到什麼，讀者不需要先學會團隊內部的簡稱。</p>
<ul>
<li>改寫前：「驗證早退情境不打後端」</li>
<li>改寫後：「讓測試能斷言『提前結束的路徑（狀態未推進、空清單）沒有發出請求』」</li>
</ul>
<p>判斷法：這個詞在程式碼或團隊詞彙表裡存在嗎？讀者第一次看到需要停下來猜嗎？「早退」「打後端」「塞 mock」這類壓縮語，寫的人省了五個字，每個讀者各付一次理解成本。</p>
<h2 id="七跳過訊息要可行動">七、跳過訊息要可行動</h2>
<p>測試被跳過時，輸出裡那行 skip 訊息是讀者唯一的線索。</p>
<ul>
<li>改寫前：<code>skip: '未提供憑證'</code></li>
<li>改寫後：<code>skip: '未提供憑證，跳過真實後端驗證——帶 &lt;具體參數&gt; 執行（見檔頭）'</code></li>
</ul>
<p>原則與 reason 相同：告訴讀者這代表什麼、怎麼讓它跑起來。</p>
<h2 id="彙總判斷表">彙總判斷表</h2>
<table>
  <thead>
      <tr>
          <th>想寫的內容</th>
          <th>該放哪</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>這條測試驗證什麼</td>
          <td>測試名稱</td>
      </tr>
      <tr>
          <td>失敗代表什麼、怎麼處置</td>
          <td>expect 的 reason</td>
      </tr>
      <tr>
          <td>違反會壞的環境約束</td>
          <td>檔頭註解</td>
      </tr>
      <tr>
          <td>不解釋會看不懂的 setup 步驟</td>
          <td>該行上方一行註解</td>
      </tr>
      <tr>
          <td>存在理由的論證、設計取捨</td>
          <td>教材／PR 說明</td>
      </tr>
      <tr>
          <td>取證過程、日期、開發史</td>
          <td>commit 訊息／知識庫</td>
      </tr>
      <tr>
          <td>分析用的後設詞彙</td>
          <td>對話裡，用完就留在對話</td>
      </tr>
      <tr>
          <td>怕有人改壞某個約束</td>
          <td>一條會紅的測試</td>
      </tr>
  </tbody>
</table>
<p>最後一列跟其他列的性質不同。前面幾列都在分配「這段文字該放哪個 surface」，而它是在說這段內容根本不是文字問題——寫下它的動機是防護，而註解不參與執行、改壞的當下不會發聲。本篇第一節允許留在檔頭的那類註解（「本檔不可初始化 UI 測試綁定」）就是這個形態的邊界案例：它守的是環境約束，而環境約束沒有任何測試接得住，所以留在檔頭是對的。判定方式與當場可執行的驗證見 <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>斷言本身的品質 → <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">斷言品質三問</a></li>
<li>那條會紅的測試該測什麼、與註解分工的全景 → <a href="/blog/testing/05-test-design-judgment/test-as-change-guard/" data-link-title="測試的價值發生在它變紅的那一刻——建立、變更與註解分工的防護視角" data-link-desc="為一段邏輯決定第一條測試該測什麼、變更或重構前評估既有測試會擋什麼、review 裡爭論某個約束該寫註解還是測試、或拿到紅燈要判讀它是刻意規則還是漏網 bug 時使用。這些問題共用同一個視角：測試的價值在未來的紅燈、不在當下的綠燈。">測試的價值發生在它變紅的那一刻</a></li>
<li>想寫的其實是防護註解、該不該改成一條測試 → <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束</a></li>
<li>這套紀律誕生的測試形態 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>「沒有機制守著就終將過期」推廣到整條文件鏈（spec / 設計文件 / 追溯表）→ <a href="/blog/report/doc-sync-needs-mechanism-or-demotion/" data-link-title="多份文件必然漂移：同步期待要嘛有機制承接、要嘛明示降級" data-link-desc="設計開發流程的文件鏈（proposal、spec、UC、設計文件、追溯表、測試、註解）、或審查一套流程的文件模型時使用。判準是每份文件的同步期待與守護機制是否匹配：期待最新就要有機制守著、沒有機制就降級為一次性 scaffold 或 append-only 記錄。">#256 多份文件必然漂移</a></li>
</ul>
]]></content:encoded></item><item><title>測試的價值發生在它變紅的那一刻——建立、變更與註解分工的防護視角</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-as-change-guard/</link><pubDate>Sat, 08 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-as-change-guard/</guid><description>&lt;p>四個時刻會用到這一篇：為一段還沒有測試的邏輯決定第一條測試該測什麼；改功能、加功能或重構之前，評估現有的測試會替這次變更擋什麼；review 裡爭論某個約束該寫成註解還是測試；以及拿到一顆紅燈，讀不出它是刻意規則還是漏網 bug 的時候。這些問題表面不同，共用同一個視角——測試的價值在時間軸上怎麼分布。&lt;/p>
&lt;h2 id="建立測試時價值在未來的紅燈不在當下的綠燈">建立測試時：價值在未來的紅燈，不在當下的綠燈&lt;/h2>
&lt;p>寫測試的當下，程式是對的——不對的話會先修程式再讓測試過；寫的過程若抓到現有 bug，那份價值發生在先出現的那顆紅燈。所以測試寫完的那顆綠燈，確認的是「此刻的行為與此刻的預期一致」，而這件事作者當場已經驗證過。這條測試之後的防護價值全部發生在未來：某一次變更讓行為偏離預期、測試變紅、變更的人在合併之前就知道。&lt;/p>
&lt;p>本篇談的是測試作為長存產物的防護價值。寫測試這個動作在當下逼出的介面設計回饋（測試先行時，介面由使用端先定義）、以及被當成規格文件閱讀的價值，是另外兩條軸、不在本篇的時間軸上——而 TDD 的紅綠循環，正是把「先出現的紅燈」制度化提前。綠燈給的部署信心也來自同一處：信心的大小等於它會紅的能力，一條恆真的斷言給不出任何信心。&lt;/p>
&lt;p>這個時間差改變了建立測試時該問的問題：不是「現在的行為對不對」，是「&lt;strong>未來哪一種改壞，要被這條測試擋下&lt;/strong>」。&lt;/p>
&lt;p>兩個問題會選出不同的測試。一個批次操作有兩個入口按鈕，差別在合併完成後的收尾——一種回到編輯流程、一種直接進入下一階段；選了哪一種，程式記在一個&lt;strong>用途值&lt;/strong>裡，而執行順序是先重設選取狀態、之後才讀取用途值來決定收尾。問「現在對不對」，寫出來的是主流程斷言：合併後清單變成一筆。問「哪種改壞要被擋」，看到的是另一件事：任何人把用途值加進重設的清除清單，讀取時拿到的就是預設值——第一種用途，於是第二種用途靜默走成第一種：不報錯、不彈窗，使用者按第二種入口、得到第一種收尾。這個問題指向的是這樣一條測試：「直接進入下一階段：不做編輯流程的收尾」。兩條測試在今天都綠，防護範圍完全不同（案例完整推導見&lt;a href="https://tarrragon.github.io/blog/work-log/comment_cannot_guard_invariant/" data-link-title="註解防不了改壞——防護需求要交給會發聲的機制" data-link-desc="想給欄位或函式寫一行 doc、或審查一則宣稱約束的註解時使用。判斷這個資訊該由測試、型別、命名還是結構承接，以及約束本身能不能先被消除。">註解防不了改壞&lt;/a>）。&lt;/p>
&lt;p>防護存不存在可以當場觀察、不必推測：在違反約束的位置故意加一行改動，跑測試，看 runner 印出的失敗輸出（&lt;code>+2 -1&lt;/code> 是通過 / 失敗計數、&lt;code>[E]&lt;/code> 標記錯誤）——&lt;/p>





&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">+2 -1: 直接進入下一階段：不做編輯流程的收尾 [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> Expected: Step:&amp;lt;Step.finished&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> Actual: Step:&amp;lt;Step.editing&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>紅了，防護存在；還原改動。建立一條防護型測試之後做一次這個動作，比讀十遍測試碼可靠——「有測試」與「那條測試會對這個違反發聲」是兩件事。&lt;/p>
&lt;p>接手零測試的專案時，「未來哪種改壞要被擋」的第一批答案在風險集中處，起步順序見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/legacy-test-bootstrap/" data-link-title="無測試 legacy 專案的起步順序" data-link-desc="接手零測試的專案、有限預算下第一批測試該從哪一層開始建——按風險集中處判斷起步路徑，而非照測試金字塔從底部往上疊">無測試 legacy 專案的起步順序&lt;/a>；變更前先把現狀鎖住的測試形態是 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">characterization test&lt;/a>——它的斷言對象是「現在的行為」而不是「正確的行為」，是防護視角的退位形式：依賴纏結或規格失傳、無法先寫正確性測試時，用它先把現狀鎖住。&lt;/p>
&lt;h2 id="變更時型別裝不下的約束測試是唯一在改壞當下發聲的機制">變更時：型別裝不下的約束，測試是唯一在改壞當下發聲的機制&lt;/h2>
&lt;p>改功能、加功能、重構，三種變更都會經過別人寫下的程式。變更的人手上有哪些防護，差別在訊號出現的時機、以及能不能被忽略：&lt;/p>
&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>編譯當下，無法忽略&lt;/td>
 &lt;td>寫不出違反約束的程式碼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試&lt;/td>
 &lt;td>跑測試時（pre-commit / CI）&lt;/td>
 &lt;td>改壞的當下變紅，合併前就知道&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>runtime assertion / 寫入時 schema 驗證&lt;/td>
 &lt;td>該路徑被執行時&lt;/td>
 &lt;td>開發期這條路徑通常靠測試才被跑到；沒有測試就要等上線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lint / architecture test&lt;/td>
 &lt;td>CI 當下&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;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>）落在型別外面時，測試是唯一以行為為斷言對象、在改壞當下發聲的位置。前提是它會自動跑——掛在 pre-commit（commit 前自動執行的 git hook）或 &lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合如何在合併前自動驗證變更品質與相容性">CI&lt;/a> 上，沒有人手動觸發它也發得了聲。&lt;/p>
&lt;p>命名把約束帶到每個呼叫點——第一節的用途值取 &lt;code>purposeSurvivingReset&lt;/code> 這種名字，動手的人在改之前會多看一眼。但 IDE 的批次 rename 與自動重構不讀語意，名字擋不下工具驅動的變更；它是有價值的輔助，不是防護的終點。&lt;/p>
&lt;p>重構時測試有雙重角色，分界在斷言的對象。斷言行為的測試是重構的安全網——行為不變就綠，結構怎麼改都放心；斷言實作的測試是重構的阻力——內部調整就紅，而那顆紅燈不代表壞掉。&lt;/p>
&lt;p>阻力長這樣（機制推導）：當初寫測試的人 mock 了內部協作者、斷言呼叫次數，那樣最容易讓覆蓋率達標——這正是&lt;a href="https://tarrragon.github.io/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束被最便宜的路徑滿足&lt;/a>的形態。沒人動結構的期間，這批測試跟斷行為的測試一樣綠；第一次重構，它們整批變紅，而行為一項也沒壞。紅燈是不是真的壞掉只能逐條打開人工判定，而那時最省事的動作是改測試遷就新結構、或放棄重構。邊界在斷言的對象：呼叫本身是對外契約時（如 outbox 的 publish 保證），次數與順序就是行為，斷它是安全網、不是阻力。&lt;/p>
&lt;p>這條分界就是&lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">斷言品質三問&lt;/a>的第一問（斷言的是行為嗎），防護視角補上它的理由：重構需要的是「行為被改壞才發聲」的訊號，對實作發聲的測試把訊號稀釋成噪音。&lt;/p>
&lt;p>加功能時，既有測試定義舊行為的邊界。新功能動到&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/shared-mutable-state/" data-link-title="Shared Mutable State（共享可變狀態）" data-link-desc="判斷一個值該不該放在多方都讀得到、都改得動的位置時使用。共享可變狀態會長出跨函式的時序約束，而那類約束在型別與建構子檢查裡都沒有位置寫得下。">共享可變狀態&lt;/a>、舊測試紅，代表新功能改壞了舊契約——這顆紅燈正是建立時那個「未來哪種改壞」的兌現，它在合併之前就浮現，不必等使用者回報。&lt;/p>
&lt;h2 id="review-爭論時測試與註解的分工">review 爭論時：測試與註解的分工&lt;/h2>
&lt;p>review 裡「這個約束該寫註解還是測試」的爭論，判準是&lt;strong>這段資訊有沒有對應的斷言&lt;/strong>——存不存在一條會紅的斷言，不是造不造得出句子：&lt;/p>
&lt;ul>
&lt;li>寫得出會紅的斷言（讀寫順序、時序耦合、狀態必須活過某次操作）→ 資訊的家是&lt;strong>測試名稱&lt;/strong>。&lt;/li>
&lt;li>斷言守不住的那一半——來源在 repo 之外的出處與當初的取捨（法規條號、稽核要求、後端契約為什麼長這樣）→ 家是 doc comment。&lt;/li>
&lt;li>兩者可以同時成立：&lt;code>test('留存不得少於七年')&lt;/code> 既有斷言、來源又在 repo 外——測試守行為、註解留出處，各寫各的那一半。&lt;/li>
&lt;li>還有一類斷言寫得出來、但不穩定或成本過高：並行安全、性能量級這種依賴非確定因素的約束，硬掛上 pre-commit 就違反本模組自己的 flaky 紀律（見&lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">Flaky test 根因分類&lt;/a>）。這類資訊的家仍是 doc comment，語意是使用契約而非防護；要升級成防護，走專用的 benchmark 或 stress pipeline、不進提交閘門。&lt;/li>
&lt;/ul>
&lt;p>防護意圖寫成註解，改壞的當下不產生任何訊號——註解不參與執行，作用只發生在有人剛好讀到的時候；當那條會紅的斷言已經存在時，那行註解更是一份沒有保護力的副本。更糟的是宣稱約束的註解會讓 reviewer 以為有人在守——半年後有人在重設函式補上一行清除，那次 diff 不包含註解所在的行，reviewer 讀到的是一段合理的清理程式碼。合併之後沒有任何報錯——使用者按第二種入口得到第一種收尾，要等有人回報才會被發現。&lt;/p>
&lt;p>分工有邊界：測試涵蓋不到的 surface（設定檔、build script、schema 定義）註解是唯一載體；給下游用的公開 API，下游 clone 不到專案內部的測試檔，doc 在那裡是契約介面。完整的動機辨識、「先問約束能不能被消除」的判斷閘、與失效邊界見 &lt;a href="https://tarrragon.github.io/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時，要處理的是那個約束&lt;/a>。&lt;/p>
&lt;h2 id="拿到紅燈時名稱承載意圖reason-承載處置">拿到紅燈時：名稱承載意圖，reason 承載處置&lt;/h2>
&lt;p>失敗輸出回答「壞在哪」，測試名稱回答「為什麼這是刻意的」。第一節那條失敗輸出只給了症狀：預期進入下一階段、實際停在編輯階段。拿到紅燈的人下一個問題是「為什麼這個行為是刻意的」——承擔這個「為什麼」的是&lt;strong>測試名稱&lt;/strong>。「直接進入下一階段：不做編輯流程的收尾」把意圖寫在名稱裡，紅燈的人讀名稱就知道自己撞到的是一條刻意的規則、不是漏網的 bug。&lt;/p>
&lt;p>名稱說不出理由時走的是另一條路（機制推導）：紅燈的人查不出這條規則為誰存在，deadline 前最合理的動作是把斷言改成新行為、讓測試回綠——防護在它發聲的那一刻被解除，而那次 diff 在 review 裡讀起來只是一次正常的測試更新。所以名稱說不出這件事為什麼成立，先改名稱；改完仍說不出來，才考慮在測試旁留註解。&lt;/p>
&lt;p>名稱、reason、skip 訊息、檔頭與 setup 註解的逐項紀律——包含 reason 寫失敗的後果與處置、分析詞彙不入程式碼——見&lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律&lt;/a>，該篇文末的彙總判斷表把每類文字的落點列齊了。&lt;/p></description><content:encoded><![CDATA[<p>四個時刻會用到這一篇：為一段還沒有測試的邏輯決定第一條測試該測什麼；改功能、加功能或重構之前，評估現有的測試會替這次變更擋什麼；review 裡爭論某個約束該寫成註解還是測試；以及拿到一顆紅燈，讀不出它是刻意規則還是漏網 bug 的時候。這些問題表面不同，共用同一個視角——測試的價值在時間軸上怎麼分布。</p>
<h2 id="建立測試時價值在未來的紅燈不在當下的綠燈">建立測試時：價值在未來的紅燈，不在當下的綠燈</h2>
<p>寫測試的當下，程式是對的——不對的話會先修程式再讓測試過；寫的過程若抓到現有 bug，那份價值發生在先出現的那顆紅燈。所以測試寫完的那顆綠燈，確認的是「此刻的行為與此刻的預期一致」，而這件事作者當場已經驗證過。這條測試之後的防護價值全部發生在未來：某一次變更讓行為偏離預期、測試變紅、變更的人在合併之前就知道。</p>
<p>本篇談的是測試作為長存產物的防護價值。寫測試這個動作在當下逼出的介面設計回饋（測試先行時，介面由使用端先定義）、以及被當成規格文件閱讀的價值，是另外兩條軸、不在本篇的時間軸上——而 TDD 的紅綠循環，正是把「先出現的紅燈」制度化提前。綠燈給的部署信心也來自同一處：信心的大小等於它會紅的能力，一條恆真的斷言給不出任何信心。</p>
<p>這個時間差改變了建立測試時該問的問題：不是「現在的行為對不對」，是「<strong>未來哪一種改壞，要被這條測試擋下</strong>」。</p>
<p>兩個問題會選出不同的測試。一個批次操作有兩個入口按鈕，差別在合併完成後的收尾——一種回到編輯流程、一種直接進入下一階段；選了哪一種，程式記在一個<strong>用途值</strong>裡，而執行順序是先重設選取狀態、之後才讀取用途值來決定收尾。問「現在對不對」，寫出來的是主流程斷言：合併後清單變成一筆。問「哪種改壞要被擋」，看到的是另一件事：任何人把用途值加進重設的清除清單，讀取時拿到的就是預設值——第一種用途，於是第二種用途靜默走成第一種：不報錯、不彈窗，使用者按第二種入口、得到第一種收尾。這個問題指向的是這樣一條測試：「直接進入下一階段：不做編輯流程的收尾」。兩條測試在今天都綠，防護範圍完全不同（案例完整推導見<a href="/blog/work-log/comment_cannot_guard_invariant/" data-link-title="註解防不了改壞——防護需求要交給會發聲的機制" data-link-desc="想給欄位或函式寫一行 doc、或審查一則宣稱約束的註解時使用。判斷這個資訊該由測試、型別、命名還是結構承接，以及約束本身能不能先被消除。">註解防不了改壞</a>）。</p>
<p>防護存不存在可以當場觀察、不必推測：在違反約束的位置故意加一行改動，跑測試，看 runner 印出的失敗輸出（<code>+2 -1</code> 是通過 / 失敗計數、<code>[E]</code> 標記錯誤）——</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">+2 -1: 直接進入下一階段：不做編輯流程的收尾 [E]
</span></span><span class="line"><span class="ln">2</span><span class="cl">  Expected: Step:&lt;Step.finished&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">    Actual: Step:&lt;Step.editing&gt;</span></span></code></pre></div><p>紅了，防護存在；還原改動。建立一條防護型測試之後做一次這個動作，比讀十遍測試碼可靠——「有測試」與「那條測試會對這個違反發聲」是兩件事。</p>
<p>接手零測試的專案時，「未來哪種改壞要被擋」的第一批答案在風險集中處，起步順序見<a href="/blog/testing/01-test-strategy-layers/legacy-test-bootstrap/" data-link-title="無測試 legacy 專案的起步順序" data-link-desc="接手零測試的專案、有限預算下第一批測試該從哪一層開始建——按風險集中處判斷起步路徑，而非照測試金字塔從底部往上疊">無測試 legacy 專案的起步順序</a>；變更前先把現狀鎖住的測試形態是 <a href="/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">characterization test</a>——它的斷言對象是「現在的行為」而不是「正確的行為」，是防護視角的退位形式：依賴纏結或規格失傳、無法先寫正確性測試時，用它先把現狀鎖住。</p>
<h2 id="變更時型別裝不下的約束測試是唯一在改壞當下發聲的機制">變更時：型別裝不下的約束，測試是唯一在改壞當下發聲的機制</h2>
<p>改功能、加功能、重構，三種變更都會經過別人寫下的程式。變更的人手上有哪些防護，差別在訊號出現的時機、以及能不能被忽略：</p>
<table>
  <thead>
      <tr>
          <th>手段</th>
          <th>訊號出現的時機</th>
          <th>對變更的人的作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>型別</td>
          <td>編譯當下，無法忽略</td>
          <td>寫不出違反約束的程式碼</td>
      </tr>
      <tr>
          <td>測試</td>
          <td>跑測試時（pre-commit / CI）</td>
          <td>改壞的當下變紅，合併前就知道</td>
      </tr>
      <tr>
          <td>runtime assertion / 寫入時 schema 驗證</td>
          <td>該路徑被執行時</td>
          <td>開發期這條路徑通常靠測試才被跑到；沒有測試就要等上線</td>
      </tr>
      <tr>
          <td>lint / architecture test</td>
          <td>CI 當下</td>
          <td>守程式文本形狀的規則（依賴方向、禁用模式）；行為約束塞進去會變成綁死實作的語法禁令</td>
      </tr>
      <tr>
          <td>命名</td>
          <td>讀到呼叫點時</td>
          <td>動手前多看一眼，但不會自動發聲</td>
      </tr>
      <tr>
          <td>註解</td>
          <td>剛好讀到宣告處時</td>
          <td>批次整理與重構的人常常根本不經過那一行</td>
      </tr>
  </tbody>
</table>
<p>型別的訊號最早，但裝得下的約束窄——「不該是負數」交給非負型別，違反它的程式碼根本寫不出來；「必須活過某次重設」這種跨函式的讀寫順序，違反的程式碼寫得出來、而且編譯得過。跨時間必須恆真的條件（<a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>）落在型別外面時，測試是唯一以行為為斷言對象、在改壞當下發聲的位置。前提是它會自動跑——掛在 pre-commit（commit 前自動執行的 git hook）或 <a href="/blog/ci/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合如何在合併前自動驗證變更品質與相容性">CI</a> 上，沒有人手動觸發它也發得了聲。</p>
<p>命名把約束帶到每個呼叫點——第一節的用途值取 <code>purposeSurvivingReset</code> 這種名字，動手的人在改之前會多看一眼。但 IDE 的批次 rename 與自動重構不讀語意，名字擋不下工具驅動的變更；它是有價值的輔助，不是防護的終點。</p>
<p>重構時測試有雙重角色，分界在斷言的對象。斷言行為的測試是重構的安全網——行為不變就綠，結構怎麼改都放心；斷言實作的測試是重構的阻力——內部調整就紅，而那顆紅燈不代表壞掉。</p>
<p>阻力長這樣（機制推導）：當初寫測試的人 mock 了內部協作者、斷言呼叫次數，那樣最容易讓覆蓋率達標——這正是<a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束被最便宜的路徑滿足</a>的形態。沒人動結構的期間，這批測試跟斷行為的測試一樣綠；第一次重構，它們整批變紅，而行為一項也沒壞。紅燈是不是真的壞掉只能逐條打開人工判定，而那時最省事的動作是改測試遷就新結構、或放棄重構。邊界在斷言的對象：呼叫本身是對外契約時（如 outbox 的 publish 保證），次數與順序就是行為，斷它是安全網、不是阻力。</p>
<p>這條分界就是<a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">斷言品質三問</a>的第一問（斷言的是行為嗎），防護視角補上它的理由：重構需要的是「行為被改壞才發聲」的訊號，對實作發聲的測試把訊號稀釋成噪音。</p>
<p>加功能時，既有測試定義舊行為的邊界。新功能動到<a href="/blog/ddd/knowledge-cards/shared-mutable-state/" data-link-title="Shared Mutable State（共享可變狀態）" data-link-desc="判斷一個值該不該放在多方都讀得到、都改得動的位置時使用。共享可變狀態會長出跨函式的時序約束，而那類約束在型別與建構子檢查裡都沒有位置寫得下。">共享可變狀態</a>、舊測試紅，代表新功能改壞了舊契約——這顆紅燈正是建立時那個「未來哪種改壞」的兌現，它在合併之前就浮現，不必等使用者回報。</p>
<h2 id="review-爭論時測試與註解的分工">review 爭論時：測試與註解的分工</h2>
<p>review 裡「這個約束該寫註解還是測試」的爭論，判準是<strong>這段資訊有沒有對應的斷言</strong>——存不存在一條會紅的斷言，不是造不造得出句子：</p>
<ul>
<li>寫得出會紅的斷言（讀寫順序、時序耦合、狀態必須活過某次操作）→ 資訊的家是<strong>測試名稱</strong>。</li>
<li>斷言守不住的那一半——來源在 repo 之外的出處與當初的取捨（法規條號、稽核要求、後端契約為什麼長這樣）→ 家是 doc comment。</li>
<li>兩者可以同時成立：<code>test('留存不得少於七年')</code> 既有斷言、來源又在 repo 外——測試守行為、註解留出處，各寫各的那一半。</li>
<li>還有一類斷言寫得出來、但不穩定或成本過高：並行安全、性能量級這種依賴非確定因素的約束，硬掛上 pre-commit 就違反本模組自己的 flaky 紀律（見<a href="/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">Flaky test 根因分類</a>）。這類資訊的家仍是 doc comment，語意是使用契約而非防護；要升級成防護，走專用的 benchmark 或 stress pipeline、不進提交閘門。</li>
</ul>
<p>防護意圖寫成註解，改壞的當下不產生任何訊號——註解不參與執行，作用只發生在有人剛好讀到的時候；當那條會紅的斷言已經存在時，那行註解更是一份沒有保護力的副本。更糟的是宣稱約束的註解會讓 reviewer 以為有人在守——半年後有人在重設函式補上一行清除，那次 diff 不包含註解所在的行，reviewer 讀到的是一段合理的清理程式碼。合併之後沒有任何報錯——使用者按第二種入口得到第一種收尾，要等有人回報才會被發現。</p>
<p>分工有邊界：測試涵蓋不到的 surface（設定檔、build script、schema 定義）註解是唯一載體；給下游用的公開 API，下游 clone 不到專案內部的測試檔，doc 在那裡是契約介面。完整的動機辨識、「先問約束能不能被消除」的判斷閘、與失效邊界見 <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時，要處理的是那個約束</a>。</p>
<h2 id="拿到紅燈時名稱承載意圖reason-承載處置">拿到紅燈時：名稱承載意圖，reason 承載處置</h2>
<p>失敗輸出回答「壞在哪」，測試名稱回答「為什麼這是刻意的」。第一節那條失敗輸出只給了症狀：預期進入下一階段、實際停在編輯階段。拿到紅燈的人下一個問題是「為什麼這個行為是刻意的」——承擔這個「為什麼」的是<strong>測試名稱</strong>。「直接進入下一階段：不做編輯流程的收尾」把意圖寫在名稱裡，紅燈的人讀名稱就知道自己撞到的是一條刻意的規則、不是漏網的 bug。</p>
<p>名稱說不出理由時走的是另一條路（機制推導）：紅燈的人查不出這條規則為誰存在，deadline 前最合理的動作是把斷言改成新行為、讓測試回綠——防護在它發聲的那一刻被解除，而那次 diff 在 review 裡讀起來只是一次正常的測試更新。所以名稱說不出這件事為什麼成立，先改名稱；改完仍說不出來，才考慮在測試旁留註解。</p>
<p>名稱、reason、skip 訊息、檔頭與 setup 註解的逐項紀律——包含 reason 寫失敗的後果與處置、分析詞彙不入程式碼——見<a href="/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律</a>，該篇文末的彙總判斷表把每類文字的落點列齊了。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>測試文字的逐項紀律（名稱 / reason / skip / 檔頭）→ <a href="/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律</a></li>
<li>斷言該斷行為還是實作、能不能區分對錯 → <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">斷言品質三問</a></li>
<li>零測試專案的第一批防護從哪建 → <a href="/blog/testing/01-test-strategy-layers/legacy-test-bootstrap/" data-link-title="無測試 legacy 專案的起步順序" data-link-desc="接手零測試的專案、有限預算下第一批測試該從哪一層開始建——按風險集中處判斷起步路徑，而非照測試金字塔從底部往上疊">無測試 legacy 專案的起步順序</a></li>
<li>防護判準的原則層（動機辨識、消除判斷閘、破壞實測）→ <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時，要處理的是那個約束</a></li>
<li>本篇批次操作案例的完整推導 → <a href="/blog/work-log/comment_cannot_guard_invariant/" data-link-title="註解防不了改壞——防護需求要交給會發聲的機制" data-link-desc="想給欄位或函式寫一行 doc、或審查一則宣稱約束的註解時使用。判斷這個資訊該由測試、型別、命名還是結構承接，以及約束本身能不能先被消除。">註解防不了改壞</a></li>
<li>「測試是行為的權威載體」推到整條文件鏈的分級 → <a href="/blog/report/doc-sync-needs-mechanism-or-demotion/" data-link-title="多份文件必然漂移：同步期待要嘛有機制承接、要嘛明示降級" data-link-desc="設計開發流程的文件鏈（proposal、spec、UC、設計文件、追溯表、測試、註解）、或審查一套流程的文件模型時使用。判準是每份文件的同步期待與守護機制是否匹配：期待最新就要有機制守著、沒有機制就降級為一次性 scaffold 或 append-only 記錄。">#256 多份文件必然漂移</a></li>
</ul>
]]></content:encoded></item></channel></rss>