iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 12
4
Modern Web

成為 Modern PHPer系列 第 12

Day 12:使用 PHPUnit

「我們公司不寫測試的,那會浪費開發時間。」--某知名電商技術工

前言

正如同在 Day 06:yield 的使用 中所提到的,這間電商的技工主管又再次語出驚人。

我並不是什麼 TDD、BDD、DDD 之類新潮名詞的傳教士,我僅僅只是覺得:撰寫測試是一個工程師對於自己程式碼的負責態度。

基本概念

對於一個系統,重構是必然發生的事情。工程師的工作不外乎以下三項:

  • 加入新功能
  • 增加既有程式效率
  • 修復程式邏輯錯誤

人是很容易遺忘的,可能今天寫的程式到半年後回來看,記憶已經所剩無幾。除了良好的程式設計習慣(如 Coding Style)之外,測試可以保證在一定程度上系統是可以正常運作的。

時機

以下時候,你應該撰寫測試:

  • 寫新的程式時
  • 維護既有程式,並且需要更改邏輯時
  • 維護既有程式,發現以前並沒有留下測試時

以下時候,你不應該撰寫測試:

  • 對於 PHP 內建的函式做測試
    • PHP 內建的函式在編譯時都可以利用 make test 進行測試,它會利用 source code 中的 *.phpt 進行測試,所以不需要另外在自己的專案中測試。
  • 對於框架內建的功能做測試
    • 通常既有框架都必須包含完整的測試,除非改寫框架既有的邏輯,否則不應再去重複測試一次
  • 對於自定義的 Class 中的 private 或 protected method 做測試
    • 在技術上是可以做到測試 class 中的 private 或 protected method,但除非有強烈的需求,否則應利用 public method 去做

謬誤

測試會增加開發時間

註:這是那個電商技工對我的質疑。

事實上這並沒有錯,因為需要另外撰寫測試用的程式。

然而,測試的目的在於:保證程式是可以正常運作的,否則在無憑無據的情況下,能夠相信自己的程式是邏輯完美、毫無瑕疵的?

另外,測試還有另一個目的:保證未來重構時確認功能仍是正常的。除非每次面對問題都以「打掉重練」的態度下去執行,否則對於一個成熟的系統而言,重構是必然。

當時我在面試時,是使用一個質疑回應:你們如何能夠保證程式是毫無瑕疵的?如果要重構又會以什麼為憑據?

當時他們的回應是:我們的程式都很小很小,不容易出錯。

我:所以在系統組合起來之後,仍然是不容易出錯的嗎,任何邏輯上都不會有謬誤?

我已經忘記當時他的回應是什麼了,不過我對於這樣的公司還能夠在台灣擁有一席之地感到訝異。

測試是 QA 的事

對於比較大的公司或組織架構,常常會有專職測試的編制。

我認為這樣的編制仍是有其必要,因為讓開發者自行編寫測試,時常會存在視野盲區。

例如我們測試一個函式 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/

設定 PHPUnit

修改 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

進階探討

Mock

有時候,對於外部資源的存取結果會放在一個 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 上有個討論:[討論]「沒做版控不要去」在台北新竹以外實際嗎?,我認為類似的討論在「測試」上也是行得通的。


上一篇
Day 11:使用 composer
下一篇
Day 13:PHP 佈署概述
系列文
成為 Modern PHPer30

尚未有邦友留言

立即登入留言