第一次用 PHPUnit 寫測試就上手

3. Data Providers (資料提供者)

資料提供者,能提供多筆的測試資料給測試案例進行多次的測試。

使用資料提供者,能讓測試更簡潔,因為,可以將測試的 assertions 與測試資料分開寫。

● 測試 3 – 限制報名人數

在一開始有提到,活動報名系統,會限制每個活動的報名人數。測試案例要測試多個不同報名人數的活動,如果報名成功,reserve() 會回傳 true,相反的報名失敗則回傳 false

src/PHPUnitEventDemo/Event.php
<?php
namespace PHPUnitEventDemo;

class Event
{
    // ignores ...

    public function reserve($user)
    {       
        // 報名人數是否超過限制 
        if ($this->attendee_limit > $this->getAttendeeNumber()) {
            // 使用者報名
            $this->attendees[$user->id] = $user;

            return true;
        }

        return false;
    }

    // ignores ...
}

Event 類別的 reserve() 加入判斷,目前報名人數是否超過活動限制的報名人數,如果沒超過,User 物件加入到 $attendees 陣列內,回傳 true,超過的話,則回傳 false

tests/EventTest.php
<?php

class EventTest extends PHPUnit_Framework_TestCase
{
    // ignore ...
    
    /**
     *  @dataProvider eventsDataProvider
     */
    public function testAttendeeLimitReserve($eventId, 
        $eventName, $eventStartDate, $eventEndDate, 
        $eventDeadline, $attendeeLimit)
    {
        // 測試報名人數限制
        $event = new \PHPUnitEventDemo\Event($eventId, 
            $eventName, $eventStartDate, $eventEndDate, 
            $eventDeadline, $attendeeLimit);
        $userNumber = 6;
        
        // 建立不同使用者報名
        for ($userCount = 1; $userCount <= $userNumber; $userCount++) {
            $userId = $userCount;
            $userName = 'User ' . $userId;
            $userEmail = 'user' . $userId . '@openfoundry.org';
            $user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);
            
            $reservedResult = $event->reserve($user);
            
            // 報名人數是否超過
            if ($userCount > $attendeeLimit) {
                // 無法報名
                $this->assertFalse($reservedResult);
            } else {
                $this->assertTrue($reservedResult);
            }
        }
    }
    
    public function eventsDataProvider()
    {
        $eventId = 1;
        $eventName = "活動1";
        $eventStartDate = '2014-12-24 12:00:00';
        $eventEndDate = '2014-12-24 13:00:00';
        $eventDeadline = '2014-12-23 23:59:59';
        $eventAttendeeFull= 5;
        $eventAttendeeLimitNotFull = 10;
        
        $eventsData = array(
            array(
                $eventId,
                $eventName,
                $eventStartDate,
                $eventEndDate,
                $eventDeadline,
                $eventAttendeeFull
            ) ,
            array(
                $eventId,
                $eventName,
                $eventStartDate,
                $eventEndDate,
                $eventDeadline,
                $eventAttendeeLimitNotFull
            )
        );
        
        return $eventsData;
    }
}

 

EventTest 類別內,加入一個測試方法為 testAttendeeLimitReserve() 來測試限制報名人數。

  • testAttendeeLimitReserve() : 標註了 @dataProvider eventsDataProvider,會取得來自 eventsDataProvider() 的測試資料
  • eventsDataProvider() : 資料提供者,回傳了一個陣列,第一層陣列有兩個元素,表示有兩筆測試資料;第二層陣列有六個元素,表示每個資料傳到測試案例內為六個引數

eventsDataProvider() 的活動資料會由 testAttendeeLimitReserve() 接收,共會分別測試兩次,第一次的測試,會收到報名人數 5 個人的活動;第二次則是會收到報名人數 10 個人的活動。

testAttendeeLimitReserve() 測試案例內,會依來自 eventDataProvider() 的回傳值建立不同報名人數的 Event 物件,每個活動都會有 6 個不同的使用者報名,如果已經報名的人數還沒超過活動限制的報名人數,預期 Eventreserve() 方法的回傳值為 true,反之,超過活動限制的報名人數,則就會預期回傳 false

執行測試

$ phpunit --bootstrap vendor/autoload.php tests/EventTest
PHPUnit 4.4.0 by Sebastian Bergmann.

....

Time: 34 ms, Memory: 3.50Mb

OK (4 tests, 16 assertions)

從測試訊息可以看到,在 EventTest 測試中,有 3 個測試案例,但是測試結果跑了 4 個測試,為什麼呢?

因為 testAttendeeLimitReserve() 使用了 eventsDataProvider() 作為資料提供者,eventsDataProvider() 提供了兩筆資料,這兩筆資料會分別執行兩次測試,加上另外兩個測試案例,所以共有 4 個測試。

