前言
這本書在剛開始要接觸單元測試時,聽同事說過是本不錯的書,入手後一直塵封許久,終於透過此次機會看完,希望透過撰寫心得的方式內化所學,也期待開發者朋友一同討論及指教。
大綱
- 單元測試基礎
- 虛設常式 (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->createStub
將 SomeClass
變為 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();
}
}
撰寫好的單元測試
可信賴
決定何時刪除或修改測試
一但測試寫好並且通過,通常不應該修改或刪除這些測試,除非是以下狀況:
- 產品有 bug
- 測試有 bug
- 語意或 API 有變更,但功能不變
- 矛盾或無效的測試
避免測試中帶著邏輯
單元測試是一系列呼叫方法及驗證,不應該包含控制流程的程式碼。
每次只測試一個關注點
一個關注點是一個工作單元的最終結果,驗證過多關注點會造成:
- 難以命名測試
- 測試失敗時無法快速定位問題
把單元測試及整合測試分開
單元測試相較於整合測試來說穩定,隔離兩者可以讓開發人員執行單元測試並快速獲得反饋,對於重構修改程式碼可以更容易及更安心。
可維護性
測試私有(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,會造成錯誤但不知道是哪個假設有問題,你應該:
- 為每個假設寫一個獨立的 method
- 使用參數傳入(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'));
}
總結
通常程式碼有受到單元測試的保護,開發及重構過程會覺得有信心,快速得到修改後的反饋,以及確保整個產品功能正常運作。在看完本書後,對於 撰寫好的單元測試
有更進一步的概念,期待能在開發上實際運用所學!
2017-09-28