Blog
靜默失敗
一道空轉了幾個月的防線
yoju 是一條每天自動跑的內容 pipeline:從 PubMed 抓論文、查證、寫成衛教文章。
裡面有一道防線:論文如果被撤稿了,就不能拿來寫文章。 撤稿的原因通常是資料造假 或結論被推翻,拿這種論文寫健康知識會很難看。
所以流程裡有一步:拿論文的 DOI(論文的身分證號碼)去 CrossRef 查有沒有撤稿紀錄。
某天有人問了一句:「這道防線真的有在運作嗎?」
去查了一下實際的執行紀錄。它從上線到那天為止,一次都沒有攔下過東西。
一開始的解釋很自然:撤稿論文本來就罕見,沒攔到很正常。
實際打了一次真實 API 才發現:那道檢查根本沒有在跑。 每一篇論文的 DOI 都是空的, 而 DOI 是空的時候,撤稿檢查就直接跳過。
沒有錯誤訊息、沒有例外、沒有紅燈。測試從頭到尾都是綠的。
錯在一行
程式碼是這樣拿 DOI 的:
doi = article.get('doi', '')
意思是「從這篇論文的資料裡取出 doi 這個欄位,沒有的話給我空字串」。
而 PubMed 真實回傳的資料沒有 doi 這個欄位。DOI 藏在另一個叫 articleids
的清單裡:
'articleids': [
{'idtype': 'pubmed', 'value': '12345678'},
{'idtype': 'doi', 'value': '10.1111/jgs.12345'}, # ← 在這裡
]
所以 article.get('doi', '') 永遠拿到空字串。永遠。
測試為什麼沒抓到
因為測試用的假資料是這樣寫的:
'12345678': {
'title': '...',
'fulljournalname': '...',
'doi': '10.1111/jgs.12345', # ← 真實 API 不會回這個
}
寫這份假資料的時候,我以為 PubMed 會回一個 top-level 的 doi 欄位。
這個猜測很合理——DOI 是論文最重要的識別碼之一,放在最外層很自然。
然後我照著這個猜測寫了程式碼。
假資料錯了,程式碼配合假資料寫,兩個錯在同一個方向,於是它們完全對得起來。
測試通過了。它驗證的是「程式碼符合我的假設」,而不是「程式碼能處理真實的 API」。
換個場景說一次
你考完試想對答案,但手邊沒有標準答案,所以憑印象自己寫了一份。
然後拿自己寫的那份去對。
全對。
那個「全對」是真的——你的答案跟你的答案當然一致。它只是完全沒有告訴你, 你跟老師的答案一不一樣。
測試的綠燈就是這種全對。
為什麼這比沒有測試更糟
沒有測試的時候,你會不放心,所以會手動去看一眼。
有了綠燈,你就不看了。
一道錯的測試會同時做兩件事:它沒有提供保護,而且它移除了你原本會有的警覺。
這道撤稿檢查躺了幾個月沒人發現,需要三個條件同時成立,而它們全都成立了:
- 測試一直綠(假資料餵的是想像中的形狀)
- 沒有錯誤(拿不到 DOI 不會當掉,只是安靜地跳過檢查)
- 撤稿論文罕見(就算真的漏掉,短期內也不會出事)
第 3 點最陰險。它讓「防線沒攔到東西」和「防線壞掉」在數據上長得一樣。
四個可以現在就問的問題
任何「串外部 API + 有防護邏輯」的程式碼,定期問這四句:
- 這道防護從上線到現在,實際攔下過東西嗎? 去看紀錄,數次數。零次要警覺。
- 我的假資料是錄下來的,還是我寫的? 憑印象寫的假資料,就是把假設固化成檔案。
- 有沒有任何一個測試打真實的 API? 一個就好。
- 拿不到預期的欄位時,是安靜地給預設值,還是會叫?
.get('doi', '')屬於前者,而前者會把「一直拿不到」變成看不見。
任何一題答得不安心,就實際打一次真實 API,跟你的假資料對一次。
修法:一個就夠
不需要把所有測試都改成打真實 API——那會很慢、會被 API 限流、會在對方掛掉時紅燈。
實際有效的組合只有兩件事:
一、假資料從真實回應錄下來。 第一次呼叫真實 API 時把回應存檔,之後測試都用那份。
Python 有 vcrpy 這類工具幫你做。這樣日常測試跑得快,形狀又是對的。
二、留一個會打真實 API 的測試。 標記成不必每次跑,但定期跑。它的工作只有一個: 在對方改了回應格式的那天告訴你。
第一件事擋掉「一開始就寫錯」,第二件事擋掉「當初對、後來對方改了」。
這兩件事都不是新東西。軟體工程界對它有一個名字,叫 contract testing(合約測試), 討論了二十年,工具也很成熟。
我踩這個坑,不是因為沒有人解決過。是因為我沒有把「這個模組串了外部 API」 跟「所以它需要合約測試」這兩件事連起來。
知道一個做法存在,跟在對的時刻想起它,是兩回事。
這一課該放在哪一層
這件事很難靠記憶避開,因為犯錯的當下感覺完全正常——寫假資料的時候, 你正在做一件負責任的事。
所以它該往下搬。這一條適合放在常駐規則裡:
## 外部 API 的測試假資料
- 假資料優先從真實回應錄製,避免憑印象手寫
- 每一個「串外部 API + 有防護邏輯」的模組,至少留一個打真實 endpoint 的測試
- 拿不到預期欄位時要發出可見的警告,避免用預設值靜默吞掉
- Code review 時對任何新的 mock fixture 問一句:這個形狀是從哪來的?
最後一條是成本最低、效果最好的一條。「這個形狀是從哪來的」這個問題, 在寫的當下問只要三秒鐘,在幾個月後問要花一整個下午。
三篇的共同點
這三篇文章講的都是同一種東西:沒有發出聲音的失敗。
第一次是工具在騙你——它精確地做了你要求的事, 而那件事剛好會藏住答案。
第二次是筆記救不了你——它躺在硬碟裡, 等你先想到要去查它。
這一次連騙你的人都沒有。你寫下自己的假設,拿自己的假設去驗證自己的程式碼, 兩邊當然對得起來。
三次的共同點只有一個:壞掉的時候,它跟正常的樣子一模一樣。
沒有錯誤訊息、沒有紅燈、沒有例外。它安安靜靜地錯下去,而你完全沒有理由停下來檢查。
這種 bug 有一個名字:靜默失敗(silent failure)。
它比會當掉的 bug 危險得多。當掉的 bug 會出聲,出聲就會被修。靜默失敗不出聲, 而且它還會多給你一份不該有的信心——一個綠燈、一張看起來完整的截圖、一篇寫好的筆記。
所以綠燈從來不是保證。它只說明兩件東西彼此一致,而那兩件東西可能都是你寫的。
在你套用之前
有兩件事要先說清楚,不然這三篇會變成一種讀完很爽、但用不上的東西。
一、這個名字很好用,好用到危險
「壞掉的時候跟正常一模一樣」這個描述,幾乎可以套在所有不會當掉的 bug 上。
一個能解釋一切的框架,實際的預測力是零。你會讀完覺得處處都是靜默失敗, 然後不知道該從哪裡開始查。
所以它需要一個邊界。這三篇的例子有一個比「沒出聲」窄得多的共同特徵:
有一個東西宣稱某件事成立,而你從來沒有檢查過那個宣稱背後的證據。
- 綠燈宣稱測試通過,證據是斷言——而斷言比對的是你自己寫的假資料
- 五筆清單宣稱那是最新的五筆,證據是清單——而清單被過濾過
- 一篇筆記宣稱這件事處理過了,證據是那篇筆記——而它不會自己出現
如果一個情境裡找不出「宣稱」和「證據」這兩樣東西,那大概不是這個問題,別硬套。
二、這三篇提出的規則,目前還沒有戰績
這篇文章前面問過一句:「這道防護從上線到現在,實際攔下過東西嗎?」
那我得對自己套用同一句。
這三篇寫出來的東西——那幾條常駐規則、那支提醒用的 hook、錄製 fixture 的做法—— 上線只有幾個小時,而唯一的觸發紀錄是我自己測出來的。
按照這篇文章自己的標準,它們現在的狀態是還沒有被驗證過的防護, 跟那道空轉了幾個月的撤稿檢查,在證據等級上沒有差別。
要等到它們真的擋下一次我沒預料到的錯誤,才算數。
在那之前,這三篇是假設,不是結論。