2006年6月2日 星期五

進階PHP程式設計-單元測試



  • Why Unit Test(單元測試)?


在本篇文章,要來討論的是Unit Test。
由於文章中會談到許多物件導向分析與設計,軟體工程的觀念,比較適合正在參與或是實做中大型專案的程式設計師閱讀。



大家在軟體工程的教科書上,教導傳統軟工的Water Fall Model(瀑布模型)開發時,都會講到Testing。Testing就是說依照Water Fall Model的開發流程,在你的系統實做完成後,應該針對系統的各個元件—應該說是「單元」(Unit),來進行測試。這樣的測試通常不是由原本撰寫系統的設計師所完成,而是應該請專門的Unit Test員(Unit Tester)撰寫測試計畫後,再實做測試程式碼來完成。而Unit Test完成後,更要進行Integration Test(整合測試)。程式碼實做,應該是在下圖的Implementation,而Unit Test就是屬於Verification的範圍。


在台灣,軟體工業一路走來真是十分艱辛,軟體公司也大概分做兩種,一種是維持傳統Water Fall Model的作法,多被大企業大公司所接受,因為員工多可以各司其責。如果軟體作業流程有上軌道的話(例如說通過ISO或CMMI認證),軟體也會有相當的品質。但這些大公司通常會著重在很大很長久的專案,或是一套很貴的軟體,Unit Test被視作必須的,而且是有專門的部門,用昂貴的Batch Unit Test軟體在進行。

另外一種就是小公司的作法,因為作法很多元化,我先將其概括,並稱做Extreme Programming(XP)模式。在XP模式中,每個程式設計師(員工)都是相當強的,這在台灣的多數中小型資訊公司也可見。通常設計師本身,除了會多種語言,開發環境,設計樣式(design pattern)...等等,十八般武藝樣樣精通,還要瞭解怎樣維護系統。而且從接到專案,實做到完工交差這樣的開發週期,可能就不是像傳統大公司有那樣多的時間。

因此,Unit Test以往以來被許多設計師視作龐大工程的其中一個分支,在撰寫專案或是應用程式的時候,由於不可能採用Water Fall Model,加上短少的專案時間,便不需要考慮再寫額外的測試碼來增加成本。但XP模式在世界各地慢慢發展,某些設計師意識到說,儘管大家都很會寫程式,但是卻無法跟大公司一樣保證軟體產出的品質(因此可能生意不好)。小公司不可能進行傳統Water Fall Model,更不可能進行CMMI的文件流程,管理來證明軟體的品質,那小公司該怎麼辦呢?於是有了後來的Agile Programming Model,Agile是敏捷的意思,強調有別於以往龐大耗時的傳統軟工模式。在小公司裡人本來就不多,因此跟客戶談好需求後,也不用寫什麼文件了,馬上就開始寫起程式來。一邊和客戶及時調整需求,一邊釋出程式的版本,一邊撰寫測試碼來保證程式的品質,如此不斷地重複以便讓客戶得到他們心中真正想要的結果。

在這種情況之下,Unit Test就相當重要了。

我從另一個角度,舉幾個設計師常碰到的問題:
1. 你對程式的類別或方法進行了更改名稱這樣子的重構,卻發現更改名稱後,很多與它相關的類別都不能使用了,卻無法有效地全部找到究竟是還有哪個類別出現問題,因此又只好看到一個再修一個。
Ans:Unit Test讓你將整個程式碼都跑過一遍,不管你的程式碼再怎樣改,只要測試正確,基本上就不會有太大問題,更方便的是在測試的過程中,你可以找到所有因為上述問題產生的bug。

2. 你設計了一個很好的函示庫想要給很多人用,當說明文件在撰寫的時候,卻發現還得構思範例程式碼,否則無法好好地說明怎樣使用。
Ans:Unit Test是一種Test Driven Programming,就是在實做程式以前,先思考程式的功能,以功能的方向去測試你的程式。也就是說,當你在撰寫測試程式碼,其實你同時也在寫一個很好的範例告訴別人你的程式怎樣用,否則就表示你的測試程式碼沒有好好寫,沒有每個功能都測到囉!

3. 你在一個團隊開發的環境中,當一個使用到資料庫系統的應用程式完成後,必須準備一個測試資料庫,灌好測試資料。可是當大家一起在測試的時候,你會發現你的測試經常與其他人有衝突,例如說你要測試delete,結果卻刪了別人要測試select的資料,弄了一陣子,就得讓管資料庫的人重新將資料灌回去。
Ans:Unit Test的Mock Object,是讓設計師撰寫一個只會回傳值的「假物件」,因此可以拿來當作測試資料使用。此外,如果你要測試的類別必須從資料庫裡抓取資料,你應該設計Data Provider Interface,並且將資料庫當作其中的一個實做。一方面這個介面就可以拿來撰寫Mock Object,另一方面是你的設計自然就讓整個系統的相依性降低。

  • How Unit Test?


