寫得糟糕的測試不總是以明顯的方式失敗。事實上,更危險的版本恰恰相反:它持續通過、讓 CI 保持綠燈,並給予團隊未經掙得的信心。這在 AI 起草的測試中尤其值得注意。AI 非常擅長重現成功測試的外觀
——準備、執行、斷言、通過——但結構完整的測試不必然是有意義的驗收證據。
我把最常見的三種模式視為假綠燈:執行卻沒有有意義的斷言、斷言實作細節而非行為,以及讓過於寬泛的 mock 取代測試本應驗證的行為。它們就像一支探棒壞掉、卻仍顯示平靜穩定讀數的三用電表。顯示幕看起來可信;問題在於它已經不在測量電路了。
在先前的測試中,Aurora Shop(虛構)一直作為一個簡單的電子商務系統,用來檢視 QA 證據如何能比一個通過的結果更有力。我們測試過折扣優先序、免運邊界、退款資格與 AI 協助的客戶支援等規則,然後以邊界分析與突變測試思維挑戰那些測試:如果行為被悄悄改動,我們的測試會察覺嗎? 這些練習確立了執行程式碼與真正驗證行為之間的重要區別。有了這個基礎,下一個問題是:當一個測試看起來正當、持續通過,但本質上很脆弱時,會發生什麼——也就是三種假綠燈。
同樣的三種形狀,換到店裡的另一個角落。每個範例都先呈現 AI 起草的測試,再呈現真正能保護些什麼的版本。
AI 被要求涵蓋運費的計算。它「涵蓋」的方式是呼叫函式,讓測試執行轉為綠燈。
def test_shipping_fee():
shipping_fee(cart_total=500, region="TW") # 被呼叫過,所以算「測過」
沒有斷言的測試沒有失敗的可能,所以如果 >= 500 悄悄漂移成 > 500,CI 保持綠燈,而剛好 $500 的購物車卻被收取運費。修法是把運費明確表述為一個值——在邊界上,以及邊界下一步之處:
def test_shipping_fee():
assert shipping_fee(cart_total=500, region="TW") == 0
assert shipping_fee(cart_total=499, region="TW") == 60 # (演練資料)
Aurora Shop 會套用會員折扣與優惠券,取省較多者,絕不同時疊加。AI 起草的測試斷言的是內部步驟順序,而不是顧客看得到的結果。
def test_pricing_order():
cart = Cart(total=1500, member=True, coupon="SAVE100")
cart.apply_pricing()
assert cart.steps_taken == ["member", "coupon"] # 食譜,不是那道菜
把兩個運算在內部對調,最終價格不變——但這個測試卻會壞掉。更糟的是,如果一個臭蟲讓兩種折扣疊加,步驟清單看起來依然整齊,測試就在行為改變的情況下保持綠燈。修法是把斷言提升到輸入與輸出的對應關係:
def test_pricing_order():
cart = Cart(total=1500, member=True, coupon="SAVE100")
assert cart.payable() == 1200 # 會員 20%(−300)勝過 −100 優惠券
這就是第三天保持沉默的那個服務。AI 起草的測試把 mock 設定成對每筆訂單都回覆「可退」,所以這個測試永遠抓不到說相反規則的政策。
def test_return_request():
policy = MagicMock()
policy.decide.return_value = "returnable" # 對任何訂單都回「可退」
assert handle_return(order_id="A1", policy=policy) == "approved"
這只證明了 handle_return 會轉發 policy 說的話。唯一值得知道的事——政策是否依訂單類別作答——被 mock 吞掉了。修法是讓 mock 依輸入而定,或者乾脆丟掉它,改跑真正的規則表:
def test_return_request():
policy = RuleBasedPolicy(rules={"apparel": "returnable",
"perishable": "non-returnable"})
assert handle_return(order_id="A1", policy=policy) == "approved" # 服飾
assert handle_return(order_id="A2", policy=policy) == "rejected" # 生鮮
每個修好的測試都以同樣的方式贏得它的綠燈:改變輸入,斷言必須跟著移動;弄壞規則,測試必須轉紅。
| 模式 | 症狀 | 修法 | 如何驗證修復 |
|---|---|---|---|
| 執行,無斷言 | 沒有斷言,或只有「不為 None」 | 斷言金額、優先順序、錯誤訊息 | 故意弄壞實作;測試應轉紅 |
| 斷言實作細節 | 無害重構會弄壞它,行為改變卻不會 | 把斷言上移到輸入與輸出 | 做一次無害的重構;測試應保持綠燈 |
| 過於寬泛的 mock | mock 的輸出與輸入無關 | 縮小 mock 範圍,或改用真實實作 | 改變輸入;斷言應跟著移動 |
| 覆蓋率膨脹 | 覆蓋率上升,但缺失的斷言依舊缺失 | 對照分支清單與風險清單核對 | 與第 08 天的存活突變體比對 |
| 只測快樂路徑 | 沒有零、空購物車或上限的案例 | 從邊界清單補上案例 | 這些案例在舊程式碼上應該失敗 |
假綠燈檢查清單把這些想法轉化為一個實用的方法,用來挑戰一個通過的測試是否真的提供了有用的證據。它把每種脆弱的測試模式對應到其症狀、一種可能的修正,以及——最重要的——驗證該修正的方法。執行卻沒有有意義斷言的測試,應該以故意弄壞預期行為的方式來挑戰;依賴實作的斷言,應該在無害的重構下存活;而 mock 應該在輸入改變時給出有意義的回應,而不是回傳預先決定的答案。這份清單也把同樣的推理延伸到覆蓋率膨脹與只測快樂路徑,用存活的突變體與邊界案例,揭露綠燈的 CI 流水線可能掩蓋的缺口。簡言之,這張表把紅燈也視為證據:一個好的測試,應該在無關的實作細節改變時保持綠燈,卻在它所保護的行為真正壞掉時可靠地轉紅。