Data Provider 與 Test Dependency 的問題

先來看例子,再說明會造成的問題。

tests/EventTest.php
<?php

class EventTest extends PHPUnit_Framework_TestCase
{
    public function testReserve()
    {
        // 測試報名

        // ignore ...
    }

    /**
     *  @dataProvider eventsDataProvider
     */
    public function testAttendeeLimitReserve($eventId, 
         $eventName, $eventStartDate, $eventEndDate, 
         $eventDeadline, $attendeeLimit)
    {
        // 測試報名人數限制
        $event = new \PHPUnitEventDemo\Event($eventId,   
            $eventName, $eventStartDate, $eventEndDate, 
            $eventDeadline, $attendeeLimit);
        $userNumber = 6;

        // 建立不同使用者報名
        for ($userCount = 1; $userCount <= $userNumber; $userCount++) {
            $userId = $userCount;
            $userName = 'User ' . $userId;
            $userEmail = 'user' . $userId . '@openfoundry.org';
            $user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);

            $reservedResult = $event->reserve($user);

            // 報名人數是否超過
            if ($userCount > $attendeeLimit) {
                // 無法報名
                $this->assertFalse($reservedResult);
            } else {
                $this->assertTrue($reservedResult);
            }
        }

        return [$event, $user];
    }

    public function eventsDataProvider()
    {
        // ignore ...
    }

    /**
     *  @depends testAttendeeLimitReserve
     */
    public function testUnreserve($objs)
    {
        // 測試取消報名

        $event = $objs[0];
        $user = $objs[0];

        // 使用者取消報名
        $event->unreserve($user);

        $unreserveExpectedCount = 0;

        // 預期報名人數
        $this->assertEquals($unreserveExpectedCount, $event->getAttendeeNumber());

        // 報名清單中沒有已經取消報名的人
        $this->assertNotContains($user, $event->attendees);
    }
}

原本 testUnreserve() 是依賴 testReserve() ,試著將 testReserve() 改成依賴 testAttendeeLimitReserve(),而 testAttendeeLimitReserve() 使用了eventsDataProvider() 作為資料提供者。

接著,執行這個測試。

$ phpunit --bootstrap vendor/autoload.php tests/EventTest
PHPUnit 4.4.0 by Sebastian Bergmann.

...PHP Fatal error:  Call to a member function unreserve() on a non-object in /Users/aming/git/PHPUnit-Event-Demo/tests/EventTest.php on line 114
PHP Stack trace:

# ignore...

從測試結果可以看出來,在執行 testUnreserve() 測試案例的時候,無法取得 $event 物件,表示 testUnreserve() 根本沒取得來自 testAttendeeLimitReserve() producer 所回傳的值。

所以,在使用相依測試 (Test dependecy) 與資料提供者 (Data provider) 要特別注意,被相依的測試案例,是否有使用資料提供者。

4. Test Exceptions (異常測試)

開發的時候,除了要確保程式運作正常、功能有達到之外,也要對程式可能會超出正常執行的部分進行異常處理,而不是讓程式直接噴出錯誤訊息或忽然的運作停止,如果是這個情況通常都會丟出一個異常出來,讓程式能順暢的處理錯誤,所以,Test exceptions 主要是預期執行發生錯誤的時候,程式會丟出異常出來。

● 測試 4 – 防止重複報名

報名功能需要加入防止相同使用者重複報名相同的活動,如果重複報名的話,就會拋出一個異常出來,接下來的測試,會預期接收到重複報名的異常。

先撰寫要拋出的異常類別。

src/PHPUnitEventDemo/EventException.php
<?php
namespace PHPUnitEventDemo;

class EventException extends \Exception
{
    const DUPLICATED_RESERVATION = 1;
}

接下來撰寫拋出異常的實作。

src/PHPUnitEventDemo/Event.php
<?php
namespace PHPUnitEventDemo;

class Event
{
    // ignore ...

    public function reserve($user)
    {       
        // 報名人數是否超過限制 
        if ($this->attendee_limit > $this->getAttendeeNumber()) {
            // 是否已經報名
            if (array_key_exists($user->id, $this->attendees)) {
                throw new \PHPUnitEventDemo\EventException(
                    'Duplicated reservation',
                    \PHPUnitEventDemo\EventException::DUPLICATED_RESERVATION
                );
            }
            // 使用者報名
            $this->attendees[$user->id] = $user;

            return true;
        }

        return false;
    }
}

