2019独角兽企业重金招聘Python工程师标准>>>
场景
在编写PHPUnit单元测试代码时,其实很多都是对各个类的各个外部调用的函数进行测试验证,检测代码覆盖率,验证预期效果。为避免增加开发量,可以使用PHPUnit提供的phpunit-skelgen来生成测试骨架。只是一开始我不知道有这个脚本,就自己写了一个,大大地提高了开发效率,也不用为另外投入时间去编写测试代码而烦心。但是后来我发现自定义的脚本比phpunit-skelgen更具人性化、更有趣。也更为有效。特此在这里分享一下。
一个待测试的示例类
假如我们现在有一个简单的业务类,实现了加运算,为了验证其功能,下面将会就两种生成测试代码的方式进行说明。
<?phpclass 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后,可以使用以下命令来生成测试骨架。
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.75MbOK (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。
与测试驱动开发TDD的结合
测试驱动开发,是要求在未写产品代码前先写单元测试的代码,并让它预期的失败。但很多情况下我们更多是针对已有的代码(特别是历史遗留或者过去自己编写的代码)由于后期维护而进行单元测试。这两种情况都稍微显得有点“偏激”,因此我们可以稍微变通一下,以平衡这两种情况之间的微妙关系。
根据三层概念视角,我们显然可以进行共性分析,并且约定好规约接口。由此,类的简单声明和函数签名可以确定并可以开发类的定义代码。随后,再补充@testcase注释并通过本脚本自动生成测试代码,进行测试驱动开发。
下面是一个简单的例子:
假设我们有一个游戏用户的辅助类,可以根据用户的经验值算出用户对应的等级。并且规定:
经验值 | 等级 |
0 | 1级 |
[1, 10) | 1级 |
[10, 20) | 2级 |
[20, 30) | 3级 |
... | ... |
[990, 1000) | 99级 |
[1000, +无穷大) | 100级 |
在此业务场景下,我们可以定义一个游戏用户类GameUserHelper为:
//$vim ./GameUserHelper.php
<?phpclass GameUserHelper
{public static function exp2level($exp){}
}
当此实现开发完成后,外部调用则可以通过以下方式来使用:
$level = GameUserHelper::exp2level(100); //等级为10
为了快速进行单元测试,我们先补充一下@testcase注释:
//$vim ./GameUserHelper.php
<?phpclass 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.FFFFTime: 30 ms, Memory: 3.75MbThere were 4 failures:1) PhpUnderControl_GameUserHelper_Test::testExp2level
Failed asserting that false is true./mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:412) PhpUnderControl_GameUserHelper_Test::testExp2levelCase0
Failed asserting that null matches expected 10./mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:523) PhpUnderControl_GameUserHelper_Test::testExp2levelCase1
Failed asserting that null matches expected 100./mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:624) PhpUnderControl_GameUserHelper_Test::testExp2levelCase2
Failed asserting that null matches expected 1./mnt/hgfs/php/centos/projects/test/GameUserHelper_Test.php:72FAILURES!
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";