測試驅動的開發和單元測試是確保代碼在經過修改和重大調整之後依然能如我們期望的一樣工作的最新方法。在本文中,您將學習到如何在模組、資料庫和使用者介面(UI)層對自己的 PHP 代碼進行單元測試。
現在是淩晨 3 點。我們怎樣才能知道自己的代碼依然在工作呢?
Web 應用程式是 24x7 不間斷啟動並執行,因此我的程式是否還在運行這個問題會在晚上一直困擾我。單元測試已經幫我對自己的代碼建立了足夠的信心 —— 這樣我就可以安穩地睡個好覺了。
單元測試 是一個為代碼編寫測試案例並自動運行這些測試的架構。測試驅動的開發 是一種單元測試方法,其思想是應該首先編寫測試程式,並驗證這些測試可以發現錯誤,然後才開始編寫需要通過這些測試的代碼。當所有測試都通過時,我們開發的特性也就完成了。這些單元測試的價值是我們可以隨時運行它們 —— 在簽入代碼之前,重大修改之後,或者部署到正在啟動並執行系統之後都可以。
PHP 單元測試
對於 PHP 來說,單元測試架構是 PHPUnit2。可以使用 PEAR 命令列作為一個 PEAR 模組來安裝這個系統:% pear install PHPUnit2。
在安裝這個架構之後,可以通過建立派生於 PHPUnit2_Framework_TestCase 的測試類別來編寫單元測試。
模組單元測試
我發現開始單元測試最好的地方是在應用程式的商務邏輯模組中。我使用了一個簡單的例子:這是一個對兩個數字進行求和的函數。為了開始測試,我們首先編寫測試案例,如下所示。
清單 1. TestAdd.php
<?php
require_once 'Add.php';
require_once 'PHPUnit2/Framework/TestCase.php';
class TestAdd extends PHPUnit2_Framework_TestCase
{
function test1() { $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
<?php
function add( $a, $b ) { return 0; }
?>
現在運行單元測試時,這兩個測試都會失敗。
清單 3. 測試失敗
% phpunit TestAdd.php
PHPUnit 2.2.1 by Sebastian Bergmann.
FF
Time: 0.0031270980834961
There were 2 failures:
1) test1(TestAdd)
2) test2(TestAdd)
FAILURES!!!
Tests run: 2, Failures: 2, Errors: 0, Incomplete Tests: 0.
現在我知道這兩個測試都可以正常工作了。因此,可以修改 add() 函數來真正地做實際的事情了。
<?php
function add( $a, $b ) { return $a+$b; }
?>
現在這兩個測試都可以通過了。
清單 4. 測試通過
% phpunit TestAdd.php
PHPUnit 2.2.1 by Sebastian Bergmann.
..
Time: 0.0023679733276367
OK (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
<?php
require_once 'dblib.php';
require_once 'PHPUnit2/Framework/TestCase.php';
class TestAuthors extends PHPUnit2_Framework_TestCase
{
function test_delete_all() {
$this->assertTrue( Authors::delete_all() );
}
function test_insert() {
$this->assertTrue( Authors::delete_all() );
$this->assertTrue( Authors::insert( 'Jack' ) );
}
function test_insert_and_get() {
$this->assertTrue( Authors::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
<?php
require_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.php
PHPUnit 2.2.1 by Sebastian Bergmann.
FFF
Time: 0.007500171661377
There were 3 failures:
1) test_delete_all(TestAuthors)
2) test_insert(TestAuthors)
3) test_insert_and_get(TestAuthors)
FAILURES!!!
Tests run: 3, Failures: 3, Errors: 0, Incomplete Tests: 0.
%
現在我們可以開始添加正確訪問資料庫的代碼 —— 一個方法一個方法地添加 —— 直到所有這 3 個測試都可以通過。最終版本的 dblib.php 代碼如下所示。
清單 9. 完整的 dblib.php
<?php
require_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)(HTML)介面進行測試。要進行這種測試,我們需要一個如下所示的 Web 頁面。
這個頁面對兩個數字進行求和。為了對這個頁面進行測試,我們首先從單元測試代碼開始入手。
清單 10. TestPage.php
<?php
require_once 'HTTP/Client.php';
require_once 'PHPUnit2/Framework/TestCase.php';
class TestPage extends PHPUnit2_Framework_TestCase
{
function get_page( $url )
{
$client = new HTTP_Client();
$client->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> 標記標出了所有區別:它對於使用者來說是不可見的,但是對於單元測試來說卻是可見的。因此單元測試並不需要複雜的邏輯來找到這個值。相反,它會檢索一個特定 <span> 標記的值。這樣當介面發生變化時,只要 span 存在,測試就可以通過。
與前面一樣,首先編寫測試案例,然後建立一個失敗版本的頁面。我們對失敗情況進行測試,然後修改頁面的內容使其可以工作。結果如下:
清單 12. 測試失敗情況,然後修改頁面
% phpunit TestPage.php
PHPUnit 2.2.1 by Sebastian Bergmann.
..
Time: 0.25711488723755
OK (2 tests)
%
這兩個測試都可以通過,這就意味著測試代碼可以正常工作。
不過對 HTML 前端的測試有一個缺陷:JavaScript。超文字傳輸通訊協定 (HTTP)(HTTP)客戶機代碼對頁面進行檢索,但是卻沒有執行 JavaScript。因此如果我們在 JavaScript 中有很多代碼,就必須建立使用者代理程式級的單元測試。我發現實現這種功能的最佳方法是使用 Microsoft® Internet Explorer® 內嵌的自動化層功能。通過使用 PHP 編寫的 Microsoft Windows® 指令碼,可以使用元件物件模型(COM)介面來控制 Internet Explorer,讓它在頁面之間進行導航,然後使用文件物件模型(DOM)方法在執行特定使用者操作之後尋找頁面中的元素。
這是我瞭解的對前端 JavaScript 代碼進行單元測試的惟一一種方法。我承認它並不容易編寫和維護,這些測試即使在對頁面稍微進行改動時也很容易遭到破壞。
編寫哪些測試以及如何編寫這些測試
在編寫測試時,我喜歡覆蓋以下情況:
所有正面測試
這組測試可以確保所有的東西都如我們期望的一樣工作。
所有負面測試
逐一使用這些測試,從而確保每個失效或異常情況都被測試到了。
正面序列測試
這組測試可以確保按照正確順序的調用可以像我們期望的一樣工作。
負面序列測試
這組測試可以確保當不按正確順序進行調用時就會失敗。
負載測試
在適當情況下,可以執行一小組測試來確定這些測試的效能在我們期望的範圍之內。例如,2,000 次調用應該在 2 秒之內完成。
資源測試
這些測試確保應用編程介面(API)可以正確地分配並釋放資源 —— 例如,連續幾次調用開啟、寫入以及關閉基於檔案的 API,從而確保沒有檔案依然是被開啟的。
回調測試
對於具有回調方法的 API 來說,這些測試可以確保如果沒有定義回呼函數,代碼可以正常運行。另外,這些測試還可以確保在定義了回呼函數但是這些回呼函數操作有誤或產生異常時,代碼依然可以正常運行。
這是有關單元測試的幾點想法。有關如何編寫單元測試,我也有幾點建議:
不要使用隨機資料
儘管在一個介面中產生隨機資料看起來貌似一個好主意,但是我們要避免這樣做,因為這些資料會變得非常難以調試。如果資料是在每次調用時隨機產生的,那麼就可能產生一次測試時出現了錯誤而另外一次測試卻沒有出現錯誤的情況。如果測試需要隨機資料,可以在一個檔案中產生這些資料,然後每次運行時都使用這個檔案。採用這種方法,我們就獲得了一些 “噪音” 資料,但是仍然可以對錯誤進行調試。
分組測試
我們很容易累積起數千個測試,需要幾個小時才能執行完。這沒什麼問題,但是對這些測試進行分組使我們可以快速運行某組測試並對主要關注的問題進行檢查,然後晚上運行完整的測試。
編寫穩健的 API 和穩健的測試
編寫 API 和測試時要注意它們不能在增加新功能或修改現有功能時很容易就會崩潰,這一點非常重要。這裡沒有通用的絕招,但是有一條準則是那些 “振蕩的” 測試(一會兒失敗,一會兒成功,反覆不停的測試)應該很快地丟棄。
結束語
單元測試對於工程師來說意義重大。它們是敏捷開發過程(這個過程非常強調編碼的作用,因為文檔需要一些證據證明代碼是按照規範進行工作的)的一個基礎。單元測試就提供了這種證據。這個過程從單元測試開始入手,這定義了代碼應該 實現但目前尚未 實現的功能。因此,所有的測試最初都會失敗。然後當代碼接近完成時,測試就通過了。當所有測試全部通過時,代碼也就變得非常完善了。
我從來沒有在不使用單元測試的情況下編寫大型代碼或修改大型或複雜的代碼塊。我通常都是在修改代碼之前就為現有代碼編寫了單元測試,這樣可以確保自己清楚在修改代碼時破壞了什麼(或者沒有破壞什麼)。這為我對自己提供給客戶的代碼提供了很大的信心,相信它們正在正確運行 —— 即便是在淩晨 3 點。