這或許會對許多設計師都感到厭煩(其實我一開始也是...@@),可能自己的專案都寫的要死了,哪有時間再寫啥測試程式碼?
我等會用一個例子慢慢解釋Test Driven Programming,以這個例子來帶入Unit Test。

舉個例子,例如說我要寫一個老鼠走迷宮,我會這樣想:
mouse1.png

這個時候你會很清楚地知道老鼠應該要有一個走的功能,而迷宮應該應該要有使用老鼠來初始化的功能,以及顯示的功能。

整個程式的流程應該是:
1. 建立一隻老鼠的物件
2. 呼叫 迷宮.初始化(那隻老鼠) 來建立迷宮
3. 呼叫 老鼠.走()
3.1 老鼠應該自己判斷有沒有路,然後前進,並且觸發「老鼠走動事件」
3.2 迷宮知道老鼠走動了(觸發了走動事件處理常式),應該記錄老鼠的座標
4. 呼叫 迷宮.顯示()
4.1 結果應該印出老鼠以及迷宮的整個圖形

在這個時候,設計師心中都有底要怎樣寫了,就會開始撰寫,測試,撰寫...
然而,你測試的方法,很可能就是直接呼叫走()顯示(),根據顯示的圖,看老鼠有沒有真的改變位置了。

可是這樣子,你真的確定你的老鼠有好好地走?還是你的迷宮有好好顯示?





Unit Test最主要的目的,就是在於用嚴謹的方式驗證程式設計師撰寫好的程式,並且增強他對於自己成果的信心


所以為了測試我的老鼠和迷宮到底有沒有正常工作,我構想了以下的東西

mouse2.png

我們建立了兩個測試案例,一個是老鼠測試案例,一個是迷宮測試案例,個別其中都有一到兩個測試方法,而且這些方法的名稱都是以「測試」開頭的。每個測試案例都有「初始化測試案例」和「清除測試案例」兩個方法,這兩個方法是為了讓測試資料不會重複使用而設計的方法。

構想設計案例的時候,基本上所有類別的public method都要經過測試,儘管大部分文章都會說不包括getter或setter,如果你的getter/setter是不是單純的取值/設定值,而是有程式在裡面,也是建議一併測試。

不過我們很可能必須測試其他重要的東西,測試方法是可以根據需求而加的。我必須知道老鼠是不是真的撞到牆壁之後會乖乖後退,因此我建立了「測試撞到牆壁」。而迷宮的測試,同樣地也必須測試老鼠走了之後,會不會觸發迷宮更新老鼠座標的事件,因此我建立「測試老鼠走動事件」。

根據所有設計在「老鼠測試案例」裡的測試方法,我應該建立好假的測試迷宮,也就是測試資料,讓老鼠去走。但為了避免測試資料重複被不同的測試方法使用到,所有的Unit Test工具都需要你建立兩個特殊的方法setUp()tearDown(),也就是圖中的「初始化測試案例」和「清除測試案例」。一般來說你應該在setUp()裡應該寫上初始化老鼠和迷宮的程式碼,在tearDown()裡要寫上將老鼠和迷宮物件釋放的程式碼。

我們稍微整理一下,並且將他換成大家都瞭解的英文
mouse4.png

如此執行MouseTestCase的時候,會依照:
1. setUp()
2. TestMove()
3. tearDown()
4. setUp()
5. TestNoExit()
6. tearDown()
這樣的流程執行,這個流程會完全由Unit Test Framework來做自動化的處理。

  • php的Unit Test


1. 安裝phpUnit2
使用pear安裝相當簡單
在指令介面執行

pear install --alldeps phpunit2

安裝完後我打pear list顯示的畫面是這樣
mouse3.PNG

2. 撰寫class
根據剛剛的class diagram,我應該有以下程式碼
Mouse.php
[code lang="php"]
class Mouse
{
public $X;
public $Y;
public $Maze;
public function Mouse()
{
//初始化老鼠
...
}
public function Move()
{
//掃瞄Maze::Data並改變$this->X,$this->Y
...
//如果無法沒有任何的路可以走了,應該丟出Exception
if($cannotmove)
throw new Exception("I cannot move!");
//通知迷宮老鼠走動了,並告訴他現在的座標
call_user_func(array(&$this->Maze,"MouseMoveEventHandler",$this->X,$this->Y));
}
}
?>
[/code]

