测试驱动的开发和单元测试是确保代码在经过修改和重大调整之后依然能如我们期望的一样工作的最新方法。在本文中,您将学习到如何在模块、数据库和用户界面(UI)层对自己的 PHP 代码进行单元测试。
现在是凌晨 3 点。我们怎样才能知道自己的代码依然在工作呢?
Web 应用程序是 24x7 不间断运行的,因此我的程序是否还在运行这个问题会在晚上一直困扰我。单元测试已经帮我对自己的代码建立了足够的信心 —— 这样我就可以安稳地睡个好觉了。
单元测试 是一个为代码编写测试用例并自动运行这些测试的框架。测试驱动的开发 是一种单元测试方法,其思想是应该首先编写测试程序,并验证这些测试可以发现错误,然后才开始编写需要通过这些测试的代码。当所有测试都通过时,我们开发的特性也就完成了。这些单元测试的价值是我们可以随时运行它们 —— 在签入代码之前,重大修改之后,或者部署到正在运行的系统之后都可以。
PHP 单元测试
对于 PHP 来说,单元测试框架是 PHPUnit2。可以使用 PEAR 命令行作为一个 PEAR 模块来安装这个系统:% pear install PHPUnit2。
在安装这个框架之后,可以通过创建派生于 PHPUnit2_Framework_TestCase 的测试类来编写单元测试。
模块单元测试
我发现开始单元测试最好的地方是在应用程序的业务逻辑模块中。我使用了一个简单的例子:这是一个对两个数字进行求和的函数。为了开始测试,我们首先编写测试用例,如下所示。
清单 1. TestAdd.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
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() 函数来真正地做实际的事情了。
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
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
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
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)界面进行测试。要进行这种测试,我们需要一个如下所示的 Web 页面。
这个页面对两个数字进行求和。为了对这个页面进行测试,我们首先从单元测试代码开始入手。
清单 10. TestPage.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( '//', $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( '//', $page ) == 1 );
preg_match( '/(.*?)<\/span>/', $page, $out );
$this->assertTrue( $out[1]=='30' );
}
}
?>
这个测试使用了 PEAR 提供的 HTTP Client 模块。我发现它比内嵌的 PHP Client URL Library(CURL)更简单一点儿,不过也可以使用后者。
有一个测试会检查所返回的页面,并判断这个页面是否包含 HTML。第二个测试会通过将值放到请求的 URL 中来请求计算 10 和 20 的和,然后检查返回的页面中的结果。
这个页面的代码如下所示。
清单 11. TestPage.php
这个页面相当简单。两个输入域显示了请求中提供的当前值。结果 span 显示了这两个值的和。 标记标出了所有区别:它对于用户来说是不可见的,但是对于单元测试来说却是可见的。因此单元测试并不需要复杂的逻辑来找到这个值。相反,它会检索一个特定 标记的值。这样当界面发生变化时,只要 span 存在,测试就可以通过。
与前面一样,首先编写测试用例,然后创建一个失败版本的页面。我们对失败情况进行测试,然后修改页面的内容使其可以工作。结果如下:
清单 12. 测试失败情况,然后修改页面
% phpunit TestPage.php
PHPUnit 2.2.1 by Sebastian Bergmann.
..
Time: 0.25711488723755
OK (2 tests)
%
这两个测试都可以通过,这就意味着测试代码可以正常工作。
不过对 HTML 前端的测试有一个缺陷:JavaScript。超文本传输协议(HTTP)客户机代码对页面进行检索,但是却没有执行 JavaScript。因此如果我们在 JavaScript 中有很多代码,就必须创建用户代理级的单元测试。我发现实现这种功能的最佳方法是使用 Microsoft® Internet Explorer® 内嵌的自动化层功能。通过使用 PHP 编写的 Microsoft Windows® 脚本,可以使用组件对象模型(COM)接口来控制 Internet Explorer,让它在页面之间进行导航,然后使用文档对象模型(DOM)方法在执行特定用户操作之后查找页面中的元素。
这是我了解的对前端 JavaScript 代码进行单元测试的惟一一种方法。我承认它并不容易编写和维护,这些测试即使在对页面稍微进行改动时也很容易遭到破坏。
编写哪些测试以及如何编写这些测试
在编写测试时,我喜欢覆盖以下情况:
すべて肯定的なテスト
この一連のテストは、すべてが期待どおりに動作していることを確認します。
すべての陰性テスト
これらのテストを 1 つずつ使用して、すべての障害または異常がテストされていることを確認します。
ポジティブ シーケンス テスト
この一連のテストは、正しい順序での呼び出しが期待どおりに動作することを確認します。
ネガティブ シーケンス テスト
この一連のテストは、呼び出しが正しい順序で行われない場合に失敗することを確認します。
負荷テスト
必要に応じて、少数のテストを実行して、これらのテストのパフォーマンスが想定内であるかどうかを判断できます。たとえば、2,000 件の呼び出しは 2 秒以内に完了する必要があります。
リソース テスト
これらのテストでは、アプリケーション プログラミング インターフェイス (API) がリソースを正しく割り当て、解放できることを確認します。たとえば、ファイルベースの API を開いたり、書き込んだり、閉じたりするための複数の連続呼び出しにより、まだファイルが残っていないことを確認します。開いて使用されています。
コールバック テスト
コールバック メソッドを備えた API の場合、これらのテストは、コールバック関数が定義されていない場合でもコードが正しく実行されることを確認します。さらに、これらのテストでは、コールバック関数が定義されていても、そのコールバック関数が正しく動作しない場合や例外が生成された場合でも、コードが正常に実行できることを確認することもできます。
ここでは単体テストについていくつか考えます。また、単体テストの作成方法についていくつかの提案があります:
ランダム データを使用しない
インターフェイスでランダム データを生成するのは良いアイデアのように思えるかもしれませんが、次のような理由からそれは避けたいと考えています。データのデバッグが非常に困難になります。データが呼び出しごとにランダムに生成される場合、あるテストではエラーが発生しても、別のテストではエラーが発生しない可能性があります。テストにランダム データが必要な場合は、ランダム データをファイルに生成し、実行するたびにそのファイルを使用できます。このアプローチでは、「ノイズの多い」データが得られますが、それでもエラーをデバッグできます。
グループ テスト
実行するには数時間かかるテストを何千件も簡単に蓄積できます。それ自体には何も問題はありませんが、これらのテストをグループ化すると、一連のテストをすばやく実行して重大な懸念事項を確認し、その後夜間にテスト全体を実行することができます。
堅牢な API と堅牢なテストを作成する
新しい機能を追加したり、既存の機能を変更したりするときに、簡単に壊れないように API とテストを作成することが重要です。ここに万能の特効薬はありませんが、経験則として、「不安定」なテスト (ある瞬間には失敗し、別の瞬間に成功することを何度も繰り返す) はすぐに破棄する必要があります。
結論
単体テストはエンジニアにとって非常に重要です。これらはアジャイル開発プロセスの基盤です (ドキュメント化にはコードが仕様に従って動作しているという証拠が必要であるため、コーディングに重点が置かれます)。単体テストはこの証拠を提供します。このプロセスは単体テストから始まります。単体テストでは、コードが実装する必要があるが現在は実装されていない機能を定義します。したがって、最初はすべてのテストが失敗します。コードがほぼ完成すると、テストに合格します。すべてのテストに合格すると、コードは非常に完成したものになります。
私は単体テストを使用せずに大規模なコードを書いたり、大規模または複雑なコード ブロックを変更したりしたことがありません。私は通常、既存のコードを変更する前に単体テストを作成します。これは、コードを変更するときに何が壊れているか (または壊れていないか) を確認するためです。これにより、たとえ午前 3 時であっても、クライアントに提供するコードが正しく実行されているという大きな自信が得られます。