Skip to content

Instantly share code, notes, and snippets.

@ymhuang0808
Last active August 29, 2015 14:12
Show Gist options
  • Save ymhuang0808/ec50b792e80985cacdd9 to your computer and use it in GitHub Desktop.
Save ymhuang0808/ec50b792e80985cacdd9 to your computer and use it in GitHub Desktop.
第一次用 PHPUnit 寫測試就上手
## 一、什麼是測試?測試的重要性?
開發者在撰寫程式的時候,程式不大可能會沒有問題,所以通常就需要驗證程式的執行是不是符合預期。測試程式能用來驗證程式程式的運作是不是正常的,並發現程式中的錯誤,以增加軟體品質。
下面是一個活動報名的系統:
[![測試活動報名系統](http://blog.fmbase.tw/wp-content/uploads/2014/12/詳細活動-1024x859.png)](http://blog.fmbase.tw/wp-content/uploads/2014/12/詳細活動.png)
這一個活動報名系統主要提供使用者報名活動,在報名活動內還有一些子功能,像是「限制活動報名人數」,如果是這一個子功能,該如何測試呢?
直覺想到可能就需要有 20 個以上的使用者,然後讓這個些使用者分別來報名活動,如果沒超過限制的人數,使用者就能繼續報名,反之,超過了限制人數的話,使用者就無法繼續報名了。說到這裡,會覺得這是什麼測試!
先分析一下,照上面的方法進行測試會有什麼問題:
1. **測試案例一多,會花太多時間**
* 這時候,如果再增加報名截止日期的測試,這樣又需要再對報名功能測試一次
2. **改了程式碼之後,需要再做一次測試**
* 日後維護時,會需要再次修改程式碼,但是,改了程式碼之後,該如何驗證修改後的程式運作上是沒問題的?所以,可能又要再次從頭做一次測試
3. **與其他程式混在一起測試**
* 要執行報名功能的程式碼,要透過 view 的程式去呼叫。但是,這樣做測試的時候,如果測試失敗,就必須花時間去找出是報名的程式出問題,還是 view 程式
那該如何解決這些問題呢?針對開發者來說,需要一個可以自動化、重複的、獨立的測試。
## 二、單元測試
單元測試是分別對程式的單元,例如:函示 (function)、方法 (method),進行測試,測試時會判斷單元的執行結果是不是有符合預期。
[![Event & EventTest](http://blog.fmbase.tw/wp-content/uploads/2014/12/PHPUnit_20141224.001拷貝.png)](http://blog.fmbase.tw/wp-content/uploads/2014/12/PHPUnit_20141224.001拷貝.png)
從上圖可以看到,撰寫了`Event` 類別提供了兩個方法,`reserve()`、`unreserve()`,也就是目標程式。
接著透過 `EventTest` 的兩個測試案例,`testReserve()` 與 `testUnreserve()` 分別來對 `Event` 類別中的兩個類別方法執行測試,測試的結果會在測試案例中驗證,如果驗證通過,表示測試就成功了!
### 單元測試能協助開發者什麼?
1. **確保單元的執行結果**
* 這一點蠻覺得就可以了解到,單元測試能協助驗證目標程式的執行結果
2. **儘早發現程式中的錯誤**
* 因為單元測試是在開發的時候就進行的,所以能發現程式中存在的問題
* 沒使用單元測試的時候,寫好了一份程式,但是,這份程式碼需要與其他程式碼整合才有辦法運作。所以就需要等整個程式開發的差不多的時候,才能對程式進行測試,在測試出現問題的時候,會花許多時間來釐清是那一份程式導致的問題
* 如果使用單元測試,就能在寫好了一份程式之後進行單元測試,而不用等到之後才對程式測試
3. **修改程式,更加有信心**
* 程式寫好了,需要維護、修 bug,如果修改了程式碼後,能確定修改後的程式與之前的正常運作是一樣的嗎?單元測試能協助修改程式後,對程式執行測試,如果測試過了,表示程式的運作是正常的,測試失敗的話,可能需要再回頭修改程式
7. **測試即文件**
* 在撰寫測試的時候,會對單元所提供的功能進行驗證,所以,除了能透過程式來瞭解單元的運作外,也能用所撰寫的單元測試來知道,被測試的單元有哪些運作及功能
* 在開發的時候,會撰寫一些文件來作為軟體的文件,在實際上,有時候那些文件並不一定會隨著程式變更而修改,到後來會變成之前所做的文件跟程式是不同步的
* 因為,單元測試的程式會對目標程式進行驗證,所以,測試能避免掉傳統文件所造成的程式與文件不同步的問題
## 三、簡介 PHPUnit
PHPUnit 是 PHP 程式語言中最常見的單元測試 (unit testing) 框架,PHPUnit 是參考 xUnit 架構利用 PHP 實作出來。
為什麼要使用 PHPUnit 來測試呢?雖然,要做單元測試可以自己寫程式來測試, 但是 PHPUnit 提供了一些測試時常用的 library 及解決測試時會遇到問題的方法,所以我們會使用 PHPUnit 來做單元測試。
## 四、撰寫 PHPUnit 測試
### 說明
在進入正題前,先說明範例程式,之後的程式,會利用一個小專案,活動報名系統來示範撰寫單元測試。
活動報名系統主要的功能是提供報名及取消報名。
示範流程:
1. 撰寫目標程式的介面及實作
2. 撰寫單元測試程式碼
3. 執行測試
4. 如果測試失敗,回頭看是實作還是測試程式碼的問題
活動報名系統目錄結構:
```
.

|-- src
| '-- PHPUnitEventDemo
| '-- Event.php
| '-- User.php
|
|-- tests
'-- EventTest.php
```
上面是範例程式的目錄架構
- PHPUnitEventDemo - 底下都是目標程式碼
- Event.php - Event 類別
- User.php - User 類別
- tests - 單元測試目錄
- EventTest.php - 測試 Event 類別的單元測試
### 1. Assertions (斷言)
Assertions 為 PHPUnit 的主要功能,用來驗證單元的執行結果是不是預期值。
小範例:
```php
assertTrue(true); # SUCCESSFUL
assertEquals('orz', 'oxz', 'The string is not equal with orz'); #UNSUCCESSFUL
assertCount(1, array('Monday')); # SUCCESSFUL
assertContains('PHP', array('PHP', 'Java', 'Ruby')); # SUCCESSFUL
```
- `assertTrue()`:判斷實際值是否為 `true`
- `assertEquals()`:預期值是 `orz`,實際值是 `oxz`,因為兩個值不相等,所以這一個斷言失敗,會顯示 `The string is not equal with orz` 的字串
- `assertCount()`:預期陣列大小為 1
- `assertContains()`:預期陣列內有一個 `PHP` 字串的元素存在
從上面的後三個 assertions 可以發現,預期值都是在第一個參數,而後面則是實際值。
#### ● 測試 1 - 提供使用者報名
預期結果:
1. 符合的報名人數
2. 報名的名單中有已經報名的使用者
接下來開始撰寫 `User` 及 `Event` 類別。
##### src/PHPUnitEventDemo/User.php
```
<?php
namespace PHPUnitEventDemo;
class User
{
public $id;
public $name;
public $email;
public function __construct($id, $name, $email)
{
$this->id = $id;
$this->name = $name;
$this->email = $email;
}
}
```
`User` 類別很單純,主要就是建立 `User` 物件用。
##### src/PHPUnitEventDemo/Event.php
```php
<?php
namespace PHPUnitEventDemo;
class Event
{
public $id;
public $name;
public $start_date;
public $end_date;
public $deadline;
public $attendee_limit;
public $attendees = array();
public function __construct($id, $name, $start_date,
$end_date, $deadline, $attendee_limit)
{
$this->id = $id;
$this->name = $name;
$this->start_date = $start_date;
$this->end_date = $end_date;
$this->deadline = $deadline;
$this->attendee_limit = $attendee_limit;
}
public function reserve($user)
{
// 使用者報名
$this->attendees[$user->id] = $user;
}
public function getAttendeeNumber()
{
return sizeof($this->attendees);
}
}
```
`Event` 類別有兩個要說明的變數,`$attendee_limit`、`$attendees`:
- `$attendee_limit` : 活動限制的報名人數
- `$attendees` : 陣列型態,每一個元素為一個 `User` 物件
另外 `Event` 類別內還主有兩個方法,`reserve()` 及 `getAttendeeNumber()`:
- `reserve()` : 提供使用者報名,將報名的使用者存在陣列中,陣列的索引值就是使用者的 id
- `getAttendeeNumber()` : 取得目前報名人數
最後,我們需要撰寫 `EventTest` 來測試 `Event` 的單元結果是不是符合預期。
##### tests/EventTest.php
```php
<?php
class EventTest extends PHPUnit_Framework_TestCase
{
public function testReserve()
{
// 測試活動報名功能
$eventId = 1;
$eventName = '活動1';
$eventStartDate = '2014-12-24 18:00:00';
$eventEndDate = '2014-12-24 20:00: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 = '[email protected]';
$user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);
// 使用者報名活動
$event->reserve($user);
$expectedNumber = 1;
// 預期報名人數
$this->assertEquals($expectedNumber, $event->getAttendeeNumber());
// 報名清單中有已經報名的人
$this->assertContains($user, $event->attendees);
}
}
```
- `EventTest` 會繼承了 PHPUnit 的類別 `PHPUnit_Framework_TestCase`
- `EventTest` 內有一個測試案例 `testReserve()`
- `testReserve()` 內主要會建立一個 `User` 及 `Event` 物件,使用者再去報名一個活動,所以活動已經有一個人報名了
- 接下來的斷言,`assertEquals()` 會預期活動報名人數有 1 個人
- `assertContains()` 預期在活動報名清單內,已經有已報名的使用者
##### 執行測試
```sh
$ phpunit --bootstrap vendor/autoload.php tests/EventTest
PHPUnit 4.4.0 by Sebastian Bergmann.
.
Time: 56 ms, Memory: 3.25Mb
OK (1 test, 2 assertions)
```
. 表示測試了一個測試案例,且通過測試。
#### ● 測試 2 - 提供使用者取消報名
活動除了可以讓使用者報名外,也能取消報名,但是要測試取消報名需要有人報名才能取消。
實作取消報名
##### src/PHPUnitEventDemo/Event.php
```php
<?php
namespace PHPUnitEventDemo;
class Event
{
// ignore ...
public function unreserve($user)
{
unset($this->attendees[$user->id]);
}
}
```
取消報名的實作很簡單,因為 `Event` 物件的 `$attendees` 陣列索引值為使用者的 id,所以使用者要取消報名時,只要將 `$attendees` 對應到使用者 id 陣列索引值的元素給刪掉。
##### tests/EventTest.php
```php
<?php
class EventTest extends PHPUnit_Framework_TestCase
{
/**
* 不應該把兩個不同的測試放在一起
*/
public function testReserveAndUnreserve()
{
$eventId = 1;
$eventName = '活動1';
$eventStartDate = '2014-12-24 18:00:00';
$eventEndDate = '2014-12-24 20:00: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 = '[email protected]';
$user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);
// 使用者報名活動
$event->reserve($user);
$expectedNumber = 1;
// 預期報名人數
$this->assertEquals($expectedNumber, $event->getAttendeeNumber());
// 報名清單中有已經報名的人
$this->assertContains($user, $event->attendees);
// 使用者取消報名
$event->unreserve($user);
$unreserveExpectedCount = 0;
// 預期報名人數
$this->assertEquals($unreserveExpectedCount, $event->getAttendeeNumber());
// 報名清單中沒有已經取消報名的人
$this->assertNotContains($user, $event->attendees);
}
}
```
把報名與取消報名的功能放在同一個測試案例內,這樣是不好的做法,因為,單元測試是分別對每一個單元做驗證,所以需要把報名與取消報名的功能分開測試,寫成不同的測試案例。
該如何將報名與取消報名測試分開呢?往下一個部分 Test Dependencies 看下去。
### 2. Test Dependencies (相依測試)
相依測試,如果有兩個測試案例,具有相依關係,就可以使用 test dependencies 在兩個測試案例建立相依關係。
承接上面要把報名與取消報名測試分開的問題,可以將報名與取消報名分成兩個測試案例,讓取消報名的測試相依於報名的測試。
##### tests/EventTest.php
```php
<?php
class EventTest extends PHPUnit_Framework_TestCase
{
public function testReserve()
{
$eventId = 1;
$eventName = '活動1';
$eventStartDate = '2014-12-24 18:00:00';
$eventEndDate = '2014-12-24 20:00: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 = '[email protected]';
$user = new \PHPUnitEventDemo\User($userId, $userName, $userEmail);
// 使用者報名活動
$event->reserve($user);
$expectedNumber = 1;
// 預期報名人數
$this->assertEquals($expectedNumber, $event->getAttendeeNumber());
// 報名清單中有已經報名的人
$this->assertContains($user, $event->attendees);
return [$event, $user];
}
/**
* @depends testReserve
*/
public function testUnreserve($objs)
{
$event = $objs[0];
$user = $objs[1];
// 使用者取消報名
$event->unreserve($user);
$unreserveExpectedCount = 0;
// 預期報名人數
$this->assertEquals($unreserveExpectedCount, $event->getAttendeeNumber());
// 報名清單中沒有已經取消報名的人
$this->assertNotContains($user, $event->attendees);
}
}
```
把原本的`testReserveAndUnreserve()` 拆成兩個測試:
- `testReserve()` : 測試報名功能
- `testUnreserve()` : 測試取消報名
##### Producer 與 Consumer
`testUnreserve()` 在註解內有利用 `@depends testReserve()` 標註相依於 `testReserve()` 測試,而被相依的測試可以當作 producer,將值傳給相依的測試 `testUnreserve()` 為 consumer,透過引數接收。
這樣就能將報名 `testReserve()` 與取消報名 `testUnreserve()` 測試分開,`testUnreserve()` 會接收來自 `testReserve()` 的回傳值,為一個兩個元素的陣列,陣列的第一個元素為,已經有人報名的 `Event` 物件,第二個元素為 `User` 物件,是已經報名的使用者。
如果 `testReserve()` 執行失敗,`testUnreserve()` 會執行嗎?
是不會的,當被相依的測試案例如果測試失敗,那相依的測試就會忽略執行。
我們可以試著將 `testReserve()` 故意測試失敗,只要將針對 `Event` 物件的 `getAttendeeNumber()` 斷言的預期值,從 1 改成 0 就可以讓 `testReserve()` 測試失敗,接著再執行測試:
```sh
$ phpunit --bootstrap vendor/autoload.php tests/EventTest
PHPUnit 4.4.0 by Sebastian Bergmann.
FS
Time: 73 ms, Memory: 3.50Mb
There was 1 failure:
1) EventTest::testReserve
Failed asserting that 1 matches expected 0.
/Users/aming/git/Hands-On-Writing-Unit-Testing-With-PHPUnit/tests/EventTest.php:15
FAILURES!
Tests: 1, Assertions: 2, Failures: 1, Skipped: 1.
```
### 3. Data Providers (資料提供者)
資料提供者,能提供多筆的測試資料給測試案例進行多次的測試。
使用資料提供者,能讓測試更簡潔,因為,可以將測試的 assertions 與測試資料分開寫。
#### ● 測試 3 - 限制報名人數
在一開始有提到,活動報名系統,會限制每個活動的報名人數。測試案例要測試多個不同報名人數的活動,如果報名成功,`reserve()` 會回傳 `true`,相反的報名失敗則回傳 `false`。
##### src/PHPUnitEventDemo/Event.php
```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
<?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`。
執行測試
```sh
$ 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
<?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()` 作為資料提供者。
接著,執行這個測試。
```sh
$ 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
<?php
namespace PHPUnitEventDemo;
class EventException extends \Exception
{
const DUPLICATED_RESERVATION = 1;
}
```
接下來撰寫拋出異常的實作。
##### src/PHPUnitEventDemo/Event.php
```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
<?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 = '[email protected]';
$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。
執行測試
```sh
$ 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
<?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 = '[email protected]';
$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 = '[email protected]';
$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 = '[email protected]';
$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
```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 ,就能省略不指定。
```sh
$ phpunit --configuration phpunit.xml tests/EventTest
```
也可以執行不同的測試組合
```sh
$ phpunit MyEventTests
```
還有更多 XML 設定檔可以使用,參考:https://phpunit.de/manual/current/en/appendixes.configuration.html
## 六、Code Coverage 分析
撰寫好單元測試之後,該如何了解到哪些目標程式還沒有經過測試?目標程式被測試百分比有多少?
PHPUnit 是利用 [PHP_CodeCoverage](https://github.com/sebastianbergmann/php-code-coverage) 來計算程式碼覆蓋率 (Code coverage),需要安裝 Xdebug。
該如何產生 Code coverage 呢?
先在專案底下建立一個 *reports/* 目錄,存放 Code coverage 分析的結果。
```sh
$ phpunit --bootstrap vendor/autoload.php phpunit.xml --coverage-html reports/ tests/
```
當然,也可以使用 XML 設定檔來設定。
##### phpunit.xml
```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>
```
接著執行測試
```sh
$ phpunit tests/
```
就可以在 reports/ 下打開 *index.html* 或其他 HTML 檔案,瀏覽 Code coverage 分析的結果。
![enter image description here](http://blog.fmbase.tw/wp-content/uploads/2014/12/Code_Coverage_for__Users_aming_git_Hands-On-Writing-Unit-Testing-With-PHPUnit_src_PHPUnitEventDemo_Event_php-%E6%8B%B7%E8%B2%9D.png)
## 更多資料
- 範例程式: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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment