安裝完畢PHPUnit之後,最重要的當然還是拿來做單元測試,所以還是用幾個實際的case來嘗試一下,看看做單元測試的基本方法。
另外,測試是否可以涵蓋測試對象的所有邏輯也是需要考慮的問題。透過xdebug的支援,PHPUnit可以在測試時同時記錄覆蓋率,這樣可以比較清楚知道目前寫的測試是否足夠。
昨天的測試中,一次只跑了一個test case,如果需要測試的東西多,一支一支跑會很麻煩,所以PHPUnit可以把test case組織成test suite,批次進行測試。
今天就把這單元測試、測試結果輸出、測試覆蓋率、組織test suite等功能都試一試。
參考:http://phpunit.de/manual/3.7/en/index.html
* 撰寫單元測試
要能進行單元測試,起碼的要求是程式中有「單元」,這是程式執行的最小單位,也就是函數或是類別的方法。(PHP是可以寫成完全沒單元...這樣就沒辦法做單元測試)
測試的方法很簡單,就是把參數丟給函數或方法,然後檢測它執行的結果是否符合預期。先寫一小段程式,然後使用phpunit來測試:
<?php
class ScormTimeUtils
{
public static function SCORMTimeParser($time)
{
$ret = 0;
$comp = explode(':', trim($time));
if (count($comp)!==3) return 0;
$hour = intval($comp[0]);
if ($hour<0) {
$hour = abs($hour);
$sign = -1;
} else {
$sign = 1;
}
$min = intval($comp[1]);
$sec = floatval($comp[2]);
$ret = ($sec + $min*60 + $hour*60*60)*1000;
return $ret*$sign;
}
public static function SCORMTimeFormat($milisec)
{
if (intval($milisec)<0) {
$milisec = abs($milisec);
$sign = '-';
} else {
$sign = '';
}
$pri = intval($milisec)/1000;
$rest = floor($pri);
$mili = round(floatval($pri - $rest),3)*1000;
$sec = $rest%60;
$rest1 = floor($rest/60);
$min = $rest1%60;
$hour = floor($rest1/60);
$ret = sprintf("$sign%04d:%02d:%02d.%03d", $hour, $min, $sec, $mili);
return $ret;
}
public static function SCORMTimeAdd($time1, $time2)
{
return self::SCORMTimeFormat(self::SCORMTimeParser($time1)+self::SCORMTimeParser($time2));
}
public static function SCORMTimeDiff($time1, $time2)
{
return self::SCORMTimeFormat(abs(self::SCORMTimeParser($time1)-self::SCORMTimeParser($time2)));
}
}
這是一個計算特殊時間格式的工具,可以用SCORMTimeParser把時間轉成毫秒,或是用SCORMTimeFormat把毫秒轉成這個時間格式。組合這兩個,就可以做時間的加減。然後寫一個簡單的測試:
<?php
require "vendor/autoload.php";
require "src/ScormTimeUtils.php";
class TestScormTimeUtils extends PHPUnit_Framework_TestCase
{
public $fixture1 = array("0000:20:21.023", "0002:23:20.324", "0002:43:41.347");
public $fixture2 = array('0000:00:23.043', 23043);
public function testScormTimeAdd() {
$this->assertEquals(ScormTimeUtils::SCORMTimeAdd($this->fixture1[0], $this->fixture1[1]), $this->fixture1[2]);
$this->assertEquals(ScormTimeUtils::SCORMTimeAdd('0000:00:00.000', '0000:00:00.000'), '0000:00:00.000');
}
public function testScormTimeDiff() {
$this->assertEquals(ScormTimeUtils::SCORMTimeDiff($this->fixture1[2], $this->fixture1[1]), $this->fixture1[0]);
}
public function testScormTimeParser() {
$this->assertEquals(ScormTimeUtils::SCORMTimeParser($this->fixture2[0]), $this->fixture2[1]);
}
/**
* @expectedException InvalidArgumentException
* @test
*/
public function doScormTimeParserException() {
ScormTimeUtils::SCORMTimeParser('0000:00:61.000');
}
public function testScormTimeFormat() {
$this->assertEquals(ScormTimeUtils::SCORMTimeFormat($this->fixture2[1]), $this->fixture2[0]);
}
}
除了使用test開頭的方法名稱,phpunit也支援annotation,例如使用@test可以讓不是test開頭的方法,也要加入測試。另外,加上expectedException,phpunit就知道至個測試應該會觸發某個指定的Exception。
測試結果:
PHPUnit 3.7.27 by Sebastian Bergmann.
Configuration read from /Users/fillano/builds/ironman6/2-3a/phpunit.xml
...F.
Time: 168 ms, Memory: 3.25Mb
There was 1 failure:
1) TestScormTimeUtils::doScormTimeParserException
Failed asserting that exception of type "InvalidArgumentsException" is thrown.
/Users/fillano/.composer/vendor/phpunit/phpunit/PHPUnit/Framework/TestSuite.php:775
/Users/fillano/.composer/vendor/phpunit/phpunit/PHPUnit/Framework/TestSuite.php:745
/Users/fillano/.composer/vendor/phpunit/phpunit/PHPUnit/Framework/TestSuite.php:705
/Users/fillano/.composer/vendor/phpunit/phpunit/PHPUnit/TextUI/Command.php:176
/Users/fillano/.composer/vendor/phpunit/phpunit/PHPUnit/TextUI/Command.php:129
FAILURES!
Tests: 5, Assertions: 6, Failures: 1.
結果測試沒通過,仔細看一下,會發現傳了不正確的參數,卻沒有觸發該有的Exception。所以回頭改一下程式:
public static function SCORMTimeParser($time)
{
$ret = 0;
$comp = explode(':', trim($time));
if (count($comp)!==3) return 0;
$hour = intval($comp[0]);
if ($hour<0) {
$hour = abs($hour);
$sign = -1;
} else {
$sign = 1;
}
$min = intval($comp[1]);
if ($min>=60) {
throw new InvalidArgumentException("Minutes over 60.");
}
$sec = floatval($comp[2]);
if ($sec >= 60.0) {
throw new InvalidArgumentException("Seconds over 60.");
}
$ret = ($sec + $min*60 + $hour*60*60)*1000;
return $ret*$sign;
}
改過以後再測試一下:
PHPUnit 3.7.27 by Sebastian Bergmann.
Configuration read from /Users/fillano/builds/ironman6/2-3a/phpunit.xml
.....
Time: 128 ms, Memory: 3.25Mb
OK (5 tests, 6 assertions)
目前程式跟測試的結果,看起來就相符了。
* 用不同的方式輸出測試結果
除了直接輸出到console,phpunit也支援多種輸出格式,例如與JUnit相同格式的xml檔,或是html檔等等。
JUnit是Java環境中最主要的單元測試框架,所以支援很完整。輸出成JUnit格式,就可以讓許多實際上並不直接支援phpunit的系統也可以吃phpunit的測試結果。
例如上面的測試,可以使用phpunit --log-junit reports/utils.xml
輸出,結果看起來就像這樣:
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="utils" tests="5" assertions="6" failures="0" errors="0" time="0.017013">
<testsuite name="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" tests="5" assertions="6" failures="0" errors="0" time="0.017013">
<testcase name="testScormTimeAdd" class="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" line="9" assertions="2" time="0.009504"/>
<testcase name="testScormTimeDiff" class="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" line="13" assertions="1" time="0.001663"/>
<testcase name="testScormTimeParser" class="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" line="16" assertions="1" time="0.001643"/>
<testcase name="doScormTimeParserException" class="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" line="23" assertions="1" time="0.002778"/>
<testcase name="testScormTimeFormat" class="TestScormTimeUtils" file="/Users/fillano/builds/ironman6/2-3a/tests/TestScormTimeUtils.php" line="26" assertions="1" time="0.001425"/>
</testsuite>
</testsuite>
</testsuites>
* 檢視測試覆蓋率
測試覆蓋率對於測試是否足夠,是一個參考的指標。只要有安裝xdebug這個zend extention,phpunit就可以利用他統計出測試所跑過的程式碼與所有程式碼的比率。透過這個數字,可以大致評估是否程式執行的所有路徑,測試都有跑到。
要檢視測試覆蓋率,加上參數就可以。例如用phpunit --coverage-text=reposts/utils.txt
來執行測試,覆蓋率的報告就會存入reports/utils.txt檔案中:
Code Coverage Report
2013-10-14 22:39:18
Summary:
Classes: 0.00% (0/13)
Methods: 1.89% (2/106)
Lines: 1.41% (32/2270)
ScormTimeUtils
Methods: 100.00% ( 4/ 4) Lines: 78.79% ( 26/ 33)
\Composer\Autoload::ClassLoader
Methods: 16.67% ( 2/12) Lines: 7.59% ( 6/ 79)
看起來他把包含phpunit的程式都一起估計了,比較有用的是關於ScormTimeUtils的估計,可以看到總共33行程式,測試有跑到的程式有26行,覆蓋率是78.79%。覺得還不夠好,所以回頭看了一下測試。ScormTimeUtils::SCORMTimeParser有兩個地方會拋出Exception,但是我只測了一個。所以再加一個測試,同時把原來的測試名稱改一下,方便辨識:
/**
* @expectedException InvalidArgumentException
* @test
*/
public function doScormTimeParserSecException() {
ScormTimeUtils::SCORMTimeParser('0000:00:61.000');
}
/**
* @expectedException InvalidArgumentException
* @test
*/
public function doScormTimeParserMinException() {
ScormTimeUtils::SCORMTimeParser('0000:62:59.000');
}
再跑一次測試覆蓋率,這次就超過80%了(只多跑了一行XD):
Code Coverage Report
2013-10-14 22:49:00
Summary:
Classes: 0.00% (0/13)
Methods: 1.89% (2/106)
Lines: 1.45% (33/2270)
ScormTimeUtils
Methods: 100.00% ( 4/ 4) Lines: 81.82% ( 27/ 33)
\Composer\Autoload::ClassLoader
Methods: 16.67% ( 2/12) Lines: 7.59% ( 6/ 79)
一般來說,測試覆蓋率最好都要超過80%,測試才比較完整。改成phpunit --coverage-html coverage
,會輸出html到coverage目錄,這樣可以看到更詳細的結果:
背景黃色的是有跑到的程式,紅色的是沒跑到的。要跑到沒有跑到的部份,就需要加更多測試來做。(這裡其實給SCORMTimeParser的小時加個負號,或是給SCORMTimeFormat的參數改成負數,就可以做到了)
* 組織test suite
有很多測試檔時,可以透過test suite來組織測試。phpunit可以直接指定目錄,然後phpunit會將目錄內所有*test.php檔案視為測試檔來跑測試。另外一個方法是利用一個test suite xml檔來指定。我在上例中其實已經使用了,大概是長這樣:
<phpunit>
<testsuites>
<testsuite name="utils">
<file>tests/TestScormTimeUtils.php</file>
</testsuite>
</testsuites>
</phpunit>
結構很簡單,<file>也可以換成目錄<directory>。檔案中可以包含多個<testsuite>,然後透過--testsuite來指定要跑哪個測試組。
========
其實要深入的話,光單元測試就可以作為一個鐵人賽題目了,今天先在這裡打住吧。