在编写PHPUnit单元测试代码时,其实很多都是对各个类的各个外部调用的函数进行测试验证,检测代码覆盖率,验证预期效果。为避免增加开发量,可以使用PHPUnit提供的phpunit-skelgen来生成测试骨架。只是一开始我不知道有这个脚本,就自己写了一个,大大地提高了开发效率,也不用为另外投入时间去编写测试代码而烦心。但是后来我发现自定义的脚本比phpunit-skelgen更具人性化、更有趣。也更为有效。特此在这里分享一下。
假如我们现在有一个简单的业务类,实现了加运算,为了验证其功能,下面将会就两种生成测试代码的方式进行说明。
<?php class Demo { /** * 求两数和 * * @testcase 2 1,1 * @testcase -5 -10,5 * * @param int $left 左操作数 * @param int $right 右操作数 * @return int */ public function inc($left, $right) { return $left + $right; } }
在安装了phpunit-skelgen后,可以使用以下命令来生成测试骨架。
phpunit-skelgen --test -- Demo ./Demo.php
生成后,使用:
vim ./DemoTest.php
可查看到生成的测试代码如下:
<?php /** * Generated by PHPUnit_SkeletonGenerator 1.2.1 on 2014-06-30 at 15:53:01. */ class DemoTest extends PHPUnit_Framework_TestCase { /** * @var Demo */ protected $object; /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. */ protected function setUp() { $this->object = new Demo; } /** * Tears down the fixture, for example, closes a network connection. * This method is called after a test is executed. */ protected function tearDown() { } /** * @covers Demo::inc * @todo Implement testInc(). */ public function testInc() { // Remove the following lines when you implement this test. $this->markTestIncomplete( 'This test has not been implemented yet.' ); } }
试运行测试一下:
[test ~/tests]$phpunit ./DemoTest.php PHPUnit 3.7.29 by Sebastian Bergmann. PHP Fatal error: Class 'Demo' not found in ~/tests/DemoTest.php on line 18
可以看到没有将需要的测试类包括进来。当然还有其他一些需要手工改动的地方。但是生成的代码立即执行是失败的!
现在改用自定义的脚本 来生成,虽然也有需要手工改动的地方,但已经尽量将需要改动的代码最小化,让测试人员(很可能是开发人员自己)更关注业务的测试。
先看一下Usage.
$ ./build_phpunit_test_tpl.php Usage: php ./build_phpunit_test_tpl.php <file_path> <class_name> [bootstrap] [author = dogstar] Demo: php ./build_phpunit_test_tpl.php ./Demo.php Demo > Demo_Test.php
然后可以使用:
php ./build_phpunit_test_tpl.php ./Demo.php Demo
来预览看一下将要生成的测试代码,如果没有问题可以使用:
php ./build_phpunit_test_tpl.php ./Demo.php Demo > ./Demo_Test.php
将生成的测试代码保存起来。注意:这里使用的是“_Test.php”后缀,以便和官方的区分。看下生成的代码:
<?php /** * PhpUnderControl_Demo_Test * * 针对 ./Demo.php Demo 类的PHPUnit单元测试 * * @author: dogstar 20150118 */ //建议采用统一的测试环境,但由于此次示例中没有,故先注释 //require_once dirname(__FILE__) . '/test_env.php'; if (!class_exists('Demo')) { require dirname(__FILE__) . '/./Demo.php'; } class PhpUnderControl_Demo_Test extends PHPUnit_Framework_TestCase { public $demo; protected function setUp() { parent::setUp(); $this->demo = new Demo(); } protected function tearDown() { } /** * @group testInc */ public function testInc() { $left = ''; $right = ''; $rs = $this->demo->inc($left, $right); $this->assertTrue(is_int($rs)); } /** * @group testInc */ public function testIncCase0() { $rs = $this->demo->inc(1,1); $this->assertEquals(2, $rs); } /** * @group testInc */ public function testIncCase1() { $rs = $this->demo->inc(-10,5); $this->assertEquals(-5, $rs); } }
随后,试运行一下:
$phpunit ./Demo_Test.php PHPUnit 4.3.0 by Sebastian Bergmann. ... Time: 22 ms, Memory: 4.75Mb OK (3 tests, 3 assertions)
测试通过了!!!
起码,我觉得生成的代码在大多数默认情况下是正常通过的话,可以给开发人员带上心理上的喜悦,从而很容易接受并乐意去进行下一步的测试用例完善。
现在,开发人员只须稍微改动测试代码就可以实现对业务的验证。如下示例:
/** * @group testInc */ public function testInc() { $left = '2015'; $right = '1'; $rs = $this->demo->inc($left, $right); $this->assertTrue(is_int($rs)); $this->assertEquals(2016, $rs); }
然后再运行,依然通过。
在上面的示例中,脚本会默认生成一个单元测试,并且尝试对已知类型的返回值作验证。除此之外,还为简单的“输入参数 & 期望结果”生成了对应的单元测试,可以有多组。
下面是相关的注释:
* @testcase 2 1,1 * @testcase -5 -10,5
格式也是显然易见的,就是:
@testcase 期望结果 (空格) [参数1,参数2,...]
其中@testcase为关键字,期望结果为函数应该返回的值,后面的参数串将会原样传递给单元测试的代码。
考虑到单元测试的复杂性和一般性,目前只是提供了这一种简单的根据注释生成测试代码。并且,这里更推荐您亲自来编写单元测试,因为通过对单元测试的编写,你将可以发现很多有趣的问题,有趣的实践。一如TDD。
测试驱动开发,是要求在未写产品代码前先写单元测试的代码,并让它预期的失败。但很多情况下我们更多是针对已有的代码(特别是历史遗留或者过去自己编写的代码)由于后期维护而进行单元测试。这两种情况都稍微显得有点“偏激”,因此我们可以稍微变通一下,以平衡这两种情况之间的微妙关系。
根据三层概念视角,我们显然可以进行共性分析,并且约定好规约接口。由此,类的简单声明和函数签名可以确定并可以开发类的定义代码。随后,再补充@testcase注释并通过本脚本自动生成测试代码,进行测试驱动开发。
下面是一个简单的例子:
假设我们有一个游戏用户的辅助类,可以根据用户的经验值算出用户对应的等级。并且规定:
经验值 |
等级 |
0 |
1级 |
[1, 10) |
1级 |
[10, 20) |
2级 |
[20, 30) |
3级 |
... |
... |
[990, 1000) |
99级 |
[1000, +无穷大) |
100级 |
在此业务场景下,我们可以定义一个游戏用户类GameUserHelper为:
//$vim ./GameUserHelper.php <?php class GameUserHelper { public static function exp2level($exp) { } }
当此实现开发完成后,外部调用则可以通过以下方式来使用:
$level = GameUserHelper::exp2level(100); //等级为10
为了快速进行单元测试,我们先补充一下@testcase注释:
//$vim ./GameUserHelper.php <?php class GameUserHelper { /** * 根据用户的经验值算出对应的等级 * * @testcase 10 100 * @testcase 100 9999 * @testcase 1 -8 * * @param int $exp 用户的经验值 * @return int */ public static function exp2level($exp) { } }
然后,通过脚本自动生成测试骨架和代码:
$./build_phpunit_test_tpl.php ./GameUserHelper.php GameUserHelper > GameUserHelper_Test.php
执行一下:
$phpunit ./GameUserHelper_Test.php PHPUnit 3.7.29 by Sebastian Bergmann. FFFF Time: 30 ms, Memory: 3.75Mb There were 4 failures: 1) PhpUnderControl_GameUserHelper_Test::testExp2level Failed asserting that false is true. /mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:41 2) PhpUnderControl_GameUserHelper_Test::testExp2levelCase0 Failed asserting that null matches expected 10. /mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:52 3) PhpUnderControl_GameUserHelper_Test::testExp2levelCase1 Failed asserting that null matches expected 100. /mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:62 4) PhpUnderControl_GameUserHelper_Test::testExp2levelCase2 Failed asserting that null matches expected 1. /mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:72 FAILURES! Tests: 4, Assertions: 4, Failures: 4.
Well Done!预期地失败了!下面是更多的业务开发,略。。。
//$ cat ./build_phpunit_test_tpl.php #!/usr/bin/env php <?php /** * 单元测试骨架代码自动生成脚本 * 主要是针对当前项目系列生成相应的单元测试代码,提高开发效率 * * 用法: * Usage: php ./build_phpunit_test_tpl.php <file_path> <class_name> [bootstrap] [author = dogstar] * * 1、针对全部public的函数进行单元测试 * 2、可根据@testcase注释自动生成测试用例 * * 备注:另可使用phpunit-skelgen进行骨架代码生成 * * @author: dogstar 20150108 * @version: 4.0.0 */ if ($argc < 3) { echo " Usage: php $argv[0] <file_path> <class_name> [bootstrap] [author = dogstar] Demo: php ./build_phpunit_test_tpl.php ./Demo.php Demo > Demo_Test.php "; die(); } $filePath = $argv[1]; $className = $argv[2]; $bootstrap = isset($argv[3]) ? $argv[3] : null; $author = isset($argv[4]) ? $argv[4] : 'dogstar'; if (!empty($bootstrap)) { require $bootstrap; } require $filePath; if (!class_exists($className)) { die("Error: cannot find class($className). \n"); } $reflector = new ReflectionClass($className); $methods = $reflector->getMethods(ReflectionMethod::IS_PUBLIC); date_default_timezone_set('Asia/Shanghai'); $objName = lcfirst(str_replace('_', '', $className)); $code = "<?php /** * PhpUnderControl_" . str_replace('_', '', $className) . "_Test * * 针对 $filePath $className 类的PHPUnit单元测试 * * @author: $author " . date('Ymd') . " */ "; if (file_exists(dirname(__FILE__) . '/test_env.php')) { $code .= "require_once dirname(__FILE__) . '/test_env.php'; "; } else { $code .= "//require_once dirname(__FILE__) . '/test_env.php'; "; } $initWay = "new $className()"; if (method_exists($className, '__construct')) { $constructMethod = new ReflectionMethod($className, '__construct'); if (!$constructMethod->isPublic()) { if (is_callable(array($className, 'getInstance'))) { $initWay = "$className::getInstance()"; } else if(is_callable(array($className, 'newInstance'))) { $initWay = "$className::newInstance()"; } else { $initWay = 'NULL'; } } } $code .= " if (!class_exists('$className')) { require dirname(__FILE__) . '/$filePath'; } class PhpUnderControl_" . str_replace('_', '', $className) . "_Test extends PHPUnit_Framework_TestCase { public \$$objName; protected function setUp() { parent::setUp(); \$this->$objName = $initWay; } protected function tearDown() { } "; foreach ($methods as $method) { if($method->class != $className) continue; $fun = $method->name; $Fun = ucfirst($fun); if (strlen($Fun) > 2 && substr($Fun, 0, 2) == '__') continue; $rMethod = new ReflectionMethod($className, $method->name); $params = $rMethod->getParameters(); $isStatic = $rMethod->isStatic(); $isConstructor = $rMethod->isConstructor(); if($isConstructor) continue; $initParamStr = ''; $callParamStr = ''; foreach ($params as $param) { $default = ''; $rp = new ReflectionParameter(array($className, $fun), $param->name); if ($rp->isOptional()) { $default = $rp->getDefaultValue(); } if (is_string($default)) { $default = "'$default'"; } else if (is_array($default)) { $default = var_export($default, true); } else if (is_bool($default)) { $default = $default ? 'true' : 'false'; } else if ($default === null) { $default = 'null'; } else { $default = "''"; } $initParamStr .= " \$" . $param->name . " = $default;"; $callParamStr .= '$' . $param->name . ', '; } $callParamStr = empty($callParamStr) ? $callParamStr : substr($callParamStr, 0, -2); /** ------------------- 根据@return对结果类型的简单断言 ------------------ **/ $returnAssert = ''; $docComment = $rMethod->getDocComment(); $docCommentArr = explode("\n", $docComment); foreach ($docCommentArr as $comment) { if (strpos($comment, '@return') == false) { continue; } $returnCommentArr = explode(' ', strrchr($comment, '@return')); if (count($returnCommentArr) >= 2) { switch (strtolower($returnCommentArr[1])) { case 'bool': case 'boolean': $returnAssert = '$this->assertTrue(is_bool($rs));'; break; case 'int': $returnAssert = '$this->assertTrue(is_int($rs));'; break; case 'integer': $returnAssert = '$this->assertTrue(is_integer($rs));'; break; case 'string': $returnAssert = '$this->assertTrue(is_string($rs));'; break; case 'object': $returnAssert = '$this->assertTrue(is_object($rs));'; break; case 'array': $returnAssert = '$this->assertTrue(is_array($rs));'; break; case 'float': $returnAssert = '$this->assertTrue(is_float($rs));'; break; } break; } } /** ------------------- 基本的单元测试代码生成 ------------------ **/ $code .= " /** * @group test$Fun */ public function test$Fun() {" . (empty($initParamStr) ? '' : "$initParamStr\n") . "\n " . ($isStatic ? "\$rs = $className::$fun($callParamStr);" : "\$rs = \$this->$objName->$fun($callParamStr);") . (empty($returnAssert) ? '' : "\n\n " . $returnAssert . "\n") . " } "; /** ------------------- 根据@testcase 生成测试代码 ------------------ **/ $caseNum = 0; foreach ($docCommentArr as $comment) { if (strpos($comment, '@testcase') == false) { continue; } $returnCommentArr = explode(' ', strrchr($comment, '@testcase')); if (count($returnCommentArr) > 1) { $expRs = $returnCommentArr[1]; $callParamStrInCase = isset($returnCommentArr[2]) ? $returnCommentArr[2] : ''; $code .= " /** * @group test$Fun */ public function test{$Fun}Case{$caseNum}() {" . "\n " . ($isStatic ? "\$rs = $className::$fun($callParamStrInCase);" : "\$rs = \$this->$objName->$fun($callParamStrInCase);") . "\n\n \$this->assertEquals({$expRs}, \$rs);" . " } "; $caseNum ++; } } } $code .= " }"; echo $code; echo "\n";