[PHPUnit]自动生成PHPUnit测试骨架脚本-提供您的开发效率【2015升级版】

场景

在编写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";


你可能感兴趣的:(自动化测试,phpunit,@testcase,测试骨架)