淺談單元測試 - 簡介篇

2014 年開始學習單元測試,一直到現在也很多年了。這之中常跟同事和朋友在討論,但還沒有認真地寫過心得文。

今天就來寫篇我所知道的單元測試吧!

用在做功能測試

很直接的想法,單元測試可以對測試目標(SUT)做功能測試(functional testing)。

比方說,下面是一個兩數加總的程式:

class Calculator
{
public function sum(int $a, int $b): int
{
return $a + $b;
}
}

單元測試可以使用測試程式,來驗證上面這段程式所實作出來的功能,有符合我們的預期:

public function testSumFunction()
{
// 3 + 7 = 10
$this->assertSame(10, (new Calculator())->sum(3, 7));
}

眾所皆知的,單元測試可以自動化,而且單元測試的執行成本相當低廉,因此一般都可以做到完整驗證。這表示我們有辦法做到每次修改程式的時候,做相較完整的測試。當我們不小心把舊功能改壞的時候,單元測試即會反應這個錯誤,進而達到避免改 A 壞 B 的問題。

但除此之外,單元測試還能幫助我們什麼事呢?思考一個問題:

我們該如何確認寫出來的程式是正確的?

當然方法有很多,以傳統的驗收測試(acceptance testing)或端對端測試(end-to-end testing)來說,是從使用者角度出發的測試。若想驗證上面的程式是否正確,最直接的方法就是開個測試專用的 route:

Route::get('/test', function() {
echo (new Calculator())->sum(3, 7);
})

這是一個簡單的小程式。寫好後把網址打開,看結果是不是 10,就可以知道程式寫得對不對了。

這個測試方法很直覺,但有幾個缺點簡單描述如下。

無法提交測試程式

正常來說,這段程式不應該提交(commit)到版本控制裡,因為這與實際功能無關。因此若使用這個方法測試的話,每次新的程式或情境,都得重新寫測試用的程式。

無法用在複雜情境

因為上面兩數加總的例子很簡單,所以才有辦法寫出簡單的測試小程式。如果程式呼叫的路徑錯綜複雜,又有很多無法控制的依賴時,是沒辦法用這個方法測試的,只能直接將改好的程式放在實際功能要放的位置,然後再做一次以上的驗收測試。

為什麼要一次以上?因為有可能第一次在測功能的情境一,第二次在測功能的情境二;而不同的情境又需要重新修改程式或調整依賴;因為程式情境複雜就比較容易出錯,測出來的結果通常也不大能直接反應是測試目標的問題。這些問題會導致開發者要「花非常多時間」才有辦法確認「某一小段程式」是運作正常的,這 C/P 值肯定是非常低的。

而單元測試呢?它可以直接測試目標,無視錯綜複雜的呼叫路徑,且結果可以直接反應功能是否正確。

用在做需求整理

在寫單元測試的過程,同時也是在確認與完成需求

這裡的需求不一定會直接對應到終端使用者角度的需求,有可能只是程式組合上的需求。比方說,數值相加的功能是用在產生報表上,而報表才是真正對終端有意義的需求。在這個情境下,數值相加這個需求應該要長什麼樣子,是沒有人可以問的,只能由開發者自己決定。

可是需求該如何決定,以及確認需求定義沒有問題呢?寫單元測試

以上面兩數總合的測試程式來說,在撰寫單元測試過程可以思考:兩數相加真的適合在產生報表這個功能上嗎?至少 PHP 上應該是不適合的,因為這樣必須要呼叫很多次函式才能完成一群數字的總合:

$sum = 0;

$sum = $calculator->sum($sum, 1);
$sum = $calculator->sum($sum, 2);
$sum = $calculator->sum($sum, 3);
$sum = $calculator->sum($sum, 4);

$this->assertSame(10, $sum);

這個測試案例會發現,如果我們有「一群數字的總合」的需求,會需要修改程式才能達成。比方說:

// 把 1 + 2 + 3 + 4 改用 array 當參數,這是新需求
$this->assertSame(10, $calculator->sum([1, 2, 3, 4]));

// 原本的 3 + 7,這是原本的需求,不改寫
$this->assertSame(10, $calculator->sum(3, 7));

程式修改結果如下:

public function sum(int ...$items): int
{
if (is_array($items[0])) {
$items = $items[0];
}

return array_sum($items);
}

這裡也可以發現,上面修改程式的流程正好就是測試驅動開發,而且完成後的程式,完整實現了必要的需求。

測試是描述需求的方法之一;單元測試則是描述使用程式的需求。

用在做設計程式

這是我近年來的感想,也是寫這篇文章的重點。

唯有在寫單元測試的時候,才能真正驗收設計程式後的成果。單元測試是用特定的方法與情境來「使用」程式。若用起來不順手,那測試就會很難寫,這時候會是一個警訊,應該回頭來思考程式架構或介面設計上是否有問題。

過多的 mock

比方說,使用過多的 mock,對應的現象是有人抱怨要寫一堆 mock,非常煩人。

這是一種警訊。我個人建議的方向是:能不 mock 就不 mock。雖然它很方便,但終究是假的,我們不能太依賴這些假貨,而是盡可能不去使用。另外,使用過多測試替身,是因為依賴過多造成的;而依賴過多又有可能造成低內聚性,進而需要檢視單一職責原則,「可能」就得重新思考物件導向設計。

單一職責原則需要在特定環境下討論才有意義(參考資料),因此過多的 mock 只是「可能」需要檢視原則,而不是「一定」違反原則。

複雜的斷言

有遇過函式回傳的內容太過複雜,導致斷言(assertion)很難寫的現象。

這有可能是函式本身處理了太多任務,或是依賴了某些副作用(side effect)造成的,如時間。這個問題通常需要做依賴解耦,可以透過依賴反轉原則來達成目的。

小結語

過去的我,對於單元測試的想法只知道有「自動化測試」的好處,在不斷持續寫單元測試後,漸漸才發現:單元測試,不只是測試,同時也是需求與設計!

好測的程式,設計不一定很好;
難測的程式,設計一定不大好。

讓我們一起寫單元測試吧,共勉之!


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