测试驱动的开发和单元测试是确保代码在经过修改和重大调整之后依然能如我们期望的一样工作的最新方法。在本文中,您将学习到如何在模块、数据库和用户界面(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 ) == 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)FAILURES!!!Tests run: 2, Failures: 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{ 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
<?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)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
<?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 ) { $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 存在,测试就可以通过。
与前面一样,首先编写测试用例,然后创建一个失败版本的页面。我们对失败情况进行测试,然后修改页面的内容使其可以工作。结果如下:
清单 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 点。