淺談單元測試 - 實踐篇

單元測試意外發展成系列文,不過這主題又廣又深,它可以出一本書叫單元測試的藝術,另外還有 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
var_dump($sum); // will see something

改之後(好的):

// Assert
if ($expected === $actual) {
echo 'test OK';
} else {
echo 'test Fail';
}

人生苦短,回應對或錯就好,不要只給原始資料讓開發者比對。

Thorough,詳盡的測試

要有良好的生產過程,再搭配詳盡的測試驗證,才能有高品質產出,工廠生產和程式都是如此。

詳盡所代表的意義也可以有很多種,比方說 100% 的涵蓋率,或是交替測試各種不同的使用情境。而網路上能看得到的大多都會是後者,我們應該要嘗試舉列出不同的使用情境來測試,以 PHP 為例,如:

function foo($input) {
// do something

return $output
}

因 PHP 是動態語言,所以 $input 實際上可以傳入各種型別的值,因此在撰寫單元測試時,就有必要測試這些情境。

涵蓋率也很重要,但不是首要追求的目標,提高測試程式碼的品質才是真正要追求的目標,這可以依賴變異測試來確認,日後有機會再討論。

Timely,確保程式與測試是一起完成的

實際執行的程式碼應該要跟單元測試所描述的行為一致,並且是一起完成的。這個原則可以使用 TDD 完美達成,思考 TDD 流程:先寫一個錯誤測試,再寫實際的程式確認測試的需求有被完成。當測試的需求完成時,程式與測試就算是一起完成的了。

反例:經典語錄「以後有空再來補測試」

實作上的困難

不會寫單元測試的團隊,會有兩種困難造成惡性循環:

  1. 不會寫單元測試,所以測試花太多時間,導致時間不足
  2. 沒時間,所以沒辦法學習單元測試

考慮管理篇曾提過的測試案例邊際效應,嘗試寫出第一個有效且正確的測試將會是對團隊最有效的投資。而在程式碼審查時,什麼樣的測試「相較之下比較好」,可以參考上面所提到的 FIRST 原則。而再談另一個層面的問題:該如何選擇測試目標(SUT),以下提供幾個可行的方向給大家思考。

  • 重要的,如帳務計算肯定是重要的,這通常會需要產品擁有者來決定。
  • 高風險的,如分析發現 Bug 機率比較高的程式。
  • 簡單的,如全新沒包袱的專案。但這種是為了練習可用,但實務上,我們是有必要去面對有包袱的專案,因此不建議長期都只在全新專案寫測試。

會寫 v.s. 會習慣寫

團隊若不會寫單元測試的話,當然需要經過教育訓練或其他方法來提升技能。但要訓練到什麼樣的程度才算是會寫呢?

我認為,會習慣寫單元測試才是真的會寫單元測試。

會習慣寫的話,有一些行為展現如下:

  • 程式若沒寫單元測試,會覺得沒有測好,因此會驅使自己主動寫單元測試。
  • 功能評估實作時間時,會把單元測試包含進去,而不是分開評估。
  • 傾向透過測試案例,來描述或理解程式碼的行為。

以上如果都具備的話,那代表單元測試對開發者而言是必要的,因此自然就會習慣寫。這也是我們管理篇所提到重要的目的--讓這個好處帶入自己的團隊裡,並且讓程式碼的品質可以維持在一定水準之上。

但行為展現會是需要觀察,且比較難量化。我們有另一個方法是,針對程式碼做分析,從量化一些指標並觀察它們的變化,得知團隊程式碼的品質提升,這些就留到下一篇在討論吧。

參考資料


感謝 @tedmax100@yaochangyu@jame2408 等好友提供許多寶貴的經驗參考。