因為 Event$attendees 陣列,是用 User 物件 $id 為索引值,來儲存報名使用者的 User 物件。要判別使用者是否已經報名過相同的活動,只要報名的使用者 id 有存在 $attendees 陣列索引值,表示已經有報名活動,如果已報名活動,就會拋出例外。

tests/EventTest.php
<?php

class EventTest extends PHPUnit_Framework_TestCase
{
    // ignore ...

    /**
     * @expectedException \PHPUnitEventDemo\EventException
     * @expectedExceptionMessage Duplicated reservation
     * @expectedExceptionCode 1
     */
    public function testDuplicatedReservationWithException()
    {
        // 測試重複報名,預期丟出異常

        $eventId = 1;
        $eventName = '活動1';
        $eventStartDate = '2014-12-24 12:00:00';
        $eventEndDate = '2014-12-24 13:30:00';
        $eventDeadline = '2014-12-23 23:59:59';
        $eventAttendeeLimit = 10;
        $event = new \PHPUnitEventDemo\Event($eventId, 
            $eventName, $eventStartDate, $eventEndDate, 
            $eventDeadline, $eventAttendeeLimit);

        $userId = 1;
        $userName = 'User1';
        $userEmail = 'user1@openfoundry.org';
        $user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);

        // 同一個使用者報名兩次
        $event->reserve($user);
        $event->reserve($user);
    }
}

 

EventTest 內增加一個 testDuplicatedReservationWithException() 測試案例,在註解內標註:

  1. @expectedException \PHPUnitEventDemo\EventException : 預期的異常類別
  2. @expectedExceptionMessage Duplicated reservation : 預期的異常訊息
  3. @expectedExceptionCode 1 : 預期的異常代碼

也就是,預期在這個測試案例內會接收到 EventException 的異常類別、異常訊息為 Duplicated reservation,異常代碼為 1。

執行測試

$ phpunit --bootstrap vendor/autoload.php tests/EventTest
PHPUnit 4.4.0 by Sebastian Bergmann.

.....

Time: 53 ms, Memory: 3.50Mb

OK (5 tests, 19 assertions)

5. Fixtures

Fixture 能協助測試時,需要用到的測試環境、物件的建立,在測試完後,把測試環境、物件拆解掉,還原到初始化前的狀態。

主要透過 setUp()tearDown() 分別來初始化測試與拆解還原到初始化前的狀態。

下面一樣利用 test/EventTest.php 來示範,先了解目前測試有哪些問題。

tests/EventTest.php
<?php

class EventTest extends PHPUnit_Framework_TestCase
{
    public function testReserve()
    {
        // 測試報名

        $eventId = 1;
        $eventName = '活動1';
        $eventStartDate = '2014-12-24 12:00:00';
        $eventEndDate = '2014-12-24 13:30:00';
        $eventDeadline = '2014-12-23 23:59:59';
        $eventAttendeeLimit = 10;
        $event = new \PHPUnitEventDemo\Event($eventId, 
            $eventName, $eventStartDate, $eventEndDate, 
            $eventDeadline, $eventAttendeeLimit);

        $userId = 1;
        $userName = 'User1';
        $userEmail = 'user1@openfoundry.org';
        $user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);

        // ignore ...
    }

    // ignore ... 

    /**
     * @expectedException \PHPUnitEventDemo\EventException
     * @expectedExceptionMessage Duplicated reservation
     * @expectedExceptionCode 1
     */
    public function testDuplicatedReservationWithException()
    {
        // 測試重複報名,預期丟出異常

        $eventId = 1;
        $eventName = '活動1';
        $eventStartDate = '2014-12-24 12:00:00';
        $eventEndDate = '2014-12-24 13:30:00';
        $eventDeadline = '2014-12-23 23:59:59';
        $eventAttendeeLimit = 10;
        $event = new \PHPUnitEventDemo\Event($eventId, 
            $eventName, $eventStartDate, $eventEndDate, 
            $eventDeadline, $eventAttendeeLimit);

        $userId = 1;
        $userName = 'User1';
        $userEmail = 'user1@openfoundry.org';
        $user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);

        // ignore ...
    }
}

注意 testReserve()testDuplicatedReservationWithException() 兩個測試案例,都需要在測試前建立 EventUser 物件,使用 setUp() 在測試前,建立兩個物件,測試完後,tearDown() 再把不需要的物件清空。

加入 fixtures 後

tests/PHPUnitEventDemo.php
<?php

class EventTest extends PHPUnit_Framework_TestCase
{
    private $event;
    private $user;

