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:
發佈留言