單元測試的藝術, 2/e (The Art of Unit Testing: with examples in C#, 2/e)


前言

這本書在剛開始要接觸單元測試時,聽同事說過是本不錯的書,入手後一直塵封許久,終於透過此次機會看完,希望透過撰寫心得的方式內化所學,也期待開發者朋友一同討論及指教。

大綱

  • 單元測試基礎
  • 虛設常式 (Stub) 介紹
  • 模擬物件 (Mock) 介紹
  • 撰寫好的單元測試

單元測試的基礎

單元測試指的就是一段程式碼去呼叫另一段程式碼,驗證某些假設的正確性,如果假設錯誤,單元測試就會失敗。

名詞定義

  • 單元:可以是指一個方法或函數
  • 被測試系統(System Under Test, SUT):被你的測試程式所測試的對象

單元測試的重要性

撰寫單元測試可以減少重複手動測試所耗費的時間,能更快速的驗證程式碼修改是否正確,另一方面可以保護其他邏輯,避免修改程式碼部分與其他功能有依賴,進而改 A 壞 B,這部分是手動測試難以涵蓋的地方。

優秀單元測試的特質

  • 應該是自動化,可以被重複執行的
  • 應該容易被實現
  • 不是臨時性的
  • 任何人都可以輕易執行它
  • 執行速度應該很快
  • 執行結果每次都會是一致的
  • 要能完全掌控被測試的單元
  • 執行時應獨立於其他測試
  • 如果執行失敗,能清楚呈現我們期望的結果為何,發生問題的原因是什麼

作者提供以下方式,可以檢驗寫的測試是否為單元測試:

  • 兩週前、幾個月前、幾年前的單元測試,今天還能正常執行並得到結果嗎?
  • 我兩個月前寫的單元測試,團隊其他成員是否能正常執行它並得到結果
  • 我能在幾分鐘內跑完所有單元測試嗎?
  • 我能一鍵跑完所有寫過的單元測試嗎?
  • 我能在幾分鐘內寫出一個基本的單元測試嗎?

虛設常式 (Stub) 介紹

虛設常式(以下稱 Stub),是指在系統中產生一個可控的替代物件,來取代一個外部相依的物件,在測試過程中,透過 Stub 來避免必須直接相依物件造成的問題。

PHPUnit 這套 PHP 單元測試框架的介紹為例:

假設有一個物件 SomeClass 裡面有一個公開方法 doSomething

<?php declare(strict_types=1);
class SomeClass
{
    public function doSomething()
    {
        // Do something.
    }
}

我們可以透過此段程式碼,使用 $this->createStubSomeClass 變為 Stub 物件,並透過 PHPUnit 提供的 Stub 物件內的方法,讓 doSomething 方法都是回傳 foo

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class StubTest extends TestCase
{
    public function testStub(): void
    {
        // 建立 SomeClass 的 Stub 物件
        $stub = $this->createStub(SomeClass::class);

        // 設定 Stub 物件
        $stub->method('doSomething')
             ->willReturn('foo');

        // 只要呼叫 stub 的 doSomething 都會返回 foo
        $this->assertSame('foo', $stub->doSomething());
    }
}

模擬物件 (Mock) 介紹

模擬物件(以下稱為 Mock)是系統中的假物件,它可以拿來驗證被測試物件是否如預期般呼叫這個假物件,因此來使得單元測試執行成功或失敗。通常每個測試裡最多只會有一個模擬物件

Stub 和 Mock 的差異

Stub 和 Mock 的差異關鍵點在 Mock 需要驗證物件之間的互動,例如 method 被呼叫幾次、傳入的參數、返回的值,這些結果會造成測試成功或失敗。

作者提供了示意圖非常清楚,如下

Stub

Mock

讓我們來看看 PHPUnit 是怎麼使用 Mock

以下為範例物件程式碼:

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

class Subject
{
    protected $observers = [];
    protected $name;

    public function __construct($name)
    {
        $this->name = $name;
    }

    public function getName()
    {
        return $this->name;
    }

    public function attach(Observer $observer)
    {
        $this->observers[] = $observer;
    }

    public function doSomething()
    {
        // Do something.
        // ...

        // Notify observers that we did something.
        $this->notify('something');
    }

    public function doSomethingBad()
    {
        foreach ($this->observers as $observer) {
            $observer->reportError(42, 'Something bad happened', $this);
        }
    }

    protected function notify($argument)
    {
        foreach ($this->observers as $observer) {
            $observer->update($argument);
        }
    }

    // Other methods.
}

class Observer
{
    public function update($argument)
    {
        // Do something.
    }

    public function reportError($errorCode, $errorMessage, Subject $subject)
    {
        // Do something
    }

    // Other methods.
}

以下為測試程式碼,首先使用 $this->createMock 來建立 Mock 物件,接著可以使用物件的 expects 方法來驗證下方 update method 會被呼叫幾次,以及 ->with($this->equalTo('something')) 預期傳入的參數會是 something

<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class SubjectTest extends TestCase
{
    public function testObserversAreUpdated(): void
    {
        // 建立 Observe mock object
        $observer = $this->createMock(Observer::class);

        // 預期 update 這個 method 只會被呼叫 1 次,且參數為 something
        $observer->expects($this->once())
                 ->method('update')
                 ->with($this->equalTo('something'));

        // 建立 Subject 物件,並附加 observer mock 物件
        $subject = new Subject('My subject');
        $subject->attach($observer);

        // 呼叫 Subject 的 doSomething 方法
        // 過程中會驗證 Observer update 方法是否如預期
        $subject->doSomething();
    }
}

撰寫好的單元測試

可信賴

決定何時刪除或修改測試

一但測試寫好並且通過,通常不應該修改或刪除這些測試,除非是以下狀況:

  1. 產品有 bug
  2. 測試有 bug
  3. 語意或 API 有變更,但功能不變
  4. 矛盾或無效的測試

避免測試中帶著邏輯

單元測試是一系列呼叫方法及驗證,不應該包含控制流程的程式碼。

每次只測試一個關注點

一個關注點是一個工作單元的最終結果,驗證過多關注點會造成:

  1. 難以命名測試
  2. 測試失敗時無法快速定位問題

把單元測試及整合測試分開

單元測試相較於整合測試來說穩定,隔離兩者可以讓開發人員執行單元測試並快速獲得反饋,對於重構修改程式碼可以更容易及更安心。

可維護性

測試私有(private)或保護(protected)方法

先觀察私有方法通常被包含在公開(public)方法上,此時測試公開方法也能一同測試到。
作者有提到如果有些私有方法或保護方法若需要被測試,要如何實踐。但我認為多數情況私有方法都是依賴在公開方法,故測試公開方法即可涵蓋。

去除重複的程式碼

遵循 DRY 原則

避免對不同關注點多次驗證

範例:

$this->assertSame(3, sum(1,1,1));
$this->assertSame(3, sum(2,1,4));
$this->assertSame(3, sum(5,6,7));

上述其實是有三個不同的假設,如果寫在同一個測試 method,會造成錯誤但不知道是哪個假設有問題,你應該:

  1. 為每個假設寫一個獨立的 method
  2. 使用參數傳入(PHPUnit dataProvider
    ## 可讀性
    ### 命名
    測試名稱應包含以下三個部分:
  • 測試方法名稱:很重要,明確指出被測試邏輯的位置
  • 測試情境
  • 預期行為

格式為 MethodUnderTest_Scenario_Beharvior()

有意義的驗證

撰寫驗證資訊需包含以下重點:

  • 不要重複測試框架的控制命令輸出的資訊
  • 不要重複測試名稱裡面包含的資訊
  • 如果沒有什麼有用的資訊,就不要顯示任何東西
  • 提供關於什麼應該發生和什麼沒有發生的資訊,如果可以也提供應該發生的時間點

驗證和操作分離

不要把驗證和方法的呼叫放在同一行,例如:

分離範例

public function badAssertMessage() {
    $result = new GetLineCount('abc.txt');
    $this->assertEquals('could not read file', $result);
}

未分離範例

public function badAssertMessage() {
    $this->assertEquals('could not read file', new GetLineCount('abc.txt'));
}

總結

通常程式碼有受到單元測試的保護,開發及重構過程會覺得有信心,快速得到修改後的反饋,以及確保整個產品功能正常運作。在看完本書後,對於 撰寫好的單元測試 有更進一步的概念,期待能在開發上實際運用所學!

#Reading note #Unit Test
單元測試的藝術, 2/e (The Art of Unit Testing: with examples in C#,  2/e)
單元測試的藝術, 2/e (The Art of Unit Testing: with examples in C#, 2/e)
作者 Roy Osherove 陳仕傑(91) 譯 / 出版社 博碩文化

2017-09-28








你可能感興趣的文章

【Day02】The Shell(中)

【Day02】The Shell(中)

寫 PRD 是為了找對問題並解決問題

寫 PRD 是為了找對問題並解決問題

D17_第二週作業、變數儲存模型

D17_第二週作業、變數儲存模型






留言討論