<?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>Regression on Tarragon</title><link>https://tarrragon.github.io/blog/tags/regression/</link><description>Recent content in Regression 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/regression/index.xml" rel="self" type="application/rss+xml"/><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>