Last active
August 29, 2015 14:12
-
-
Save ymhuang0808/ec50b792e80985cacdd9 to your computer and use it in GitHub Desktop.
第一次用 PHPUnit 寫測試就上手
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## 一、什麼是測試?測試的重要性? | |
開發者在撰寫程式的時候,程式不大可能會沒有問題,所以通常就需要驗證程式的執行是不是符合預期。測試程式能用來驗證程式程式的運作是不是正常的,並發現程式中的錯誤,以增加軟體品質。 | |
下面是一個活動報名的系統: | |
[![測試活動報名系統](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