故障注入與測試錯誤路徑(fault injection and testing the error paths)
想像一位消防安全檢查員,他不只是欣賞灑水系統——他真的點起一小團受控的火,來檢查灑水器會不會啟動。一套你從沒見過它對緊急狀況做出反應的緊急系統,是不能信任的。故障注入就是軟體版的同一個原則:在受控的條件下,刻意讓事情失敗,這樣你就能親眼看著你的錯誤處理程式碼真正運行,並確認它做了對的事。
需要這麼做的理由令人不舒服卻是真的:錯誤路徑幾乎是每個程式中測試得最少的程式碼。順利路徑每次你用程式時都會跑,所以那裡的臭蟲很快就會浮現。但「malloc() 回傳 NULL 時、write() 因磁碟滿了而失敗時、網路讀取逾時時」會跑的那段程式碼——在正常開發中可能從不曾執行,所以它可以錯上好幾年都沒人發現。故障注入逼這些路徑去執行。技巧從簡單到精巧都有:在 malloc() 外包一層、讓它每第 100 次呼叫就回傳 NULL(用來測記憶體不足的處理);一個提早關閉檔案描述符、讓接下來的 read() 失敗的測試;攔截系統呼叫並按需讓它們失敗的工具;以及極端的混沌工程,在活的分散式系統裡刻意殺掉伺服器,以證明它能存活。目標永遠相同:把程式逼進那些它否則幾乎永遠不會走的錯誤分支,並檢查它有清理、有誠實回報、且沒有搞壞狀態。
為何重要:一條從沒執行過的錯誤路徑,實際上就是未測試的程式碼,而未測試的錯誤處理,恰恰會在你最需要它時失效——在一次真正的當機期間。故障注入正是「我寫了清理碼」如何變成「我看著清理碼在失敗下正確地運行」。誠實的提醒:你無法注入每一種可能的故障,所以它是補充而非取代細心的設計與程式碼審查;注入的故障必須可控且可逆,免得你的測試框架留下真實的損害;而測試必須檢查結果,不只是程式有沒有存活——一個靠默默丟棄資料來「存活」過 write() 失敗的程式並沒有通過,即使它沒崩潰。
一個僅供測試的 malloc 包裝:void *test_malloc(size_t n) { if (--countdown == 0) return NULL; return malloc(n); }——把 countdown 設成讓第三次配置失敗,然後執行那個函式,確認它清理了前兩次配置、並回傳錯誤,而不是崩潰或洩漏。
強迫第三次 malloc 失敗,讓那條罕見執行的清理路徑真正執行並被檢查。
存活過一次注入的故障,和通過測試不是同一回事:一個靠默默丟資料來「繼續運行」的程式仍然失敗了。測試必須驗證正確的結果(清理發生了、回傳了誠實的錯誤),而不只是它沒崩潰。