Maze.php
[code lang="php"]
class Maze
{
public $Data=array();
public $Mouse;
public function Maze(Mouse $mouse)
{
//初始化迷宮,並檢查$mouse,如果有問題,throw Exception
$this->Mouse=$mouse;
$this->Mouse->Maze=$this;
...
if($mousewrong)
throw new Exception("Something wrong with mouse: $mouse");
//如果沒有問題,將老鼠的初始位置寫入$Data
...
}
public function MouseMoveEventHandler()
{
//取得引數,並且更新$Data中的值
...
}
public function Diaplay()
{
//diaplay data to graphics or text
...
}
}
?>
[/code]

請注意,如果class和檔名不一樣,可能會造成phpunit指令介面程式不正常。
此外,要讓phpunit程式順利讀取,你的php程式不論如何一定都要先可以執行,不能產生任何的error。

3. 使用phpunit指令介面程式產生test case skeleton
如果安裝正常,不管在windows平台或是unix平台都應該可以執行。如果不行的話,請查查你的php裝在哪裡,應該是路徑設定不正確。
否則
phpunit --help

應該會顯示東西。

執行

phpunit --skeleton Mouse

他會讀取同一個目錄下的Mouse.php,其中class Mouse的public method宣告,來產生MouseTest.php。
產生的結果如下
[code lang="php"]
// Call MouseTest::main() if this source file is executed directly.
if (!defined("PHPUnit2_MAIN_METHOD")) {
define("PHPUnit2_MAIN_METHOD", "MouseTest::main");
}

require_once "PHPUnit2/Framework/TestCase.php";
require_once "PHPUnit2/Framework/TestSuite.php";

// You may remove the following line when all tests have been implemented.
require_once "PHPUnit2/Framework/IncompleteTestError.php";

require_once "Mouse.php";

/**
* Test class for Mouse.
* Generated by PHPUnit2_Util_Skeleton on 2006-06-02 at 09:36:40.
*/
class MouseTest extends PHPUnit2_Framework_TestCase {
/**
* Runs the test methods of this class.
*
* @access public
* @static
*/
public static function main() {
require_once "PHPUnit2/TextUI/TestRunner.php";

$suite = new PHPUnit2_Framework_TestSuite("MouseTest");
$result = PHPUnit2_TextUI_TestRunner::run($suite);
}

/**
* Sets up the fixture, for example, open a network connection.
* This method is called before a test is executed.
*
* @access protected
*/
protected function setUp() {
}

/**
* Tears down the fixture, for example, close a network connection.
* This method is called after a test is executed.
*
* @access protected
*/
protected function tearDown() {
}

/**
* @todo Implement testMove().
*/
public function testMove() {
// Remove the following line when you implement this test.
throw new PHPUnit2_Framework_IncompleteTestError;
}
}

// Call MouseTest::main() if this source file is executed directly.
if (PHPUnit2_MAIN_METHOD == "MouseTest::main") {
MouseTest::main();
}
?>
[/code]

4. 設計test case
很多人都會對測試案例有個疑問,「我怎麼知道我設計的案例有沒有完整把我的程式測試好?」
這個答案有點模糊,只能夠說「依照專案的時間或是進度盡量思考測試案例。」
本來測試案例就是一個不可能全部想出來的東西,所以測試的時候,我們可以遵循一些原則。
基本上,你的測試程式碼要能夠盡量執行過你的每一行程式。我想有經驗的設計師一定會知道自己的程式弱點在哪,關鍵的元件或類別在哪,建議你將這些類別的public method都好好測一下。
測試案例也是有pattern的,請參考這篇文章:
http://www.codeproject.com/gen/design/autp5.asp

而我在這個例子所思考的測試案例,就是給老鼠一個可以走到底的和一個不能走到底的迷宮。
這兩個迷宮在兩個測試方法中,應該會產生很多意想不到的結果。

在進行下面的程式碼範例前,你應該先看看:
http://www.phpunit.de/pocket_guide/

而phpunit完整的API參考在這裡:

http://pear.php.net/package/PHPUnit2/docs/2.3.6/

