測試驅動的開發和單元測試是確保程式碼在經過修改和重大調整之後仍然能如我們期望的一樣運作的最新方法。在本文中,您將學習如何在模組、資料庫和使用者介面(UI)層對自己的PHP 程式碼進行單元測試。
現在是凌晨3 點。我們怎麼知道自己的程式碼仍在工作呢?
Web 應用程式是24x7 不間斷運行的,因此我的程式是否還在運行這個問題會在晚上一直困擾我。單元測試已經幫我對自己的程式碼建立了足夠的信心—— 這樣我就可以安穩地睡個好覺了。
單元測試是一個為程式碼編寫測試案例並自動執行這些測試的框架。測試驅動的開發是一種單元測試方法,其想法是應該先編寫測試程序,並驗證這些測試可以發現錯誤,然後才開始編寫需要通過這些測試的程式碼。當所有測試都通過時,我們開發的特性也就完成了。這些單元測試的價值是我們可以隨時運行它們—— 在簽入程式碼之前,重大修改之後,或者部署到正在運行的系統之後都可以。
PHP 單元測試
對於PHP 來說,單元測試框架是PHPUnit2。可以使用PEAR 命令列作為一個PEAR 模組來安裝這個系統:% pear install PHPUnit2。
在安裝這個框架之後,可以透過建立派生於PHPUnit2_Framework_TestCase 的測試類別來編寫單元測試。
模組單元測試
我發現開始單元測試最好的地方是在應用程式的業務邏輯模組中。我使用了一個簡單的例子:這是一個對兩個數字進行求和的函數。為了開始測試,我們首先編寫測試案例,如下所示。
清單1. TestAdd.php
<?phprequire_once 'Add.php';require_once 'PHPUnit2/Framework/TestCase.php';class TestAdd extends PHPUnit2_Framework_TestCase{ function test1() { $==this->assertTrue( add( 1, 2 ) { $==this->assertTrue( add( 1, 2 ) ) 3 ); } function test2() { $this->assertTrue( add( 1, 1 ) == 2 ); }}?>
這個TestAdd 類別有兩個方法,都使用了test 前綴。每個方法都定義了一個測試,這個測試可以跟清單1 一樣簡單,也可以十分複雜。在本例中,我們在第一個測試中只是簡單地斷定1 加2 等於3,在第二個測試中是1 加1 等於2。
PHPUnit2 系統定義了assertTrue() 方法,它用來測試參數中包含的條件值是否為真。然後,我們又寫了Add.php 模組,最初讓它產生錯誤的結果。
清單2. Add.php
<?phpfunction add( $a, $b ) { return 0; }?>
現在執行單元測試時,這兩個測試都會失敗。
清單3. 測試失敗
% phpunit TestAdd.phpPHPUnit 2.2.1 by Sebastian Bergmann.FFTime: 0.0031270980834961There were 2 failures:1) test1(TestAdd)2) test2(TestAdd), 2. Errors: 0, Incomplete Tests: 0.
現在我知道這兩個測試都可以正常運作了。因此,可以修改add() 函數來真正做實際的事情了。
現在這兩個測試都可以通過了。
<?phpfunction add( $a, $b ) { return $a+$b; }?>
清單4. 測試通過
% phpunit TestAdd.phpPHPUnit 2.2.1 by Sebastian Bergmann...Time: 0.0023679733276367OK (2 tests)%
儘管這個測試驅動開發的例子非常簡單,但是我們可以從中體會到它的想法。我們首先創建了測試案例,並且有足夠的程式碼讓這個測試運行起來,不過結果是錯誤的。然後我們驗證測試的確是失敗的,接著實作了實際的程式碼使這個測試能夠通過。
我發現在實作程式碼時我會一直不斷地添加程式碼,直到我擁有一個覆蓋所有程式碼路徑的完整測試為止。在本文的最後,您將看到有關編寫什麼測試和如何編寫這些測試的一些建議。
資料庫測試
在進行模組測試之後,就可以進行資料庫存取測試了。資料庫存取測試帶來了兩個有趣的問題。首先,我們必須在每次測試之前將資料庫還原到某個已知點。其次,要注意這種復原可能會對現有資料庫造成破壞,因此我們必須對非生產資料庫進行測試,或在編寫測試案例時注意不能影響現有資料庫的內容。
資料庫的單元測試是從資料庫開始的。為了闡述這個問題,我們需要使用下面的簡單模式。
清單5. Schema.sql
DROP TABLE IF EXISTS authors;CREATE TABLE authors ( id MEDIUMINT NOT NULL AUTO_INCREMENT, name TEXT NOT NULL, PRIMARY KEY ( id ));
清單5 是一個authors 表,每筆記錄都有相關的ID 。
接下來,就可以編寫測試案例了。
清單6. TestAuthors.php
<?phprequire_once 'dblib.php';require_once 'PHPUnit2/Framework/TestCase.php';class TestAuthors extends PHPUnit2_Framework_TestCase{::Tunction test_delete_all() { $this-this- >t.TestCase{dunction test_delete_all() { $this-this- >t.F5(ete); } function test_insert() { $this->assertTrue( Authors::delete_all() ); $this->assertTrue( Authors::insert( 'Jack' ) ); } function test_insert_and_get() { $this-> ::delete_all() ); $this->assertTrue( Authors::insert( 'Jack' ) ); $this->assertTrue( Authors::insert( 'Joe' ) ); $found = Authors::get_all() ; $this->assertTrue( $found != null ); $this->assertTrue( count( $found ) == 2 ); }}?>
這組測試涵蓋了從表中刪除作者、向表中插入作者以及在驗證作者是否存在的同時插入作者等功能。這是一個累加的測試,我發現對於尋找錯誤來說這非常有用。觀察哪些測試可以正常運作,而哪些測試不能正常運作,就可以快速地找出哪些地方出錯了,然後就可以進一步理解它們之間的差異。
最初產生失敗的dblib.php PHP 資料庫存取程式碼版本如下所示。
清單7. dblib.php
<?phprequire_once('DB.php');class Authors{ public static function get_db() { $dsn = 'mysql://root:password@localhost/unitdb'; $db =& DB: :Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } return $db; } public static function delete_all() { return false; } public static function insert( $name ) { return false; } public static function get_all() { return null; }}?>
對清單8 中的程式碼執行單元測試會顯示這3 個測試全部失敗了:
清單8. dblib.php
% phpunit TestAuthors.phpPHPUnit 2.2.1 by Sebastian Bergmann.FFFTime: 0.007500171661377There were 3 failures:1) test_delete_all(TestAuthors)test2) test. Tests run: 3, Failures: 3, Errors: 0, Incomplete Tests: 0.%
現在我們可以開始添加正確存取資料庫的程式碼—— 一個方法一個方法地添加—— 直到所有這3 個測試都可以通過。最終版本的dblib.php 程式碼如下所示。
清單9. 完整的dblib.php
<?phprequire_once('DB.php');class Authors{ public static function get_db() { $dsn = 'mysql://root:password@localhost/unitdb'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } return $db; } public static function delete_all() { $ db = Authors::get_db(); $sth = $db->prepare( 'DELETE FROM authors' ); $db->execute( $sth ); return true; } public static function insert( $name ) { $db = Authors::get_db(); $sth = $db->prepare( 'INSERT INTO authors VALUES (null,?)' ); $db->execute( $sth, array( $name ) ); return true; } public static function get_all() { $db = Authors::get_db(); $res = $db->query( "SELECT * FROM authors" ); $rows = array(); while( $res->fetchInto( $ row ) ) { $rows []= $row; } return $rows; }}?>
HTML 測試
對整個PHP 應用程式進行測試的下一個步驟是對前端的超文本標記語言(HTML)介面進行測試。要進行這種測試,我們需要一個如下所示的Web 頁面。
清單10. TestPage.php
<?phprequire_once 'HTTP/Client.php';require_once 'PHPUnit2/Framework/TestCase.php';class TestPage extends PHPUnit2_Framework_TestCase{ function get_page( $url ) { $cliient = newwork_TestCase{ function get_page( $url ) { $client = new HTTP_Clent); ->get( $url ); $resp = $client->currentResponse(); return $resp['body']; } function test_get() { $page = TestPage::get_page( 'http://localhost/unit /add.php' ); $this->assertTrue( strlen( $page ) > 0 ); $this->assertTrue( preg_match( '/<html>/', $page ) == 1 ); } function test_add( ) { $page = TestPage::get_page( 'http://localhost/unit/add.php?a=10&b=20' ); $this->assertTrue( strlen( $page ) > 0 ); $this-> assertTrue( preg_match( '/<html>/', $page ) == 1 ); preg_match( '/<span id="result">(.*?)</span>/', $page, $out ); $this->assertTrue( $out[1]=='30' ); }}?>
這個測試使用了PEAR 提供的HTTP Client 模組。我發現它比內嵌的PHP Client URL Library(CURL)更簡單一點兒,不過也可以使用後者。
有一個測試會檢查所回傳的頁面,並判斷這個頁面是否包含HTML。第二個測試會透過將值放到請求的URL 中來請求計算10 和20 的和,然後檢查傳回的頁面中的結果。
這個頁面的程式碼如下所示。
清單11. TestPage.php
<html><body><form><input type="text" name="a" value="<?php echo($_REQUEST['a']); ?>" /> + <input type="text" name="b" value="<?php echo($_REQUEST['b']); ?>" /> =<span id="result"><?php echo($_REQUEST ['a']+$_REQUEST['b']); ?></span><br/><input type="submit" value="Add" /></form></body></html >
這個頁面相當簡單。兩個輸入域顯示了請求中提供的目前值。結果span 顯示了這兩個值的和。 標記標示了所有區別:它對使用者來說是不可見的,但是對於單元測試來說卻是可見的。因此單元測試並不需要複雜的邏輯來找出這個值。相反,它會檢索一個特定標記的值。這樣當介面發生變化時,只要span 存在,測試就可以通過。
與前面一樣,首先編寫測試案例,然後建立一個失敗版本的頁面。我們對失敗情況進行測試,然後修改頁面的內容使其可以運作。結果如下:
清單12. 測試失敗情況,然後修改頁面
% phpunit TestPage.phpPHPUnit 2.2.1 by Sebastian Bergmann...Time: 0.25711488723755OK (2 tests)%
這兩個測試都可以通過,這就意味著測試程式碼可以正常工作。
在對這段程式碼執行測試時,所有的測試都可以沒有問題地運行,這樣我們就可以知道自己的程式碼可以正確運作了。
不過對HTML 前端的測試有一個缺陷:JavaScript。超文本傳輸協定(HTTP)客戶機程式碼對頁面進行檢索,但是卻沒有執行JavaScript。因此如果我們在JavaScript 中有很多程式碼,就必須建立使用者代理程式層級的單元測試。我發現實現此功能的最佳方法是使用Microsoft? Internet Explorer? 內嵌的自動化層功能。透過使用PHP 編寫的Microsoft Windows? 腳本,可以使用元件物件模型(COM)介面來控制Internet Explorer,讓它在頁面之間進行導航,然後使用文件物件模型(DOM)方法在執行特定使用者操作後尋找頁面中的元素。
這是我了解的前端JavaScript 程式碼進行單元測試的惟一一種方法。我承認它並不容易編寫和維護,這些測試即使在對頁面稍微進行改動時也很容易遭到破壞。
編寫哪些測試以及如何編寫這些測試
在編寫測試時,我喜歡覆蓋以下情況:
所有正面測試
這組測試可以確保所有的東西都如我們期望的一樣工作。
所有負面測試都
逐一使用這些測試,從而確保每個失效或異常情況都被測試到了。
正面序列測試
這組測試可以確保按照正確順序的呼叫可以像我們期望的一樣運作。
負面序列測試
這組測試可以確保當沒有正確順序進行呼叫時就會失敗。
負載測試
在適當情況下,可以執行一組測試來確定這些測試的性能在我們期望的範圍之內。例如,2,000 次呼叫應該在2 秒之內完成。
資源測試
這些測試確保應用程式介面(API)可以正確地分配並釋放資源- 例如,連續幾次呼叫開啟、寫入以及關閉基於檔案的API,從而確保沒有檔案仍然是被開啟的。
回調測試
對於具有回調方法的API 來說,這些測試可以確保如果沒有定義回調函數,程式碼可以正常運作。另外,這些測試還可以確保在定義了回呼函數但是這些回呼函數操作有誤或產生異常時,程式碼依然可以正常運作。
這是有關單元測試的幾點想法。有關如何編寫單元測試,我也有幾點建議:
不要使用隨機資料
儘管在一個介面中產生隨機資料看起來貌似一個好主意,但是我們要避免這樣做,因為這些資料會變得非常難以調試。如果資料是在每次呼叫時隨機產生的,那麼就可能產生一次測試時出現了錯誤而另外一次測試卻沒有出現錯誤的情況。如果測試需要隨機數據,可以在一個檔案中產生這些數據,然後每次運行時都使用這個檔案。採用這種方法,我們就獲得了一些「噪音」 數據,但是仍然可以對錯誤進行調試。
分組測試
我們很容易累積數千個測試,需要幾個小時才能執行完。這沒什麼問題,但是對這些測試進行分組使我們可以快速運行某組測試並對主要關注的問題進行檢查,然後晚上運行完整的測試。
編寫穩健的API 和穩健的測試
編寫API 和測試時要注意它們不能在增加新功能或修改現有功能時很容易就會崩潰,這一點非常重要。這裡沒有通用的絕招,但是有一條準則是那些「振蕩的」 測試(一會兒失敗,一會兒成功,反復不停的測試)應該很快地丟棄。
結束語
單元測試對工程師來說意義重大。它們是敏捷開發過程(這個過程非常強調編碼的作用,因為文件需要一些證據證明程式碼是按照規範進行工作的)的一個基礎。單元測試就提供了這種證據。這個過程從單元測試開始入手,這定義了程式碼應該實現但目前尚未實現的功能。因此,所有的測試最初都會失敗。然後當程式碼接近完成時,測試就通過了。當所有測試全部通過時,程式碼也就變得非常完善了。
我從來沒有在不使用單元測試的情況下編寫大型程式碼或修改大型或複雜的程式碼區塊。我通常都是在修改程式碼之前就為現有程式碼編寫了單元測試,這樣可以確保自己清楚在修改程式碼時破壞了什麼(或沒有破壞什麼)。這為我對自己提供給客戶的程式碼提供了很大的信心,相信它們正在正確運行—— 即使是在凌晨3 點。