問題

我已經把「守門測試必須反向驗證」當成硬規則:改壞實作,確認它真的會紅。規則本身是對的,之前處理過的是「宣告了沒接線」「設定寫了沒生效」(後者見守門有沒有在擋,只能證明、不能推定)。

但在一個 Express 專案一輪又一輪的對齊收斂裡,反向驗證這個動作本身失效了七次,形態各不相同。共通點是:植入做了、測試跑了、也看到紅或綠了——結論仍然是錯的。

這篇講的是再上一層的東西:怎麼知道你的「證明」本身有效。

七種形態

1. 植入點在生產路徑上跑不到

敏感欄位遮蔽的反向驗證,第一次把 body dump 植在 apiErrorLog——測試照樣全綠。那一層只在錯誤路徑跑,而且 body-parser 失敗時 req.body 還沒生成。

改植在 accessLog(每個請求都會經過)之後:拿掉遮蔽 3 支紅、裝回去 3 支綠,才證明擋住的是遮蔽本身。

判準:植入點必須在生產路徑上真的會被執行到。植入之前先問「這一行在正常請求裡跑不跑得到」。

2. 植入到測試自己寫的替身上

請求逾時的既有測試打的 /slow 是測試檔內自建的 handler,而它自己守了 headersSent。驗到的是 stub 的守門,不是生產的。

生產的 /health//ready 兩支從來沒被這樣打過。後來把逾時設成極短(1ms)真的打一次生產端點,才抓到一個藏著的問題:整個 Node 行程直接結束。逾時 middleware 送出 504 之後,handler 又用沒守 headersSent 的 res.json 再寫一次,catch 接住後又寫又拋,第二次沒人接。這是反向驗證改植到生產碼之後才看得到的那一類問題。

判準:反向驗證要植入生產程式碼,不是測試的替身。

3. 守門自己的前處理把植入物抹掉

有一道靜態守門要掃「有沒有模組自己讀 X-Forwarded-For」。第一版的前處理連字串常量一起剝掉,於是植入的 req.headers['x-forwarded-for'] 被守門自己的前處理抹掉了,測試照樣全綠。

改成只剝註解之後才驗到。

判準:靜態掃描型的守門,要確認植入物不會被自己的正規化步驟吃掉。

4. 植入對了,但跑錯測試檔

錯誤信封格式收斂那一次,我把頂層 message 從生產的信封建構器拿掉,跑 e2e——全綠。當下差點下結論說那道門是空的。

實際上守門在 tests/contract/error-envelope-parity.test.js,跑在單元/整合那一輪,不在 e2e。換對之後:拿掉頂層 message → 2 支紅;讓兩條路徑的 message 各自解析(原本的缺陷形態)→ 精準紅在「頂層 message 恆等於 error.message」那一支。

判準:植入之後要跑那道門所在的測試檔,不是隨便跑一輪。

5. 空集合讓壞掉的形狀與正確的形狀重合

這一種最狡猾,同一天出現兩個實例。

形狀維度:formatResponse() 寫 data: { config, ...response_data },傳陣列進去會被展開成 {"0":…,"1":…},client 無法 .map()。而空陣列展開成 {},恰好等於回應格式約定的「空的 data 輸出 {}」——壞掉的形狀與正確的形狀在空集合上完全一致。整個功能上線期間沒人發現,是因為那張表一直是 0 筆。

否定式斷言維度:菜單權限從舊的來源改讀 RBAC 時,有一則「看不到別人那條權限的選單」的反向驗證沒有紅——因為舊來源讀出來是 [] 時什麼都看不到,not.toContain 自然成立。

判準:守門的主證據要用非空資料。空集合那一則要驗型別(Array.isArray)而不是值相等(toEqual({}) 兩種實作都會通過);否定式斷言旁邊要有正向斷言擋著。

6. 紅了,但紅的原因不是你以為的那個

交易預算的守門,拿掉交易接線後測試確實紅了——但紅的原因是 AuditTrail 連不到 MySQL,不是預算耗盡。

改成先斷言錯誤來源(name: 'SequelizeConnectionError' + 訊息逐字比對)再看資料表,才是真的在驗那件事。

判準:看到紅不等於驗證成立。要確認紅的理由就是你植入的那件事。

7. 植入的「錯寫法」還不是要驗的那個錯

前六種都是「植入對了,驗證機制出問題」。這一種反過來:驗證機制是好的,植入物本身不夠壞。

一個 Kotlin 客戶端的冪等鍵(Idempotency-Key)要驗一件事:重試時換一把新鍵,該被測試抓到。換新鍵會讓後端的防重放完全失效——後端看到兩個不相干的請求,於是建兩筆資料。