phpunit的架構大概如下:
1. 所有的測試案例必須繼承自PHPUnit2_Framework_TestCase,代表一個測試案例,因此就會繼承到許多的assertion method(假設方法)。你應該利用許多的假設方法來撰寫剛剛構思的測試案例,如果說物件的屬性,或是要檢查的任何數值,不符合你預期的結果,那就表示你的原始程式碼一定哪裡有出錯。也因此Test Driven Programming就像是你以往利用在螢幕上輸出的結果,加上眼睛去比對判斷一樣,只是現在做的更嚴謹,你是直接利用假設方法,判斷物件的任何一個狀態有沒有正確。測試案例越多,就表示你除了更多的bug,也代表你的原始程式也一定會越穩定。

2. 你可以利用PHPUnit2_Framework_TestSuite,也就是測試套件,來加入許多的測試案例。最後只要呼叫$suite->run(),測試就會自動進行。

3. 雖然Test Suite自己可以執行,不過上面的程式碼範例就使用了不同的PHPUnit2_TextUI_TestRunner來執行Test Suite,這也是一個方法。無論如何,產生出來的測試碼外框,是一個可以獨立執行的測試程式。

5. 撰寫基本的test code
在本篇文章中,實做程式不是我們的目標,因此以下的例子只會簡略地撰寫程式碼。

根據剛剛的設計,我應該加上這些程式碼。在這裡因為兩個迷宮是分開測試的,我便宣告了不同的老鼠。
[code lang="php"]
...
class MouseTest extends PHPUnit2_Framework_TestCase {
...
//加上這些部分
public $mouse1;
public $mouse2;
public $maze;
public $mazenoexit;
...
?>
[/code]
在Unit Test裡的用語,這些物件稱作為fixture,就是要拿來測試的東西。
而我將這些物件的初始化,全部都寫在setUp裡

[code lang="php"]
...
class MouseTest extends PHPUnit2_Framework_TestCase {
...
protected function setUp() {
$this->mouse1=new Mouse();
$this->mouse2=new Mouse();
$this->maze=new Maze($this->mouse1);
//初始化一般的迷宮
//接下來的程式碼應該手動修改這個$this->maze->Data,讓他成為一個可以測試的迷宮
...
$this->mazenoexit=new Maze($this->mouse2);
//初始化沒有出路的迷宮
//接下來的程式碼應該手動修改這個$this->mazenoexit->Data,讓他成為一個沒有出路的迷宮
...
}
...
?>
[/code]

而這些物件的釋放,我寫在tearDown裡
[code lang="php"]
...
class MouseTest extends PHPUnit2_Framework_TestCase {
...
protected function tearDown() {
$this->mouse1=null;
$this->mouse2=null;
$this->maze=null;
$this->mazenoexit=null;
}
...
?>
[/code]

實際上真正在進行測試的時候,你一定要相當清楚你進行測試的數值「應該」要得到怎樣的結果。
我假設呼叫mouse1->Move();之後,mouse1->X和mouse1->Y應該要是1和2。所以我加上這些程式碼:
[code lang="php"]
...
class MouseTest extends PHPUnit2_Framework_TestCase {
...
/**
* @todo Implement testMove().
*/
public function testMove() {
$this->mouse->Move();
$this->assertEquals($this->mouse1->X,1);
$this->assertEquals($this->mouse1->Y,2);
}
...
?>
[/code]

在這邊我使用了$this->assertEquals,也就是「假設相等」,這是一個繼承自PHPUnit2_Framework_Assert的方法,表示我本來預期某個變數應該要是什麼。執行測試案例的時候,如果兩個值相同,就不會有任何錯誤。如果不同,就會在錯誤訊息裡說明是在哪一行,你所使用的假設失敗了。

常用到的假設方法有下列幾種:
比較型。引數有兩個,一個是預期的,一個是實際的值:
assertEquals
assertNotEquals
assertSame
assertNotSame
assertContains
assertNotContains
assertRegExp
assertNotRegExp
assertType
assertNotType

其中Same和Equal的差別是在於Same會進行深層比較(就是===)。
assert(Not)Type指的是比較變數型態是否相同。

是否型,引數只有一個:
assertTrue
assertFalse
assertNull
assertNotNull

6. 撰寫測試Exception的test code
在測試的過程中,是否會有正確的Exception也是很重要。
[code lang="php"]
...
class MouseTest extends PHPUnit2_Framework_TestCase {
...
public function testNoExit()
{
try{
$this->mouse2->Move();
}
catch(Exception $e)
{
//你可以在測試$e的訊息是否是正確的錯誤訊息
...
return;
}
$this->fail("testNoExit() fails!");
}
...
?>
[/code]

7. 執行測試
現在這個MouseTest.php已經可以執行了,你可以直接用瀏覽器觀看是否有問題。

mouse5.PNG

另一個方式在命令提示字元可以直接執行
phphunit MouseTest.php

mouse6.PNG

