「我們公司不寫測試的,那會浪費開發時間。」--某知名電商技術工
正如同在 Day 06:yield 的使用 中所提到的,這間電商的技工主管又再次語出驚人。
我並不是什麼 TDD、BDD、DDD 之類新潮名詞的傳教士,我僅僅只是覺得:撰寫測試是一個工程師對於自己程式碼的負責態度。
對於一個系統,重構是必然發生的事情。工程師的工作不外乎以下三項:
人是很容易遺忘的,可能今天寫的程式到半年後回來看,記憶已經所剩無幾。除了良好的程式設計習慣(如 Coding Style)之外,測試可以保證在一定程度上系統是可以正常運作的。
以下時候,你應該撰寫測試:
以下時候,你不應該撰寫測試:
make test
進行測試,它會利用 source code 中的 *.phpt
進行測試,所以不需要另外在自己的專案中測試。註:這是那個電商技工對我的質疑。
事實上這並沒有錯,因為需要另外撰寫測試用的程式。
然而,測試的目的在於:保證程式是可以正常運作的,否則在無憑無據的情況下,能夠相信自己的程式是邏輯完美、毫無瑕疵的?
另外,測試還有另一個目的:保證未來重構時確認功能仍是正常的。除非每次面對問題都以「打掉重練」的態度下去執行,否則對於一個成熟的系統而言,重構是必然。
當時我在面試時,是使用一個質疑回應:你們如何能夠保證程式是毫無瑕疵的?如果要重構又會以什麼為憑據?
當時他們的回應是:我們的程式都很小很小,不容易出錯。
我:所以在系統組合起來之後,仍然是不容易出錯的嗎,任何邏輯上都不會有謬誤?
我已經忘記當時他的回應是什麼了,不過我對於這樣的公司還能夠在台灣擁有一席之地感到訝異。
對於比較大的公司或組織架構,常常會有專職測試的編制。
我認為這樣的編制仍是有其必要,因為讓開發者自行編寫測試,時常會存在視野盲區。
例如我們測試一個函式 add($number1, $number2)
時,可能會只使用 1, 2
這樣的參數組合,也就是所謂的 Happy Case。不過如果使用 Edge Case 下去做測試,很可能就會因為 integer overflow 或自動轉型為 double 時產生錯誤,這部份 QA 就能夠以其專業補足測試。
這邊說個題外話,以前唸研所時當大一程設的助教,第一堂課我就直接表明:「如果作業寫不出來是沒關係的,至少要繳個屍體就有分數。記住,要活過才能夠叫屍體,那種編譯都不會過的東西只能叫人體鍊成的失敗品。」
讓開發者寫測試就像是「至少東西編譯是會過,而且擁有基本運作的能力」,對於一個真正要上線的產品,仍然有賴於 QA 的專業。
在 PHP 中存在很多「測試框架」:PHPUnit、Behat、Codeception、PHPSpec……
其中最基礎的應該是 PHPUnit。專案中至少應該存在一個測試框架,而我認為應該是 PHPUnit。
利用 composer 可以快速安裝 phpunit
composer require --dev phpunit/phpunit
利用 --dev
旗標表明這個 Package 僅在開發時期使用。
通常會建議依照以下的資料夾結構:
project/
src/
tests/
composer.json
其中 src/
是用來存放主要程式碼的,這個名字可以自由變更(例如在 Laravel 中是 app/
)
修改 composer.json
,為 tests/
加入 PSR-4 Autoloader 的機制
{
"name": "chivincet/phpunit"
"require-dev": {
"phpunit/phpunit": "^8.0"
},
"autoload": {
"psr-4": {
"Chivincent\\Phpunit\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
加入 phpunit.xml
,指定測試範圍與自動載入 vendor/autoloader.php
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuit>
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
</phpunit>
// File: src/Main.php
class Main
{
public function hello(string $name = 'World'): string
{
return "Hello, $name";
}
}
// File: tests/MainTest.php
use PHPUnit\Framework\TestCase;
class MainTest extends TestCase
{
// 註:我在測試案例中通常不用 PSR-12 中所指定的駝峰式命名
// 因為它有時會讓名稱變得難以閱讀
public function test_hello()
{
$main = new Main();
$this->assertSame('Hello, World', $main->hello());
}
public function test_hello_with_name()
{
$main = new Main();
$this->assertSame('Hello, Jack', $main->hello('Jack'));
}
}
接著,在專案根目錄執行 vendor/bin/phpunit
即可。
對於其它的 assertion 可以參考 PHPUnit - Assertions
有時候,對於外部資源的存取結果會放在一個 Class 中(例如 Facebook 登入後取得的 User 資料),但不可能每次執行測試時都對該外部資源發一次請求。
假設存在以下程式
// File: src/FacebookUser.php
class FacebookUser
{
/**
* @var $user
*/
protected $user;
public function __construct(FacebookResponse $response)
{
$this->user = $response->extractUser();
}
public function getEmail(): ?string
{
return $this->user->email;
}
}
上述程式中,FacebookResponse
是某個來自於 FB 的 OAuth Response。想當然爾,不可能每次做自動化測試時都跟 FB 要一次(一般網路服務都會有所限制)
然而,為了要測試這個程式,我們必須有一個 FacebookResponse
的 Class,這時就可以利用 Mock。
// File: tests/FacebookUserTest.php
use PHPUnit\Framework\TestCase;
class FacebookUserTest extends TestCase
{
public function test_get_email()
{
$facebookResponseStub = $this->createMock(FacebookResponse::class);
$facebookResponseStub->method('extractUser')
->willReturn((object)['email' => 'user@fb.test']);
$facebookUser = new FacebookUser($facebookResponseStub);
$this->assertSame('user@fb.test', $facebookUser->getEmail());
}
}
利用 PHPUnit 所內建的 createMock
建立一個「假的」 FacebookResponse,然後進行測試。
註:Mock 出來的 Class 是一個繼承於指定 Class 的匿名類別,它裡面會加入一些 method 可供使用。
有關於 Mock,建議一定要閱讀 PHPUnit 的官方文件 8. Test Doubles
本來在「進階探討」的地方想講一些有關測試 private/protected method 的方法,沒想到光這樣的字數都五千多字所以作罷(反正本來也不是什麼正規做法)
我相信,2019 年的今天,一定還有很多開發者、公司並沒有測試的習慣,或是一股腦把測試的責任丟給 QA。事實上也不能夠怪罪他們,只不過頂著自己沒測試還硬要宣揚他們不測試的理念的,就像沒穿褲子還在大街上宣揚妨害風化的人一樣。
前陣子 PTT 上有個討論:[討論]「沒做版控不要去」在台北新竹以外實際嗎?,我認為類似的討論在「測試」上也是行得通的。