本文已被成都计算所《计算机应用》采用,版权属于该杂志与作者共同所有,并不同于其他在网上发表之文章,请勿擅自转载。
1. TDD
测试驱动开发(Test-Driven Development, TDD),是近年来兴起的一种软件开发方法。作为一项最佳实践,测试驱动开发在XP方法中占有重要地位。其对开发效率、代码健壮性的显著改进已被越来越多的实例项目所证实。
TDD的主要精神可以概括为“测试先行,快速反馈”。在有实际功能代码之前就开始编写测试代码,通过测试来反映设计,使得测试代码成为事实上的设计文档。一开始测试代码甚至不能通过编译,因为其要测试的功能代码还未编写。我们通过编写刚好够用的功能代码使测试尽快通过,并且没有多余的功能。然后再增加测试使其意料之中地失败,于是又产生编码的需要。一定功能完成之后对代码进行重构,然后加入新的功能。这样不断快速的“失败/通过/重构(red/green/refactor)”的循环中,以测试推动编码需求,步步为营,产生简洁易懂、可测试、健壮性强、刚好够用无多余功能的优良代码,形成测试驱动的开发模式。
2. PHP+TDD
TDD本质是一种软件工程上的开发方法,应该与具体语言环境没有依赖。但其在不同语言中的具体运用状况是不尽相同的。从语言角度来看,测试驱动开发起源于面向对象的Smalltalk社区,成型于Java语言。以Java的自动化单元测试框架JUnit的推出为标志,TDD跳出了理论和实验,广泛在实践中运用起来。从JUnit衍生出的xUnit 家族成为TDD应用中不可缺少的工具,使TDD在许多语言中的应用成为可能。本文采用的PHPUnit[注1]就是其中之一。
TDD从诞生之始就带着强烈的OO色彩。在Java/C++等完全OO语言中,为了单独隔离测试某一给定的模块(单元测试),使用了多态接口这样的OO技术来实现隔离。
Robert Koss博士与Jeff Langr在CUJ上的《Test Driven Development in C/C++》一文中,讨论了以C这样的过程语言实践TDD的方法,使用宏来实现自动化测试,以连接期(link-time)多态来实现隔离。
对于施行TDD来说,PHP又有其自身的语言特性。首先,PHP支持类,可以说是部分OO的。也因为如此PHPUnit才有可能模仿JUnit的结构。在PHP5中对OO的支持已有了相当大的改进和扩充。但是,PHP并不是生来就设计为OO的,它在很大程度上照顾了C的色彩。而且在国内PHP项目的现状来看,OOD/OOP也并不是主导思想。在广泛运用的稳定版本PHP4.x中,OO方面支持简单继承,但因为弱类型特性,所以谈不上多态的概念。目前国内对TDD方法的关注大多基于Java环境,以PHP为例的讨论并不多见。因此有必要进行一些探讨。
3. 例子
我们以利用PHP标准函数explode()实现另一标准函数strtok()[注2]作为例子,具体说明如何在PHP项目中应用TDD。为区别于标准函数,我们将待实现函数命名为strtk()。运行环境为Apache 1.3.23 ,PHP 4.1.1,PHPUnit 0.6.2。
3.1任务分析 —— 需求、设计、测试
程序员通过向strtk()传递两个字符串参数$str与$sep,以及随后连续调用strtk($sep),会依次得到$str中被$sep里任一字符分割的每个非空子串。事实上,TDD不提倡事先列出详细的设计条款或者形成官僚繁冗的设计文档,因为“测试反映设计”,测试即是编码的设计文档,不断增加的测试对应着不断变化的需求。需求变化中的任何时刻我们的代码都是可用的,因为它们都是保证测试通过的。这与XP方法“主动拥抱变化”的精神是一致的。
3.2第一轮 —— PHPUnit、伪实现模式
首先想到容易测试的是$str中不存在分隔符串$sep的情况,结果应是原串$str。我们开始编写测试用例中的第一个方法:
//test for strtk()
require_once "PHPUnit.php";//使用PHPUnit
require_once "strtk.php";//被测函数所在文件
//一个测试用例即为一个继承了PHPUnit_TestCase的子类
class test_strtk extends PHPUnit_TestCase
{
//constructor
function test_strtk($TestName)
{
//调用父类的构造函数以执行名为$TestName的测试方法
$this->PHPUnit_TestCase($TestName);
}
//initialization
function setUp()
{
//每个测试方法被执行之前此函数都会被调用
}
//depose
function teardown()
{
//每个测试方法被执行之后此函数都会被调用
}
//第一个测试方法:无匹配字符,应返回原串
function test_no_match()
{
$teststr="abcd";
$rslt=strtk($teststr,"#");
$this->assertEquals($teststr,$rslt,"无匹配字符时未返回原串");
}
}
//执行指定的测试用例类中每一个名字以'test'开头的方法
//此时测试结果输出时将统一小写,故方法命名中用'_'
$tsStrtk=new PHPUnit_TestSuite(test_strtk);
$TestRslt=PHPUnit::run($tsStrtk);//执行获得结果
echo $TestRslt->toHTML();//结果在浏览器中输出
?>
保存为test_strtk.php,执行得到结果:
Fatal error: Failed opening required 'strtk.php'
意料之中,因为要被测试的文件和函数都还不存在。现在测试要求我们创建文件strtk.php并编写函数strtk():
//strtk.php
function strtk($str,$sep)
{
return $str;//Fake-It Pattern
}
?>
执行test_strtk.php,测试通过:
TestCase test_strtk->test_no_match() passed
在TDD中,最重要的是尽快让测试通过。直接返回$str看起来似乎不可理喻,但确实是让测试最快通过的方法。这正符合TDD中的“伪实现模式(Fake-It Pattern)”。伪实现马上就会因为测试的增加而被替换掉。但是为什么不直接硬编码返回”abcd”呢?因为返回$str这个显而易见的抽象在实际中并不耗费我们比硬编码更多的时间。当抽象不那么轻松的时候,我们还有下面的方法来用测试驱动抽象的必要性,保证抽象的正确性。
3.3第二轮 —— 三角模式
调用一次strtk()应该能获得被分割的第一个子串。我们在test_strtk类中增加测试方法:
//获得第一个子串
function test_get_first_substr()
{
$teststr="one,two,three";
$rslt=strtk($teststr,",");
$this->assertEquals("one",$rslt,"未能获得第一个子串");
//Triangulate Pattern
$teststr="Feb./8/2004";
$rslt=strtk($teststr,"/");
$this->assertEquals("Feb.",$rslt,"未能获得第一个子串");
}
运行测试,和预计的一样得到失败的结果:
TestCase test_strtk->test_no_match() passed
TestCase test_strtk->test_get_first_substr() failed: 未能获得第一个子串 expected one, actual one,two,three
TestCase test_strtk->test_get_first_substr() failed: 未能获得第一个子串 expected Feb., actual Feb./8/2004
直接返回原串行不通了。通过测试实践还可以证明尝试硬编码返回”one”或者”Feb.”也都不行。这个测试方法中同时存在两个断言,利用了“三角模式(Triangulate Pattern)”提出了进一步抽象的需要。测试要求我们利用explode()函数了:
function strtk($str,$sep)
{
$strarr=explode($sep,$str);
return $strarr[0];
}
硬编码0取得第一个子串就刚好能使测试通过了。
3.4第三轮 —— 子测试模式、重构
接下来应该可以通过连续调用strtk($sep)来依次获得后续的子串,即是试图在test_strtk测试用例类中添加这样一个测试并使其通过:
//获得后续子串
function test_get_followup_substrs()
{
$teststr="one/two/three/four/five";
$rslt[0]=strtk($teststr,"/");
$rslt[1]=strtk("/");
$rslt[2]=strtk("/");
$rslt[3]=strtk("/");
$this->assertEquals(array("one","two","three","four"),$rslt,
"未能获得后续子串");
unset($rslt);
$teststr="To be, or not to be";
$rslt[0]=strtk($teststr," ");
$rslt[1]=strtk(" ");
$rslt[2]=strtk(" ");
$this->assertEquals(array("To","be,","or"),$rslt,"未能获得后续子串");
}
如果没有迅速的让测试通过,说明这个测试跨度太大。在TDD中“快速反馈”是非常重要的。我们不妨先不添加这个测试,将其反映的需求划分为几个相对简单的需求来分别编写测试。这是符合“子测试模式(Child Test Pattern)”的。
a. 有了参数可选的需求,这似乎需要将函数形参表写作strtk($str,$sep=NULL)。
b. 有了保持原串分隔状态的需求,这要求函数内部的$strarr为静态数组。
c. 有了感知函数调用次数的需求,这也需要函数内部有静态变量来记录。
d. 通过再次传递两个参数可以开始新一轮分割。
通过分别编写这些子测试并使其都通过,我们最后得到了使得测试test_get_followup_substrs()通过的代码:
function strtk($str,$sep=NULL)
{
static $strarr;//子串数组
static $i=0;//函数调用次数
if(is_null($sep))
{
//获得后续子串
return $strarr[$i++];
}
else
{
//开始新的分割
$strarr=explode($sep,$str);
$i=0;
return $strarr[$i++];
}
}
代码出现了重复的部分return $strarr[$i++],说明应该进行重构了:
function strtk($str,$sep=NULL)
{
static $strarr;
static $i=0;
if(!is_null($sep))
{
$strarr=explode($sep,$str);
$i=0;
}
return $strarr[$i++];
}
前面的所有测试依然通过,表明重构无误。
3.5后续工作
至此我们的代码已经可以胜任一定的工作了,但是要达到可以使用还有很多工作要做:
a. 没有做任何的参数安检:传入空串如何,显式的指定传入NULL如何。[注3]
b. 边界条件定义不周:若原串已经结束将如何,连续遇到两个分隔符有何后果。[注4]
c. 未考虑分隔符$sep的变化:$sep不止一个字符怎么处理,分割过程中传入的$sep不一致怎么处理。[注5]
这些需求都可以通过测试来表达从而以与前面相似的方法解决。
4. 结语
我们可以看到,利用PHPUnit编写测试时和Java中使用JUnit类似,都是类与方法构建起来的测试框架;而实现隔离却是用的简单的文件包含,这和C的连接期多态是相似的。PHP的有限OO特性与脚本式的执行方式使得在其中运用TDD有这样半对象/半过程的混合型特点。
与其他语言相比,PHP的弱类型特性使得在用其进行TDD实践的时候需要更加细致的测试设计,包括类型检查。也正因为这样,PHP项目的开发更需要TDD的强大保护,以更好的发挥这种灵活又布满陷阱的语言的威力。
附注
注1. 可由http://pear.php.net/package/PHPUnit下载。最新版本为1.0.0 alpha3,要求PHP5/Zend Engine 2。 0.6.2 是可在PHP4稳定版本上使用的最后版本。
注2. 仅出于示例目的,这里虚构了strtok()封装explode()的情景。通过阅读实际的PHP实现源码,可知两者是同层次平行的,没有相互封装的关系。在无需使用正则表达式(Regular Expression)来进行简单串操作的场合,这两个函数还是十分简练有效的。
注3. 继续用可选形参表配合is_null()判断将引起混淆。欲解决应引入func_num_args()与func_get_arg()这类函数。值得注意的是PHP4中explode()不允许分隔符空或为NULL,否则引发Warning并返回FALSE,源串可为空。而strtok()恰好相反,分隔符可空可NULL,源串长为0则返回FALSE。
注4. 原串结束返回NULL,连续两个分隔符返回空串。仅凭返回值串长或布尔值,程序员是无法区分这两种情况的,必须辅以类型判断。试图以while(strlen(strtk("/")))遍历所有子串将会有潜在的漏洞。实际上PHP4中的strtok()当源串结束时返回FALSE,连续两个分隔符略过并返回下一子串。
注5. 标准函数strtok()允许接收多字符的分隔串,每一个字符分别都是一个分隔符,而本函数将分隔串整体作为一个分隔符。分隔符变化时strtok()从当前位置以新分隔符继续工作。
参考文献:
[1] Kent Beck. Test-Driven Development: By Example [M]. Addison Wesley. Nov. 2002
[2] Martin Fowler, Kent Beck, John Brant, William Opdyke, Don Roberts. Refactoring: Improving the Design of Existing Code [M]. Addison Wesley. Jun. 1999
[3] Dr. Robert S. Koss, Jeff Langr. Test Driven Development in C/C++. C/C++ Users Journal [J]. Oct. 2002