  • 結論



更多閱讀
WhiteBox Testing指的就是測試的人員瞭解原始程式碼是怎樣完成的,並且撰寫測試程式碼進行測試。與其相對的就是BlackBox Testing。
http://en.wikipedia.org/wiki/White_box_testing


文章中提到,測試程式碼要盡量能夠執行到每一行你的原始程式碼。也因此測試工具可能會提供你分析測試碼和程式碼後,測試碼覆蓋程度的報告。這個稱做Code Coverage Test。
http://en.wikipedia.org/wiki/Code_coverage


你的系統加入新的功能後,你必須測試他和舊系統是否能夠整合在一起,或是你的新程式碼是否正常,稱做Regression Test(回歸測試)。
http://en.wikipedia.org/wiki/Regression_testing


各種平台的Unit Test工具
.Net:http://www.nunit.org/
asp.net:http://nunitasp.sourceforge.net/
Java:http://www.junit.org/
J2EE:http://jakarta.apache.org/cactus/
C++:http://www.gamesfromwithin.com/articles/0412/000061.html
Ruby:http://www.ruby-doc.org/stdlib/libdoc/test/unit/rdoc/index.html
Perl:http://search.cpan.org/~sburke/Test-1.25/lib/Test.pm


引用文章
http://www.javaworld.com.tw/jute/post/view?bid=33&id=18970&sty=3&keywords=unit+test
http://www.javaworld.com.tw/jute/post/view?bid=33&id=19659&sty=3&age=0&tpg=1&ppg=1#19659
http://www.javaworld.com.tw/jute/post/view?bid=33&id=19745&sty=3&age=0&tpg=1&ppg=1#19745


參考書目
http://www.phpunit.de/pocket_guide/
http://www.amazon.co.uk/exec/obidos/ASIN/0596101031/sebastianberg-21/026-9147681-0936442

結論
台灣的資訊產業在這幾年快速發展,有了微軟.Net Framework或是J2SE,好用的類別庫讓個別的設計師能夠撰寫更多的程式,整體就能提升很高的生產力。有了php這樣簡單好上手的script語言,讓很多不知道怎樣寫動態網頁的人可以花很少的時間就踏進這圈子,大幅度地提升普及度。總觀軟體,程式語言的趨向,就是好寫好用,用CPU的速度和記憶體的容量來取代以往複雜難寫的程式語法。也因此就出現很多的個人公司,或是工作室,不僅發展出台灣特有的Agile Programming Model,也證明了寫軟體不是只有大公司才能作。而在這個競爭的軟體產業局勢下,能夠讓這些小公司生存的,我認為就是Unit Test。保障軟體的品質,藉由開放原始碼,自動化的Unit Test Framework,以更少的成本寫出更穩定的產品,如此使用者也會相當滿意。

下一次,我再來介紹php的建置工具—phing。

7 則留言 :

  1. 感謝提供,資料轉載於下面網址:
    http://twpug.net/modules/smartsection/item.php?itemid=61

    回覆刪除
  2. 感覺起來好像版面有點扭曲,不然這樣好了,我在php版上發一次吧,就麻煩你搬移文章了。

    回覆刪除
  3. kiwi, do you do any freelance coding work?
    I've a php/mysql case in hand, kindly contact me at hsuanyen@hotmail.com if you are interested. The project contains web2.0 elements.

    回覆刪除
  4. No, I don't
    Thanks for reading my blog :)

    回覆刪除
  5. 嘿嘿,不错,搜索到这里来了。
    比较期待 kiwi 的 phing 之作 :)
    对 Java 的 Ant 不怎么熟悉,昨晚看了一下 phing 文档,感觉还缺乏一些基础知识的理解。

    今天搜索 phpunit 3 ,就找到你这里来了,改天再细看 :)

    回覆刪除
  6. 整理 PHP 單元測試文章時,剛好看到你的文章。
    在下日前整理了一篇 PHPUnit3 的使用文章,放個連結在這,提供大家參考。
    Working with PHPUnit3, part 2 - 撰寫測試案例

    回應關於 programming 的內容。小公司的作法,一般是土法煉鋼,未必是 XP, Agile method...
    記得提出 XP 的 Kent Beck ,其實是在累積許多大型專案的經驗後,才提出 XP 概念的。小公司接的小專案,不見得會體現 XP, Agile method 的價值。

    回覆刪除
  7. 呵呵~你說的沒錯,不過換句話說,或許是台灣軟體界特有的agile method。我想追求節省成本,價值提升應該是所有資訊產業的共同目標吧!

    回覆刪除