场景
在编写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后,可以使用以下命令来生成测试骨架。
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。
与测试驱动开发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";