    public function setUp()
    {
        $eventId = 1;
        $eventName = '活動1';
        $eventStartDate = '2014-12-24 12:00:00';
        $eventEndDate = '2014-12-24 13:30:00';
        $eventDeadline = '2014-12-23 23:59:59';
        $eventAttendeeLimit = 10;
        $this->event = new \PHPUnitEventDemo\Event(
            $eventId, $eventName,  $eventStartDate, 
            $eventEndDate, $eventDeadline,    
            $eventAttendeeLimit);

        $userId = 1;
        $userName = 'User1';
        $userEmail = 'user1@openfoundry.org';
        $this->user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);
    }
    public function tearDown()
    {
        $this->event = null;
        $this->user = null;
    }
    public function testReserve()
    {
        // 測試報名

        // 使用者報名活動
        $this->event->reserve($this->user);

        $expectedNumber = 1;

        // 預期報名人數
        $this->assertEquals($expectedNumber, $this->event->getAttendeeNumber());

        // 報名清單中有已經報名的人
        $this->assertContains($this->user, $this->event->attendees);

        return $this->event;
    }

    // ignore ...

    /**
     * @expectedException \PHPUnitEventDemo\EventException
     * @expectedExceptionMessage Duplicated reservation
     * @expectedExceptionCode 1
     */
    public function testDuplicatedReservationWithException()
    {
        // 測試重複報名,預期丟出異常

        // 同一個使用者報名兩次
        $this->event->reserve($this->user);
        $this->event->reserve($this->user);
    }
}

$event$user 物件修改成全域變數,接著把建立物件寫在 setUp() 中,清空物件寫在 tearDown(),再將 原本 testReserve()testDuplicatedReservationWithException() 中的 建立 $event$user 物件程式移掉,且使用到這兩個變數改成使用全域變數,也就是 $this->event$this->user

所以在執行測試的時候,運作順序會是: setUp()testReserve()tearDown() → … → setUp()testDuplicatedReservationWithException()tearDown()

五、設定 PHPUnit

在前面此用 PHPUnit 工具來執行測試時,有用到 –bootstrap,在執行測試前先執行 vendor/autoload.php 程式來註冊 autoloading 的 function。可是每次執行測試,都要加上參數有點麻煩,所以,PHPUnit 可以利用 XML 設定檔來設定。

將 phpunit.xml 設定檔放在專案目錄下,與 src、tests 同一層。

phpunit.xml
<phpunit
    bootstrap="./vendor/autoload.php">
    <testsuites>
        <testsuite name="MyEventTests">
            <file>./tests/EventTest.php</file>
        </testsuite>
    </testsuites>
</phpunit>

 

  • <phpunit> : 加入 bootstrap 屬性,對應到的值就是要執行的程式檔案
  • <testsuites> : 在專案底下,能採用不同的測試組合。由一至多個的 <testsuite> 組成
  • <testsuite> : name 屬性,設定測試組合的名稱。測試組合內會包括許多測試程式檔案。

執行測試,如果 XML 設定檔檔名不是 phpunit.xml 的話,可以利用 --configuraton 來指定 XML 設定檔的路徑,如果檔名是 phpunit.xml ,就能省略不指定。

$ phpunit --configuration phpunit.xml tests/EventTest

也可以執行不同的測試組合

$ phpunit MyEventTests

還有更多 XML 設定檔可以使用,參考:https://phpunit.de/manual/current/en/appendixes.configuration.html

六、Code Coverage 分析

撰寫好單元測試之後,該如何了解到哪些目標程式還沒有經過測試?目標程式被測試百分比有多少?

PHPUnit 是利用 PHP_CodeCoverage 來計算程式碼覆蓋率 (Code coverage),需要安裝 Xdebug。

該如何產生 Code coverage 呢? 先在專案底下建立一個 reports/ 目錄,存放 Code coverage 分析的結果。

$ phpunit --bootstrap vendor/autoload.php phpunit.xml --coverage-html reports/ tests/

當然,也可以使用 XML 設定檔來設定。

phpunit.xml
<phpunit
    bootstrap="./vendor/autoload.php">
    <testsuites>
        <testsuite name="MyEventTests">
            <file>./tests/EventTest.php</file>
        </testsuite>
    </testsuites>
    <logging>
        <log type="coverage-html" target="reports/" charset="UTF-8"/>
    </logging>
</phpunit>

接著執行測試

$ phpunit tests/

就可以在 reports/ 下打開 index.html 或其他 HTML 檔案,瀏覽 Code coverage 分析的結果。

enter image description here

更多資料

  • 範例程式:https://github.com/ymhuang0808/PHPUnit-Event-Demo
  • PHPUnit 安裝:https://github.com/ymhuang0808/Hands-On-Writing-Unit-Testing-With-PHPUnit/wiki
  • 參考資料:https://phpunit.de/documentation.html

此文章同步發表於 OpenFoundry:

 

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料