問題
在多個 repo 做對齊與驗收的過程裡,「測試綠但結論不成立」反覆出現。綠燈只證明「這段斷言在這個環境下通過」,離「行為在 production 成立」之間,隔著不只一種斷層。下面整理出十七種,分成六族,驗收時逐一對照。
這些形態都來自實際踩到的案例。這篇是跨專案的彙整摘要,個別實例只摘出關鍵事實,沒有在這裡逐一重新驗證。
第一族:環境層
- 跑在舊碼上——dev 容器的 hot reload 沒有套用 DI 註冊與啟動期的改動;容器活著不代表跑的是剛改的碼。改組裝根之後要
docker compose up -d --force-recreate。restart不夠,它連 compose 檔裡的 env、healthcheck 改動都不會重讀(Docker 文件原話:“If you make changes to your compose.yml configuration, these changes are not reflected after running this command.”,docker compose restart)。 - 替身世界太寬鬆——EF Core 的 InMemory provider 不是關聯式資料庫,「文章掛在不存在的分類下」這種現實不會發生的世界照樣全綠。實際把一組 specification 測試遷到真 MySQL 之後,有 6 支紅在外鍵約束上——原本的測試一直在測一個不存在的世界。微軟官方也明說不建議拿 InMemory 當測試用的資料庫替身,它不支援交易、也不支援 raw SQL(Choosing a testing strategy)。行為涉及 constraint、交易、查詢轉譯的,一律用真 DB。
- dev 環境 drift——dev 資料庫的 schema 不是 migration 建出來的,unique index 全缺,拿它驗 constraint 得到「沒有衝突」的假綠。驗 constraint 要用 migration 從零建起來的測試庫。
第二族:斷言層
- 名不符實的測試——測試名說「驗證 X」,內文只 assert
status == 200。引用測試當證據之前,先讀它到底 assert 了什麼。 - 單向斷言——fail-open 的測試只斷言「照常放行」,沒斷言「該擋的仍被擋」;一個「一律放行」的錯誤實作同樣綠。
- 量錯對象——同一個 context 讀回來的是追蹤中的物件,量不到 driver 的行為(例如 DateTimeKind 要開一個全新的 context 讀才看得到);停掉 Redis 去打需要認證的端點,量到的是最前面那個 fail-closed 元件的方向,不是想驗的那一格。
跨測試共用狀態也是這一族的近親:mock 覆寫沒生效、請求被快取擋掉,測試照樣綠,但受測路徑根本沒被觸發,細節見測試之間的狀態外洩。
第三族:守門自身
- 掃描空集合——原始碼掃描、路由反查掃到 0 個檔案時永遠綠。每道掃描門都要配一支哨兵測試:斷言「掃得到檔案,而且掃到的集合包含已知樣本」。覆蓋率設定也有同型的坑,兩個目標都沒命中時產出空報告、靜默通過,見「兩種情況都寫」的容錯設定會兩頭落空。
- 門本身是 no-op——啟動期的守門讀了錯的 DataSource,讀的時候資料還沒填進去,永遠是空的,所以對任何違規都綠。門立起來的當下,就塞一條違規證明它會紅,並且把這個反向驗證留成常態測試。
- 帳面過時——「做完沒打勾」與「打勾了其實沒做」都出現過;連負責對帳的那一輪也會誤判,把檔頭的「補課前清單」讀成現況。收官前的對帳輪是必要的,而且以程式碼實況為準去修正勾選。
第四族:驗證機制自身沒真的跑
這一族的共同形狀是:驗證看起來跑了,量的卻不是那個東西。
- hook 靜默不存在——husky 把
core.hooksPath設成.husky/_,並在該目錄放一個內容為*的.gitignore(husky 原始碼),所以這個目錄是生成物、不進版控。開一個 git worktree 時裡面沒有這個目錄,pre-commit 一聲不吭地全部跳過;commit 成功看起來像「過了門」,實際上門根本不在。在 worktree 裡工作,要把生成目錄 symlink 回去,或事後在主 checkout 補驗一次。 - exit code 量到管線尾巴——
make check | grep ... ; echo $?量到的是grep的 0,不是make的。Bash 的規則是:沒開pipefail時,管線的回傳值是最後一個指令的退出碼(GNU Bash manual: Pipelines)。輸出看起來全綠、exit 也是 0,兩個訊號都是真的,合起來卻不等於「check 過了」。驗收 exit code 一律把輸出重導到檔案再直接取(cmd > log; echo $?),不經過管線。 - 拿舊報告當新結果——測試失敗時不會產生新的覆蓋率報告,守門腳本拿上一次留下的舊檔來比,照樣印「通過」。這在一個 Angular 專案實際發生過。報告類的驗證要帶時效檢查,比對報告的產出時間戳。
第五族:檢查本身恆真
這一族的共同形狀是:檢查跑了、也回答了,但它讀到的不是執行期的事實。
- build 期內嵌的值讓執行期檢查恆真——Next.js 的
NEXT_PUBLIC_*變數在next build當下就被寫死進 bundle,build 之後不再反映環境變數的變化(Next.js: Environment Variables)。寫一個「readiness 檢查process.env.NEXT_PUBLIC_API_URL有沒有值」的端點,執行期把環境變數清空,它仍然回 200、欄位仍是 true。證據要三邊對照才看得出來:容器內printenv是空的、node -e讀到空字串,但grep -rol "<build 期的值>" .next/server在那支檢查自己的 route.js 裡找到硬編的位址。凡是 build 期會內嵌的東西(前端的公開環境變數、編譯進二進位的常數),都不能拿來當執行期檢查的對象。 - 編碼正確但內容已毀——一份
.gitlab-ci.yml能用 UTF-8 解碼、yamllint 全綠、CI lint 回 valid,但其中 21 行中文早就變成?字元,216 個字歸零,包括六行藍綠部署時操作者會看到的提示。「能解碼」與「字還在」是兩件事,曾經處理編碼問題時就只驗了前者(編碼把工具誤導到別處的另一個例子,見工具報的錯不一定是它說的那個問題)。檢查法是數 CJK 字元數(sum(1 for c in t if '\u4e00' <= c <= '\u9fff')),再配合grep -c '??',而不是只看 decode 會不會拋例外。 - 卡住不算失敗——migration 繞進無限迴圈,容器狀態是 running + unhealthy + 重啟次數 0,log 停在「資料庫連上了」之後就沒有下文,沒有任何錯誤訊息,卡了 17 分鐘沒人發現。失敗會重啟、會引人注意,卡住是靜默的。凡是「可能永遠不回來」的子程序都要有逾時,把卡住主動轉成失敗。判別卡住還是慢:對
pg_stat_activity取樣三次,如果每次都是同一個 pid、同一句查詢,但now() - query_start都是負的毫秒數,代表每次抓到的都是剛開始的新查詢,也就是迴圈。
第六族:故障放在檢查看不到的地方
最後兩種是後來才補上的,共同點是反向測試放錯了位置:故障確實發生了,但剛好落在檢查的盲區。
- 殘留狀態讓結果比對通過——還原演練加上了「還原後比對來源與還原端筆數」,反向測試仍然被騙:Elasticsearch 還原回 500、MySQL 塞壞備份還原失敗,但還原端留著上一輪的資料,筆數剛好等於來源。第一次跑的時候還原端是空的,失敗就是 0 筆、比對會紅,所以這個假綠只在第二次以後才會出現。比對之外,動作本身的回傳碼也要算進失敗;反向測試要在「已經跑過一次」的狀態下做。
- 守門報通過,是因為根本沒掃到——釘版守門只掃 yaml 與 Dockerfile,Makefile 裡的
OSV_IMG := ...:latest照樣「通過」;先前宣稱的「零誤判」,其實是沒驗過漏判。修正之後在臨時 repo 放 4 個該抓、6 個不該抓的樣本,命中恰好是 4 個。同一類問題也出現在 shellcheck 的 pre-commit:它只看^scripts/,壓測腳本全放在k6/scripts/,一支都不在範圍內,帳上卻寫著「新債不會再長」。檢查範圍要跟「東西實際寫在哪」對照,不是跟「通常寫在哪」對照;反向測試要把故障放在範圍邊界外面至少一次。
通則
宣告「已驗證」前問三件事:跑的是哪份碼、斷言的是哪個層、門紅過沒有。三題任一答不出來,綠燈只是綠燈。
後來補上第四件:這個檢查有沒有紅過。第 13 種的假檢查在正向測試下完全正常(回 200、欄位是 true),只有故意把值拿掉才看得出它是假的。新寫的檢查,第一件事是讓它紅一次。