(改寫自公司內部 memo)
Hi all engineers,
上完一整天單元測試課程之後,有同事問我:「有沒有標準來決定要不要寫單元測試?」
剛讀完劉潤《底層邏輯》的我,對於這類疑似「注射器」的問句格外敏感。雖然我可以瀟灑引述 Working Effectively with Legacy Code 的說法「我毫無疑問地將『遺留程式碼』定義為『沒有編寫測試的程式碼』」:
沒有編寫測試的程式碼是糟糕的程式碼——不管我們多麼細心地編寫它們,不管它們有多漂亮、物件導向或封裝良好。有了測試,我們就能夠迅速、可驗證地修改程式碼的行為。沒有測試,我們就不知道修改後的程式碼,實際上是變得更好還是更糟。
但當下我還是決定先展開一場對話。
我:「我一時想不出來有沒有合適的標準,但我有一個簡單的 gut feeling 來判斷:如果我沒有把握證明這段程式碼會表現出該有的行為,那麼我就應該考慮替它加一段測試,用測試來表現出這段程式碼的確忠實表現出該有的行為,讓測試來說話。」
同事:「即使是很簡單的程式碼,也需要加單元測試嗎?這會不會太麻煩了?」
我心中頓時浮現一個聲音:“如果你有 TDD (test-driven design) 的觀念或紀律,就不會有此一問了。再簡單的程式碼,都會測試先行——何況,簡單的程式碼,對應的單元測試會很難嗎?如果很難很繁雜,那麼,或許這程式碼本身並沒有當初想像中那麼簡單。”
但當下我還是決定繼續對話,先不要說教⋯⋯
我:「能不能舉一個『簡單的程式碼』的例子?」
同事:「就像某一個 function,它會先檢查輸入的日期格式,如果格式不是 yyyy/mm/dd
就會失敗;如果格式對了,就會繼續往下做正事⋯⋯」
我:「且慢,你所說的『如果格式不對,就會失敗』這句話,所謂的『失敗』是指什麼?」
同事:「啊⋯⋯就是失敗呀!」
我:「讓我們用程式語言的角度來講。你所謂的『失敗』,是指 return false 呢,還是 throw exception?」
同事:「嗯,以這個例子來說,是 throw exception。」
我:「好的,那麼,你怎麼證明,在遇到錯誤的日期格式時,你的程式碼真的會 throw exception?」
同事靜默中⋯⋯
我:「在談論寫不寫單元測試時,我想稍微區分一下問題,究竟是 ❶ 不會寫,或者是 ❷ 懶得寫?」
我:「就以這一個例子來說,你知道該怎麼單元測試嗎?」
同事靜默中⋯⋯
我:「目前的主流單元測試框架,都有 Assert.ThrowsException 或 Assert.ThrowsExceptionAsync 之類的設施去處理 exception,所以『❶ 不會寫』就不成立了。只剩下一個問題:『❷ 懶得寫』。」
我:「你會在這段 function 一開頭的 doc comment 陳述說:『如果日期格式錯誤,會 throw XXX exception』嗎?」
同事:「會。」
(他大概不敢當面說不會,因為我兩個月前才寫過一篇內部 memo 講 “doc comment” 這件事。)
我:「針對 throw exception 這個行為,既然你都已經花時間寫程式了,也都已經花時間寫 doc comment 了,為什麼不順手寫一小段單元測試來證明你的程式碼與 doc comment 是對齊的,讓測試來說話?」
同事靜默中⋯⋯
我:「而且,你寫的程式,不只是給現在的你自己看,不只是給 code reviewers 看,也會給幾個月後、甚至一兩年後接手維護的其他人看。萬一有一天,別人沒注意到『日期格式』這件事就改寫這段程式碼,你的程式又沒有單元測試的保護,你會不會擔心對方把你的程式改壞了?」
同事靜默中⋯⋯
我:「所以在單元測試課堂的開場致詞中,我強調:『單元測試是程式設計師的職業道德』,就是這個意思。」
最後,請容我引述《修改軟件的藝術 (Beyond Legacy Code)》裡面的金句:
在編寫程式碼之後才編寫測試,往往會發現程式碼難以測試,需要大量的清理來讓其可以測試,這可能會成為一個大工程。最好是一開始就編寫出可測試的程式碼,而編寫可測試程式碼的最好方式是先編寫測試。
沒法進行測試,則說明我有些設計問題需要重新進行思考。
應該假設測試集是對整個用戶故事的完整定義。如果沒有用測試來描述一個行為,那麼這個行為很可能是錯的。任何沒有在測試集中體現的行為,都被視為不存在。
測試集不僅驗證行為,而且描述行為。用測試描述行為,從而建立活的文檔。
(PS. 以上情節,是根據實際對話加油添醋改編而成,請勿對號入座。)