這篇說明寫 unit test的好處,以及為何先寫 unit test 再實作,比實作完再補 unit test 更好。這裡先列出摘要:
- 測試碼可以簡省手動測試的時間,但有錯時無法告訴我們錯誤的源頭在那。
- unit test 可以告訴我們錯誤的源頭在那,可是 unit test 有時間成本和維護成本。寫過多 unit test 反而有害。
- TDD 藉由先寫測試避開 unit test 的成本問題,並帶來其它好處。
為什麼要寫測試碼?
吃燒餅那有不掉芝麻,寫程式自然要驗證程式是否正確。想像平時我們如何驗證程式?大概就是寫一寫,執行看看,看看輸出符不符合預期,換些不同輸入,再看看結果,有錯再改,改完再重跑...。測試碼可以協助我們自動化驗證,省時省力。
也許有人會懷疑寫測試碼不划算,功能常改變,到時測試碼又要重改。這個問題視情況有不同種解法,比方用較「便宜」的方式寫測試,像是準備好輸出入檔,用 shell script 執行,配合 diff 看輸出是否一致。或是用 scripting language (如 Python) 寫簡單測試。而更根本的解法,個人認為是配合 TDD,待後面說明。
手動執行測試,執行個三十年,每次花的時間還是一樣,也許還會因為手酸變慢。但寫測試碼則相反,我們會愈寫愈快,愈寫功能愈好。
為什麼要寫 unit test (單元測試)?
首先介紹不同級別的測試碼,由小到大依序為:
- unit test:測試對象為單一函式。
- integration test:測試對象為數個單元函式的複合體。我沒特別區分這個和 unit test [*1],以下兩者一起用 unit test 表示。
- system test:測試整個系統。
- acceptance test:客戶驗收用的測試。將使用情節或使用需求轉成 acceptance test,藉以確認產品滿足客戶要求,方便起見,以下以 system test 統稱。
備註 *1:在談論 mock 時,unit test 和 integration test 的差異較明顯,本文不談這個議題,總得讓大家有意願寫測試,再來談如何寫測試才有意義,你說對吧。
以測試的觀點來看,system test 的 test case 夠多,system test 應該能涵蓋到大範圍的產品碼,如此一來也達成我們一開始提的「驗證」目的。從實務觀點來看,寫愈多測試碼,負擔愈多,規格變動時要改的東西也多。更何況 unit test 著眼點更細,變更實作方式也有可能得改 unit test。如此一來,為什麼要寫 unit test?
首先,unit test 著眼點比 system test 小,也意味著當測試結果不對時,unit test 可以指出更明確的問題點,而 system test 只能粗略地和我們說輸出不對,接著我們得比對正確和錯誤的輸出,開始推測 bug 在那,並用 debugger 設中斷,或在程式內輸出訊息一步步找出問題源頭。相信大家都能明白除錯的痛苦。而 unit test 可以協助我們釐清那些程式是對的,那些是錯的,將問題範圍縮小。若 unit test 寫得好,幾乎用不到 debugger 和輸出訊息,光看那個 unit test 錯誤,就知道 bug 在那。
除了幫自己省時間外,unit test 也可幫助別人維護和理解程式。維護的人不如自己熟悉,難免不小心改爛程式,unit test 可以減少這類問題,也讓原作者安心給其他人修改。新手要閱讀程式時,unit test 可看成是各函式的使用範例,相信大家都同意讀例子比讀全部程式碼來得容易吧。好的範例有時比註解還有用。
為什麼要先寫測試?
好吧,若讀者大人耐著性子看到這裡,想必已對測試碼已有些心動,也認為寫 unit test 有那麼幾分道理,那先寫測試和後寫測試有什麼差別?若後來寫的程式不管用,需求變更,先寫測試不是搬磚砸自己的腳,自找麻煩嗎?事實上正好相反。
我們得先問自己,為什麼程式會不管用?為什麼需求會變更?去除客戶或主管找麻煩的因素外,一部份原因為思慮不週,寫的功能不夠貼近目標。如同產品一般,沒試用過我們不會明白產品的缺點在那,函式也是一樣。先寫測試碼就是先想像要如何使用即將要寫的函式,在寫測試碼的同時,我們同時也在設計函式。有時會發現難以測試,而想出更簡潔的介面。當我們能輕鬆寫出測試碼時,也意味著目標函式易於使用,之後才方便重用。
再者,先寫測試,相當於先列需求,規範中的輸入為何?預期輸出為何?可能有什麼例外情況?於是,我們接著寫出的程式,一定是有用的。我們不會忽然想說「要不要加個 foo(),之後大概會用到吧。」先寫測試,確保之後寫出的每一行產品碼都是有意義的。同時,我們寫的測試碼也是有意義的,若事後再補測試,不明白加入一個 unit test 有何幫助,有時會多寫不必要的 unit test,如同前面提過,測試碼不是萬靈丹,它也同時是負擔,需求變更時,有時也得一併改測試碼。再者,先寫測試表示有先考慮程式的可測性 (testability),使得程式容易測試,通常會將程式拆得很細,待寫產品碼時容易將寫好的小函式組成目標功能;完成產品碼後再來補測試則困難許多,由於寫產品碼時沒有考慮到寫測試的需求,結果是影響測試碼的品質,甚至變成過於難寫而不補上測試。
除幫助設計、減少寫冗碼的機會外,先寫測試還方便我們之後進行重構。沒有測試碼的重構是很危險的,而沒重構的程式碼也是很危險的 [*2],就像在底層不穩的地基上不斷加蓋偷工減料的高樓一般。先寫測試的好處還有一點,早寫早享受,先寫測試,就能先自動化驗證,省去所有手動驗證。
備註 *2:為什麼要重構?這是一個值得獨立討論的議題,主因為減少隱藏的 bug 和強化程式碼的可讀性,並更容易重用,以及增加新功能。詳見 Martin Fowler 寫的 《重構:改善既有程式的設計》
綜合以上所言,先寫測試花費的時間,可以攤算在設計時間,以及減少寫無用程式、減少手動驗證、縮短除錯的時間,所以先寫測試不見得會增加額外時間,我們只是把投入不划算事上的時間,先拿來寫測試。
特別加贈:何時該寫新的測試碼?
雖然寫測試好處多多,也別卯起勁來狂寫,最後卻覺得投資沒回本,浪費過多時間寫測試。遵照 TDD 的原則,先依規格寫測試,別浪費時間寫也許有用的測試。
那麼,何時才是跳出 TDD 迴圈,另外加寫測試的時機呢?依我個人經驗,我發現以下三個情況,都是寫新測試碼的好時機:
- 在程式裡輸出訊息找錯誤:與其花時間寫 print、看輸出訊息、再回頭砍掉 print,不如寫個 unit test,之後能持續受惠。
- 執行 debugger:同上,你還是得花時間設中斷,一步步看訊息,不如寫個 unit test,之後能持續受惠。
- 不知錯誤在那,該如何進行下一步:這表示一次貪心實作太多功能,把目標縮小,一步步補 unit test 吧。
另外,在解 bug 時,先寫個 unit test 重製問題,再開始除錯。如此一來可確保自己明白成因,之後同樣的 bug 也不會再出現。
超級加贈:若 TDD 這麼好用,為啥它不遍及?
施主,這個問題得問你自己啊,快來加入推廣 TDD 的行列吧!
說正經的,雖然上面將寫 unit test 和 TDD 說得如此美好,寫 unit test 不是簡單的事。若寫得不好,就會有「改變實作,也得改變測試」的副作用,或是測試執行過久,不方便反覆執行,或是寫出無用的測試。如同學習 design pattern 一般,unit test 也有 design pattern,對沒接觸過寫測試碼的人來說,相當於要花「兩倍」時間學習。加上前面提到大家可能有的誤解,使得 TDD 難以落實。
另一個可能是,若你的組織同時有兩批人馬對同一產品進行開發和除錯,程式碼分成兩個 branch:一個加入新功能,一個除錯。重構會造成程式碼難以合併,決策者因而選擇不頻繁重構。並且,這類組織可能有龐大的人力另外進行測試 (可看成是 system / acceptance test),而誤以為不需要 unit test。
然而,長遠來看,第一個理由顯然是偷懶,只是把現在的問題延後到日後再解決。趁早把除錯的時間省下來,投資到 TDD 這積優股吧。第二個情況可能是公司考量,專業分工使得公司容易找人和培訓,我沒在這類公司工作的經驗,不知能如何對應。
詳細的 TDD 風險,可參照 What is the downside to Test Driven Development?。結論是團隊要有熱情學一堆新技術,或是有個經驗老道的開發者帶才適合用 TDD。不過,我個人的觀點是,現實世界的選擇不是非一即零,不是所有程式都能 TDD,若成本過高用 TDD 不划算,那就暫時別花時間學相關技巧,挑軟柿子吃。至少,有用 TDD 的部份就能享受到 TDD 帶來的好處。別因為一些可能想見的問題,而全盤否定使用 TDD。
我對 TDD 充滿熱血,請問如何入門?
好吧,這個標題是我寫來自 high 的。若有興趣的話,可以參考 The Bowling Game Kata 的投影片,由設計保齡球計分程式的小例子,一步步展示 TDD 進行的過程,相信跟著例子走一次必能有所啟發,親身體驗 TDD 如何帶來新設計。若能自己先寫一遍,再來看投影片,感受會更深。或是參閱我之前寫的介紹文:《TDD 推廣:背景知識和簡介》以及《最近用 Python + TDD 心得(與 Java 做對照)》。