測試的價值發生在它變紅的那一刻——建立、變更與註解分工的防護視角
四個時刻會用到這一篇:為一段還沒有測試的邏輯決定第一條測試該測什麼;改功能、加功能或重構之前,評估現有的測試會替這次變更擋什麼;review 裡爭論某個約束該寫成註解還是測試;以及拿到一顆紅燈,讀不出它是刻意規則還是漏網 bug 的時候。這些問題表面不同,共用同一個視角——測試的價值在時間軸上怎麼分布。
建立測試時:價值在未來的紅燈,不在當下的綠燈
寫測試的當下,程式是對的——不對的話會先修程式再讓測試過;寫的過程若抓到現有 bug,那份價值發生在先出現的那顆紅燈。所以測試寫完的那顆綠燈,確認的是「此刻的行為與此刻的預期一致」,而這件事作者當場已經驗證過。這條測試之後的防護價值全部發生在未來:某一次變更讓行為偏離預期、測試變紅、變更的人在合併之前就知道。
本篇談的是測試作為長存產物的防護價值。寫測試這個動作在當下逼出的介面設計回饋(測試先行時,介面由使用端先定義)、以及被當成規格文件閱讀的價值,是另外兩條軸、不在本篇的時間軸上——而 TDD 的紅綠循環,正是把「先出現的紅燈」制度化提前。綠燈給的部署信心也來自同一處:信心的大小等於它會紅的能力,一條恆真的斷言給不出任何信心。
這個時間差改變了建立測試時該問的問題:不是「現在的行為對不對」,是「未來哪一種改壞,要被這條測試擋下」。
兩個問題會選出不同的測試。一個批次操作有兩個入口按鈕,差別在合併完成後的收尾——一種回到編輯流程、一種直接進入下一階段;選了哪一種,程式記在一個用途值裡,而執行順序是先重設選取狀態、之後才讀取用途值來決定收尾。問「現在對不對」,寫出來的是主流程斷言:合併後清單變成一筆。問「哪種改壞要被擋」,看到的是另一件事:任何人把用途值加進重設的清除清單,讀取時拿到的就是預設值——第一種用途,於是第二種用途靜默走成第一種:不報錯、不彈窗,使用者按第二種入口、得到第一種收尾。這個問題指向的是這樣一條測試:「直接進入下一階段:不做編輯流程的收尾」。兩條測試在今天都綠,防護範圍完全不同(案例完整推導見註解防不了改壞)。
防護存不存在可以當場觀察、不必推測:在違反約束的位置故意加一行改動,跑測試,看 runner 印出的失敗輸出(+2 -1 是通過 / 失敗計數、[E] 標記錯誤)——
1+2 -1: 直接進入下一階段:不做編輯流程的收尾 [E]
2 Expected: Step:<Step.finished>
3 Actual: Step:<Step.editing>紅了,防護存在;還原改動。建立一條防護型測試之後做一次這個動作,比讀十遍測試碼可靠——「有測試」與「那條測試會對這個違反發聲」是兩件事。
接手零測試的專案時,「未來哪種改壞要被擋」的第一批答案在風險集中處,起步順序見無測試 legacy 專案的起步順序;變更前先把現狀鎖住的測試形態是 characterization test——它的斷言對象是「現在的行為」而不是「正確的行為」,是防護視角的退位形式:依賴纏結或規格失傳、無法先寫正確性測試時,用它先把現狀鎖住。
變更時:型別裝不下的約束,測試是唯一在改壞當下發聲的機制
改功能、加功能、重構,三種變更都會經過別人寫下的程式。變更的人手上有哪些防護,差別在訊號出現的時機、以及能不能被忽略:
| 手段 | 訊號出現的時機 | 對變更的人的作用 |
|---|---|---|
| 型別 | 編譯當下,無法忽略 | 寫不出違反約束的程式碼 |
| 測試 | 跑測試時(pre-commit / CI) | 改壞的當下變紅,合併前就知道 |
| runtime assertion / 寫入時 schema 驗證 | 該路徑被執行時 | 開發期這條路徑通常靠測試才被跑到;沒有測試就要等上線 |
| lint / architecture test | CI 當下 | 守程式文本形狀的規則(依賴方向、禁用模式);行為約束塞進去會變成綁死實作的語法禁令 |
| 命名 | 讀到呼叫點時 | 動手前多看一眼,但不會自動發聲 |
| 註解 | 剛好讀到宣告處時 | 批次整理與重構的人常常根本不經過那一行 |
型別的訊號最早,但裝得下的約束窄——「不該是負數」交給非負型別,違反它的程式碼根本寫不出來;「必須活過某次重設」這種跨函式的讀寫順序,違反的程式碼寫得出來、而且編譯得過。跨時間必須恆真的條件(不變式)落在型別外面時,測試是唯一以行為為斷言對象、在改壞當下發聲的位置。前提是它會自動跑——掛在 pre-commit(commit 前自動執行的 git hook)或 CI 上,沒有人手動觸發它也發得了聲。
命名把約束帶到每個呼叫點——第一節的用途值取 purposeSurvivingReset 這種名字,動手的人在改之前會多看一眼。但 IDE 的批次 rename 與自動重構不讀語意,名字擋不下工具驅動的變更;它是有價值的輔助,不是防護的終點。
重構時測試有雙重角色,分界在斷言的對象。斷言行為的測試是重構的安全網——行為不變就綠,結構怎麼改都放心;斷言實作的測試是重構的阻力——內部調整就紅,而那顆紅燈不代表壞掉。
阻力長這樣(機制推導):當初寫測試的人 mock 了內部協作者、斷言呼叫次數,那樣最容易讓覆蓋率達標——這正是機械約束被最便宜的路徑滿足的形態。沒人動結構的期間,這批測試跟斷行為的測試一樣綠;第一次重構,它們整批變紅,而行為一項也沒壞。紅燈是不是真的壞掉只能逐條打開人工判定,而那時最省事的動作是改測試遷就新結構、或放棄重構。邊界在斷言的對象:呼叫本身是對外契約時(如 outbox 的 publish 保證),次數與順序就是行為,斷它是安全網、不是阻力。
這條分界就是斷言品質三問的第一問(斷言的是行為嗎),防護視角補上它的理由:重構需要的是「行為被改壞才發聲」的訊號,對實作發聲的測試把訊號稀釋成噪音。
加功能時,既有測試定義舊行為的邊界。新功能動到共享可變狀態、舊測試紅,代表新功能改壞了舊契約——這顆紅燈正是建立時那個「未來哪種改壞」的兌現,它在合併之前就浮現,不必等使用者回報。
review 爭論時:測試與註解的分工
review 裡「這個約束該寫註解還是測試」的爭論,判準是這段資訊有沒有對應的斷言——存不存在一條會紅的斷言,不是造不造得出句子:
- 寫得出會紅的斷言(讀寫順序、時序耦合、狀態必須活過某次操作)→ 資訊的家是測試名稱。
- 斷言守不住的那一半——來源在 repo 之外的出處與當初的取捨(法規條號、稽核要求、後端契約為什麼長這樣)→ 家是 doc comment。
- 兩者可以同時成立:
test('留存不得少於七年')既有斷言、來源又在 repo 外——測試守行為、註解留出處,各寫各的那一半。 - 還有一類斷言寫得出來、但不穩定或成本過高:並行安全、性能量級這種依賴非確定因素的約束,硬掛上 pre-commit 就違反本模組自己的 flaky 紀律(見Flaky test 根因分類)。這類資訊的家仍是 doc comment,語意是使用契約而非防護;要升級成防護,走專用的 benchmark 或 stress pipeline、不進提交閘門。
防護意圖寫成註解,改壞的當下不產生任何訊號——註解不參與執行,作用只發生在有人剛好讀到的時候;當那條會紅的斷言已經存在時,那行註解更是一份沒有保護力的副本。更糟的是宣稱約束的註解會讓 reviewer 以為有人在守——半年後有人在重設函式補上一行清除,那次 diff 不包含註解所在的行,reviewer 讀到的是一段合理的清理程式碼。合併之後沒有任何報錯——使用者按第二種入口得到第一種收尾,要等有人回報才會被發現。
分工有邊界:測試涵蓋不到的 surface(設定檔、build script、schema 定義)註解是唯一載體;給下游用的公開 API,下游 clone 不到專案內部的測試檔,doc 在那裡是契約介面。完整的動機辨識、「先問約束能不能被消除」的判斷閘、與失效邊界見 #253 寫註解的動機是怕被改壞時,要處理的是那個約束。
拿到紅燈時:名稱承載意圖,reason 承載處置
失敗輸出回答「壞在哪」,測試名稱回答「為什麼這是刻意的」。第一節那條失敗輸出只給了症狀:預期進入下一階段、實際停在編輯階段。拿到紅燈的人下一個問題是「為什麼這個行為是刻意的」——承擔這個「為什麼」的是測試名稱。「直接進入下一階段:不做編輯流程的收尾」把意圖寫在名稱裡,紅燈的人讀名稱就知道自己撞到的是一條刻意的規則、不是漏網的 bug。
名稱說不出理由時走的是另一條路(機制推導):紅燈的人查不出這條規則為誰存在,deadline 前最合理的動作是把斷言改成新行為、讓測試回綠——防護在它發聲的那一刻被解除,而那次 diff 在 review 裡讀起來只是一次正常的測試更新。所以名稱說不出這件事為什麼成立,先改名稱;改完仍說不出來,才考慮在測試旁留註解。
名稱、reason、skip 訊息、檔頭與 setup 註解的逐項紀律——包含 reason 寫失敗的後果與處置、分析詞彙不入程式碼——見測試註解與命名紀律,該篇文末的彙總判斷表把每類文字的落點列齊了。
下一步路由
- 測試文字的逐項紀律(名稱 / reason / skip / 檔頭)→ 測試註解與命名紀律
- 斷言該斷行為還是實作、能不能區分對錯 → 斷言品質三問
- 零測試專案的第一批防護從哪建 → 無測試 legacy 專案的起步順序
- 防護判準的原則層(動機辨識、消除判斷閘、破壞實測)→ #253 寫註解的動機是怕被改壞時,要處理的是那個約束
- 本篇批次操作案例的完整推導 → 註解防不了改壞
- 「測試是行為的權威載體」推到整條文件鏈的分級 → #256 多份文件必然漂移