淺談單元測試 - 實踐篇
單元測試意外發展成系列文,不過這主題又廣又深,它可以出一本書叫單元測試的藝術,另外還有 Kuma 哥花 30 天寫的鐵人賽冠軍,這麼多精采的內容,其實也意味著這主題是需要解釋的。
前兩篇傳送門提供給有興趣的人連結:
本篇文章將會分享簡單的方向與原則,讓大家可以大略了解單元測試的樣貌,進而有辦法對測試程式做審查。
單元測試定義
開頭,先來定義單元測試。曾有看過文章提到單元測試不應該跟資料庫互動,應該使用測試替身(test double)隔離依賴資料庫。但我個人認為,可以控制的依賴就不需要隔離,因此當資料庫服務的啟動、關閉等行為可控的話,那它也可以被單元測試涵蓋,因此我認為測試就算包含資料庫也可以算是單元測試。
目前書讀的少,還沒有聽到標準定義,所以以下的描述是採用「資料庫互動測試是單元測試」的想法為前提討論。
FIRST 原則
單元測試需要遵守五個原則,把首字拿出來剛好是 FIRST,類似 SOLID 一樣合成一個單字。其中 T 會有兩種解釋:Thorough 和 Timely,因為都有道理,所以都拿來解釋。
部分原則也可以應用在檢視驗收測試案例。
Fast,就是要快
單元測試主要在驗證程式執行邏輯是否如預期,有時候會因需求會把測試範圍擴大,比方說測試資料庫存取行為,像 Laravel 針對這點做了非常多改善,讓寫出測試 DB 的程式變得非常簡單。當然,要怎麼做都行,只是這個原則指的是,執行的速度要快。管理篇有提到要「時常執行」,這是持續整合的精神,可是整合的時間太久的話,就容易讓開發者不耐煩,接著就會選擇放棄「時常執行」,這是很可惜的。
這裡也延伸出另一個議題:要讓大家做持續整合最有效的方法就是,縮短開始整合到回饋結果的時間,只要時間縮短,就有辦法做到頻繁驗證,這就是持續整合的精神。
快,是一個相對主觀的感受,而且會隨著程式複雜度與測試情境調整,所以無法明確定義。不過 Continuous Integration 這本書裡面有提到建置(build)的建議時間:
A commit build is your fastest integration build (< ten minutes) and includes a compile and the unit tests.
書上提到一個做編譯(compile)與單元測試的快速整合型建置,時間要小於 10 分鐘;我個人手邊遇過最久的差不多 8 分鐘,但實際上個人感受是,3 分鐘就感到很痛苦了,以上時間僅供參考。
若測試範圍真的很大,測到滿要花很久時間的時候,該怎麼辦?
Continuous Integration 這本書裡已經有提到了,下一篇文章再來討論。
Independent,測試間互不依賴
測試案例之間,必須要各自獨立。白話地說,第一次執行的結果不能影響第二次執行的結果。有的工具會提供隨機執行測試的參數,可以使用這樣的工具來確認測試案例是獨立的。
反例:使用共用資料庫做單元測試就可能會出現測試間依賴問題,使用獨立的資料庫進行測試才是合適的選擇。
Repeatable,可重複執行的
這個原則在說的是,單元測試應該要能在任何環境都能正常執行。也就是不管在誰的電腦,包括持續整合的服務上,都要能正常執行。
反例:經典語錄「在我的電腦就沒問題」,這通常是因為自己的電腦上有為了測試建置個人客製化環境,所以才會有這個問題。比方說,資料庫存了其他人不知道的特定資料。
Self-Validating,回應驗證的結果
測試的過程會得到很多數據,而驗證的結果是藉由觀察這些數據來判斷驗證是否成功。這個原則說明的是測試結果只能回傳對或錯,也就是判斷對錯的程式是要寫在測試程式裡的。
例:過去曾寫過單元測試相關的文件,裡面提到的一個變化正是在指這個原則。
改之前(不好的):
// Assert |
改之後(好的):
// Assert |
人生苦短,回應對或錯就好,不要只給原始資料讓開發者比對。
Thorough,詳盡的測試
要有良好的生產過程,再搭配詳盡的測試驗證,才能有高品質產出,工廠生產和程式都是如此。
詳盡所代表的意義也可以有很多種,比方說 100% 的涵蓋率,或是交替測試各種不同的使用情境。而網路上能看得到的大多都會是後者,我們應該要嘗試舉列出不同的使用情境來測試,以 PHP 為例,如:
function foo($input) { |
因 PHP 是動態語言,所以 $input
實際上可以傳入各種型別的值,因此在撰寫單元測試時,就有必要測試這些情境。
涵蓋率也很重要,但不是首要追求的目標,提高測試程式碼的品質才是真正要追求的目標,這可以依賴變異測試來確認,日後有機會再討論。
Timely,確保程式與測試是一起完成的
實際執行的程式碼應該要跟單元測試所描述的行為一致,並且是一起完成的。這個原則可以使用 TDD 完美達成,思考 TDD 流程:先寫一個錯誤測試,再寫實際的程式確認測試的需求有被完成。當測試的需求完成時,程式與測試就算是一起完成的了。
反例:經典語錄「以後有空再來補測試」
實作上的困難
不會寫單元測試的團隊,會有兩種困難造成惡性循環:
- 不會寫單元測試,所以測試花太多時間,導致時間不足
- 沒時間,所以沒辦法學習單元測試
考慮管理篇曾提過的測試案例邊際效應,嘗試寫出第一個有效且正確的測試將會是對團隊最有效的投資。而在程式碼審查時,什麼樣的測試「相較之下比較好」,可以參考上面所提到的 FIRST 原則。而再談另一個層面的問題:該如何選擇測試目標(SUT),以下提供幾個可行的方向給大家思考。
- 重要的,如帳務計算肯定是重要的,這通常會需要產品擁有者來決定。
- 高風險的,如分析發現 Bug 機率比較高的程式。
- 簡單的,如全新沒包袱的專案。但這種是為了練習可用,但實務上,我們是有必要去面對有包袱的專案,因此不建議長期都只在全新專案寫測試。
會寫 v.s. 會習慣寫
團隊若不會寫單元測試的話,當然需要經過教育訓練或其他方法來提升技能。但要訓練到什麼樣的程度才算是會寫呢?
我認為,會習慣寫單元測試才是真的會寫單元測試。
會習慣寫的話,有一些行為展現如下:
- 程式若沒寫單元測試,會覺得沒有測好,因此會驅使自己主動寫單元測試。
- 功能評估實作時間時,會把單元測試包含進去,而不是分開評估。
- 傾向透過測試案例,來描述或理解程式碼的行為。
以上如果都具備的話,那代表單元測試對開發者而言是必要的,因此自然就會習慣寫。這也是我們管理篇所提到重要的目的--讓這個好處帶入自己的團隊裡,並且讓程式碼的品質可以維持在一定水準之上。
但行為展現會是需要觀察,且比較難量化。我們有另一個方法是,針對程式碼做分析,從量化一些指標並觀察它們的變化,得知團隊程式碼的品質提升,這些就留到下一篇在討論吧。
參考資料
感謝 @tedmax100 、 @yaochangyu 、 @jame2408 等好友提供許多寶貴的經驗參考。