反向驗證第一版把攔截器改成這樣:

// 植入的「錯寫法」
val key = request.tag(WriteIntent::class.java)?.key ?: UUID.randomUUID().toString()

那支驗收測試沒紅。 因為這個版本有標籤時的行為完全正確——它只在沒標籤時才生新鍵,而驗收測試送的兩個請求都有標籤。植入物保留了要驗的那條正確路徑。

真正的錯寫法是完全不看標籤:

val key = UUID.randomUUID().toString()   // 這個版本才讓驗收那支紅

這一種的危險在於它指向錯誤的結論。 失效的症狀是「沒紅」,而「沒紅」的自然解讀是「測試沒在守這件事」——於是人會去改測試。但測試是對的,錯的是植入物。改測試會把一道有效的門改壞。

跟形態 5 同源但維度不同:形態 5 是資料維度重合(空集合讓好幾種實作看起來都對),這一種是程式碼路徑維度重合(植入版本仍走正確分支)。

判準:植入之後問「這個版本在測試實際走的那條路徑上,行為變了嗎」。只改了測試走不到的分支,等於沒植入。

兩個操作面的坑

還原要放在最前面,不是只放最後面。 反向驗證的指令被逾時砍掉時,收尾的還原不會跑,植入的破壞留在工作區。我是回頭查 git status 才發現的——沒查的話那個植入就會混進 commit。

正確寫法是「先無條件還原 → 再植入 → 跑測試 → 再還原」。

指令沒有輸出,有兩種完全不同的原因。 Docker 整個卡住的時候,docker ps 可能回空而不是報錯。我因此誤判過一次:--detectOpenHandles 毫無輸出,以為是自己的程式碼有問題,花時間 debug 一個不存在的 bug;實際上是 Docker 卡住了。

逾時的時候先確認環境有沒有回應,再判斷是不是自己的程式碼壞了。

怎麼預防再次發生

反向驗證做完之後,對自己問五句:

  1. 植入點在生產路徑上跑得到嗎? 不是測試的替身、不是只有錯誤路徑才走的那一層。
  2. 我跑的是那道門所在的測試檔嗎?
  3. 紅的理由是不是我植入的那件事? 不是別的錯誤剛好也讓它紅。
  4. 主證據是非空資料嗎? 空集合會讓好幾種錯誤實作看起來都對。
  5. 植入物在測試實際走的那條路徑上,行為真的變了嗎?(形態 7) 沒紅的時候先查這一句,再去懷疑測試——「沒紅」最容易被誤讀成「測試沒在守」,而那個誤讀會讓人去改一道其實有效的門。

還有一句給守門測試本身:這道門有沒有一則測試在證明「它真的在看東西」。同一個專案裡有三道門可以當範例——

  • 信封同形守門的第一支:「先確認兩條路徑真的各自走到了」(否則後面幾支比的是同一條)
  • i18n 缺鍵守門:「掃得到呼叫點,也掃得到語系檔」「掃到的檔案數與 app/ 的實際 .js 數一致」(掃到 0 個當然沒缺鍵)
  • 殘留守門:「seeder 真的跑過——基準的核心表都不是 0」(全新資料庫上「筆數沒變」恆真)

相關

  • 守門有沒有在擋,只能證明、不能推定——設定寫了沒生效、以及怎麼證明守門在擋。這篇補的是「證明的方法本身如何失效」。
  • 驗收假綠形態目錄——測試綠不等於事實成立的十七種方式;其中「掃描空集合」「門本身是 no-op」跟形態 3、5 是同一族。
  • 工具報的錯不一定是它說的那個問題——「Docker 卡住、指令沒輸出」那一段的近親:訊號把人指錯方向。
  • 比錯基準:檢查跑了、有輸出、但比錯基準。分工是:那個主題講檢查本身量錯東西,這篇講「證明檢查有效」這個動作失效。形態 7 是兩者的交集——反向驗證的植入物自己比錯了基準(比的是「看起來錯」,不是「真的會造成那個後果」)。
  • 同期另一個 Django 專案的觀察:一批 service 改成繼承基類之後,issubclass 測試全綠,但其中有自訂 __init__ 卻沒呼叫 super().__init__() 的。「宣告成立」不等於「初始化跑過」。JS 的變體不同:衍生類別的建構子不呼叫 super() 就用 this 會直接 ReferenceError(MDN: constructor),藏不住;藏得住的是「呼叫了但沒把參數轉發進去」,DI 契約因此從來沒生效。