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 個不同的使用者報名,如果已經報名的人數還沒超過活動限制的報名人數,預期 Event 的 reserve() 方法的回傳值為 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() 測試案例,在註解內標註:
@expectedException \PHPUnitEventDemo\EventException: 預期的異常類別@expectedExceptionMessage Duplicated reservation: 預期的異常訊息@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() 兩個測試案例,都需要在測試前建立 Event 與 User 物件,使用 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 分析的結果。

更多資料
- 範例程式: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: