PHP编程之PHPUnit高级测试

PHPUnit是XUnit家族中的一员,它的作者是Sebastan Bergmann,他自己的使命是:Driven by his passion to help developers build better software。
简单认识完PHPUnit的作者后,我们就要开始深入学习PHPUnit和相关的测试技巧了。概括来说,PHPUnit单元测试的执行流程,主要有以下几个环节:
TestCase::setUpBeforeClass()
TestCase::setUp()
TestCase::testXXX()TestCase::tearDown()
TestCase::tearDownAfterClass()
PHPUnit官方文档有详细的说明,这里不再重复赘述。这里要讲的是,下面四个小节的内容,都是基于这条主线而展开的内容。首先,我们会介绍如何自动生成测试代码骨架,而不需要人工编写重复的代码。有了测试代码骨架后,再根据构造-操作-检验模式就可以编写具体的测试用例了。接下来就是怎么执行单元测试的问题了。把这些环节全部串联起来,就可以完成测试驱动开发中“红-绿-重构”这条主线的实践了。
但在PHPUnit高级测试中,还有两个非常重要的模块,分别是针对输入和输出的。前者说的是造假的技巧,即使用桩、替身、仿件等技术模拟需要的测试数据。后者说的是断言的艺术,包括对异常的断言、对API接口结果的验证和对模板渲染输出的验证等。

1 自动生成测试代码骨架

不管以何种方式,能自动生成测试代码骨架,都能帮助我们开发人员节省很多重复代码编写的时间,更重要的是能提升我们编程的心理体验。
在讨论不同的自动生成方式前,先让时光稍微倒流一下,假设我们的计数器类Calculator还没有实现,只是设计好了函数签名,并且单独在一个类文件Calculator.php中。最初时,代码是这样:

<?php
class Calculator {
    public function add($left, $right) {
        // TODO
    }
}

为了给Calculator类,包括后面项目中成千上百个类生成对应的单元测试代码骨架,现在花一点时间来学习自动生成的技术,是很有必要的。

使用官方的phpunit-skelgen生成

根据官方文档的说明,可以使用以下命令在Linux系统上快速安装phpunit-skelgen,注意第三行命令需要使用root权限。

$ wget https://phar.phpunit.de/phpunit-skelgen.phar
$ chmod +x phpunit-skelgen.phar
# mv phpunit-skelgen.phar /usr/local/bin/phpunit-skelgen

成功下载安装后,就可以查看到对应的版本号了。

$ phpunit-skelgen --version
phpunit-skelgen 2.0.1 by Sebastian Bergmann.

进入刚才Calculator.php所在的目录,再执行以下命令就可以自动生成测试代码骨架了。非常简单。

$ phpunit-skelgen generate-test Calculator ./Calculator.php

温馨提示:单元测试的代码通常都统一放到tests目录内,并且与产品代码保持结构平行。这里为了关注测试代码骨架的自动生成,单元测试所在的目录不作过多要求。

这里,会生成一个./CalculatorTest.php文件,里面有PHPUnit单元测试的基本骨架代码。自动生成的代码看起来类似如下:

<?php
/**
 * Generated by PHPUnit_SkeletonGenerator on 2018-05-27 at 06:31:37.
 */
class CalculatorTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var Calculator
     */
    protected $object;

    // ……

    /**
     * @covers Calculator::add
     * @todo   Implement testAdd().
     */
    public function testAdd()
    {
        // Remove the following lines when you implement this test.
        $this->markTestIncomplete(
            'This test has not been implemented yet.'
        );
    }
}

对此代码加以调整,引入了Calculator.php源代码,并且根据构造-操作-检验模式补充具体的测试用例后,再次执行,就可以得到和前面同样的单元测试的结果了。

PhalApi框架中的phalapi-buildtest生成

如果使用的是PhalApi框架进行接口项目开发,则可以使用它所提供的phalapi-buildtest脚本命令。即便使用的不是PhalApi框架,也可以单独使用它这个phalapi-buildtest。
phalapi-buildtest可以生成与phpunit-skelgen类似的骨架代码,不过它的用法与生成的代码,更符合我们国内的情况。此外,还可以根据testcase注解生成对应的用例。例如,在Calculator.php类文件中,添加两行注解。一行是@testcase 3 1, 2
,表示3 = 1 + 2
,第一个参数表示期望的结果,后面的参数列表是对应的参数值。第二行表示是两个负数相加,即-3 = (-1) + (-2)

class Calculator {
    /**
     * @testcase 3 1, 2
     * @testcase -3 -1, -2
     */
    public function add($left, $right) {
        // TODO
    }
}

然后,借用phalapi-buildtest,最后生成的测试代码骨架中,有以下类似的代码片段:

    /**
     * @group testAdd
     */ 
    public function testAdd()
    {
        $left = '';
        $right = '';

        $rs = $this->calculator->add($left, $right);
    }

    /**
     * @group testAdd
     */ 
    public function testAddCase0()
    {
        $rs = $this->calculator->add(1, 2);

        $this->assertEquals(3, $rs);
    }

    /**
     * @group testAdd
     */ 
    public function testAddCase1()
    {
        $rs = $this->calculator->add(-1, -2);

        $this->assertEquals(-3, $rs);
    }

可以发现,除了提供默认的测试用例外,还有根据@testcase注解生成的两个测试用例testAddCase0()和testAddCase1()。

自己编写生成器

如果项目有自己特殊的需要,也可以参考自己编写代码生成器。不过,更好的建议是优先使用已成熟的开源的代码生成器。其次是基于这些现成的脚本、命令、工具进行二次开发,扩展自己特殊的功能。最后,才是从零开始,编写自己专属的测试代码骨架生成器。
实现思路比较简单,主要使用了反射机制。除上根据源代码生成测试代码这种正向工程外,还可以向phpunit-skelgen那样根据测试代码反向生成源代码。

2 如何执行单元测试?

测试代码骨架已经生成好,特定的测试用例代码也已经根据模式编写完毕。接下来,就是要执行单元测试了。
重复回顾前面CalculatorTest.php测试文件,它的代码是:

class CalculatorTest extends TestCase {
    public function testAdd() {
        // Step 1. 构造
        $left = 1;
        $right = 2;

        // Step 2. 操作
        $calculator = new Calculator();
        $sum = $calculator->add($left, $right);

        // Step 3. 检验
        $this->assertSame(3, $sum);
    }
}

为此,从小到大,执行单元测试的方式有:
执行单个测试用例例如:phpunit --filter testAdd ./CalculatorTest.php
执行指定单元测试内某一组测试例如:phpunit --group testAdd ./CalculatorTest.php

执行单个测试文件例如:phpunit ./CalculatorTest.php
执行某个目录下全部的测试文件例如:phpunit ./tests
执行测试套件(可自由组合多个目录和文件)例如:phpunit ./tests -c ./phpunit.xml
一键测试(集成多个测试套件到自定义脚本)可以编写shell脚本,将多个测试套件,通过一个脚本命令来运行,做到多合一。
这些执行方式,不需要死记硬背,有理解的基础上灵活应用即可。

3 九个造假技巧

根据前面所提到的构造-操作-检验模式,在编写单元测试时,我们首先需要进行的就是构造一个测试场景。但很多时候,我们的功能实现又依赖于第三方接口或者外部数据。
例如,我们需要验证用户领取优惠券的几个业务场景:
第一次成功领券
超出最大限制次数时领券
底层接口异常时领券失败

而这些场景,我们更好的方案应该是模拟测试数据,也就是利用桩、替身、外部依赖注入等技巧来模拟测试数据,以达到更灵活、覆盖率更高的测试以及制造所需要的待测试场景。
这也是编写单元测试中难度最大、维护成本最高的一部分。为了方便更多同学掌握“造假”技巧,降低对编写单元测试的学习成本,我们根据这几年的经验,从不同的项目情况总结了以下9个造假技巧。
首先,最重要的一个原则是:“给我一个入口,我可以模拟任何数据。” 还有一个前提是:尽量不修改原来的产品源代码。
其次,通常情况下,部分代码的写法会严重限制、甚至根本无法对其进行模拟,也就无法进行更好地单元测试。所以不被提倡的写法有:
不提倡使用面向过程的函数
不提倡使用静态类成员函数
不提倡使用private级别的类成员函数/属性

技巧1:通过构造参数实现外部依赖注入

很多类在实现功能时,需要拥有于外部其他协作类,即聚合关系,或依赖其他服务实现技术功能,像委托。如需要发送邮件时,可能的写法是:

<?php
class Push {
    protected $mailService;
    public function __construct() {
        $this->mailService = new PHPMailer();
        // 其他更多逻辑 ...
    }
}

但这样在测试时,不方便对发送邮件的服务进行模拟(也可以实现子类重载构造方法,但仍需要保持后面其他更多逻辑的一致性,且覆盖率低)。 可以将服务的初始化从内部转向外部,通过构造参数来进行外部依赖注入。

<?php
class Push {
    protected $mailService;
    public function __construct($mailService) {
        $this->mailService = $mailService;
        // 其他更多逻辑 ...
    }
}

然后就可以对此邮件服务进行任意的替换、模拟。

$push = new Push(new PHPMailer());      //真实的邮件服务,用于产品代码
$push = new Push(new PHPMailer_Mock()); //模拟的邮件服务,用于单元测试

技巧2:通过接口参数实现外部依赖注入

这里所说的接口是指类的函数签名所对应的接口声明,而非远程调用的后台接口。
在很多时候,我们的功能类里只是有极个别的操作需要用到极个别的外部资源、协作类或者服务,或者本身就是为了提供服务而无状态。这样的话,可以直接通过接口参数来实现外部依赖注入。

<?php
class Push {
    public function sendEmail($mailService, $title, $content) {
        // 前期准备 ...
        $mailService->Send();
        // 更多代码 ...
    }
}

这样,在减少一个类成员属性的同时,我们也能更好地进行模拟测试。

$push = new Push();
$push->sendEmail(new PHPMailer_Mock(), $title, $content);

顺便提一下,通常比较好的建议是一个类的成员属性,不应超过3个。

技巧3:通过提取成员函数制造缝纫点

若使用的服务只有一个且固定时,使用接口参数注入的方式会显得有点过于重复、烦锁。这种情况下,可以先将创建资源服务的new操作提取到成员函数,再对此成员函数进行模拟。
如第一步先提取函数:

<?php
class Push {
    protected $mailService;
    public function __construct($mailService) {
        $this->mailService = $this->getMailService();
        // 其他更多逻辑 ...
    }
    protected function getMailService() {
        return new PHPMailer();
    }             
}

第二步在进行单元测试时,可对创建实例的成员函数进行重载替换:

class Push_Mock extends Push {
    protected function getMailService() {
        return new PHPMailer_Mock();
    }
}

最后,使用模拟后的子类进行测试即可。
这种技巧也可用于一些特定难以模拟的操作中,也可以将此真实操作提取到成员函数再进行模拟。

技巧4:通过工厂方法或者资源容器进行外部注入

很多项目也会使用工厂方法或者资源容器的来管理、维护对象实例。
如在系统中使用Factory进行统一管理下,获取用户登录态的代码实现片段:

<?php
class UserHelper {
    /**
     * 获取用户信息
     */
    public static function getUser()
    {
        $saturn = Cookie::get('saturn');
        if (! empty($saturn))
        {
            $userModel = Factory::create('UserModel');
            $userInfo = $userModel->getMcUser($saturn);
            return $userInfo;
        }
        return NULL;
    }
}

在进行测试时,如果我们只想测试上层业务功能,而难以每次都获取一个真实的用户登录态时,可以对UserModel这一实例进行替换。但问题在于,工厂方法通常是这样编写的:

<?php
class Factory {
    protected static $cache = array();

    public static function create($className) {
        if(isset(self::$cache[$className])) {
            return self::$cache[$className];
        }

        self::$cache[$className] = new $className();
        return self::$cache[$className];
    }
}

对此,我们需要在启动单元测试之前对这个工厂类进行再改造,即添加一个新的函数Factory ::setCache($className, $obj),再一次“以假乱真”。

<?php
class Factory {
    // 原来的代码 ...
    public static function setCache($className, $obj) {
        self::$cache[$className] = $obj;
    }
}

然后,通过“新”工厂方法的绿色通道,我们可以在单元测试时使用Mock指定任意模拟数据。

<?php
class PhpUnderControl_EcouponController_Test extends PHPUnit_Framework_TestCase
{
    public $ecouponController;
    protected function setUp()
    {
        //登录态
        $userModelStub = $this->getMock('UserModel');
        $userModelStub->expects($this->any())
            ->method('getMcUser')
            ->will($this->returnValue(array(
                'tokenId' => '5BA25B3FD90C71AB99B532929F1CF4E1E7F39A50',
                'tokenSecret' => '6e215b1e7d0e73f9dc5ba6d4320ee8eb',
                'userId' => '1',
                'userName' => 'phpunit',
            )));
        Factory::setCache('UserModel', $userModelStub);
    }
    protected function tearDown()
    {
        Factory::setCache('UserModel', null);
    }
}

技巧5:对使用单例模式的实例进行替换

首先需要注意到的是,单例模式肯定有其作用和使用场景,但不应过于泛滥地使用,除非确切知道使用它能带来的好处以及所造成的障碍。
因为单例模式通常结合使用了开头最不提倡的三种写法中的两种写法,即:同时用了静态类函数,又用了private级别的类成员。这使得在测试制造模拟数据时无法进行。
对此,我们需要先进行一点“小手术”,把private的单例成员变量,改成protected级别。
例如曾经在电商平台特卖会中,调用接口中间层的实现类:

<?php
class App_ShopApi {
    private static $_instance = null; //实例对象
    /**
     * 获取单例实例
     * @return App_ShopApi
     */    
    public static function getInstance()
    {
        if (self::$_instance ===null) {
            self::$_instance = new App_ShopApi();
        }
        return self::$_instance;
    }
    // ……
}

此情况下,不得已需要修改产品代码,将private改成protetec。

<?php
class App_ShopApi {
    protected static $_instance = null; //实例对象
    // ...

在测试前,先利用子类实现替换:

<?php
class App_ShopApi_Mock extends App_ShopApi {
    public function setInstance($instance) {
        parent::$_instance = $instance;
    }
}

再进行替换操作:

<?php
Api_ShopApi_Mock::setInstance($stub);

这样就可以实现替换了。

技巧6:对PHP官方函数进行模拟

PHP官方函数有:exit()、die()、header()、setcookie()等。而这些如exit和die会直接终止单元测试,而header则会导致警告出现。这些都不利于单元测试。
为此,如何既使用官方函数,又能很好进行单元测试呢?答案仍然是:入口!在开发时,我们需要对这些官方原生态的函数进行再封装。例如在底层时可以使用一个辅助类,类似:

<?php
/**
 * 系统函数切入点
 */
class KernalHelper {
    public static function headerEx($string, $replace = true, $http_response_code = null) {
        if ($http_response_code === null) {
            header($string, $replace);
        } else {
            header($string, $replace, $http_response_code);
        }
    }

    public static function exitEx($status = null) {
        if ($status === null) {
            exit();
        } else {
            exit($status);
        }
    } 
}

接着,在启动单元测试前,对此Kernal类进行统一替换。

<?php
if (!class_exists('KernalHelper', FALSE)) {
    class KernalHelper {
        public static function __callStatic($func, $arguments) {
            echo "This will call KernalHelper::$func(", implode(',', $arguments) ,") ... \n";
        }
    }
}

在模拟时,需要注意两点。第一是class_exists()第二参数使用FALSE,避免触发真实类的自动加载。第二点是可进行打印输出以模拟真实操作。
如对于通常接口结果返回处理的响应类:

<?php
/**
 * 更符合框架精神的响应类
 *
 * - 实现IOutput接口,以便可以统一输出处理
 * - 通用返回格式:code + message + data
 * - 依赖于XssHelper、KernalHelper辅助类,以便更安全、更可测
 *
 * @author dogstar 20151116
 */
class ResponseHelper implements IOutput {
    /** -------------------------- 输出 -------------------------- **/
    /**
     * 支持JSONP输出返回
     */
    public function display() {
        // ... ...
        foreach ($this->headers as $header) {
            KernalHelper::headerEx($header);
        }
        // ... ...
    }
}

运行单元测试后,会看到类似这样的输出:

This will call KernalHelper::headerEx(Cache-Control: no-cache) ... 
This will call KernalHelper::headerEx(Content-type: application/json; charset=utf-8)

技巧7:通过结果收集器对输出进行模拟输出

在使用Yii框架中,我们会对模块视图进行渲染,或者需要返回JSON格式的数据。而这些都会导致结果直接打印显示出来,虽然PHPUnit支持对输出进行断言、回调。
但是我们希望得到更灵活的测试方式时,可以对这些输出进行模拟输出。同时辅以结果收集器,将要输出的结果纪录下来,以便校验。
因此,在测试启动文件中,我们需要首先对Controller类进行仿真。

<?php
// ... ...

if (!class_exists('Controller', false)) {
    class Controller extends CController
    {
        public static $view;
        public static $data;
        public static $return;
        public static $code;
        public static $message;
        public static $url;
        public static $terminate;
        public static $statusCode;
        public $layout='//layouts/main';
        /**
         * api接口返回错误信息
         */
        public function showError( $code, $message='' ){
            self::$code = $code;
            self::$message = $message;
        }
        /**
         * api接口返回信息
         */
        public function showMessage( $data=array(),$code=200 ){
            self::$code = $code;
            self::$data = $data;
        }
        public function render($view,$data=null,$return=false) {
            self::$view = $view;
            self::$data = $data;
            self::$return = $return;
        }
        public function redirect($url,$terminate=true,$statusCode=302) {
            self::$url = $url;
            self::$terminate = $terminate;
            self::$statusCode = $statusCode;
        }    
    }
}

这里,可以发现,对于错误显示、模板渲染、接口输出、重定向等我们都可以进行仿真,并且对当时的场景信息进行保存,以但纪录。
然后,对于其中的页面处理,如退出登录:

<?php
class SiteController extends Controller
{
    // ... ...
    /**
     * Logs out the current user and redirect to homepage.
     */
    public function actionLogout()
    {
        Yii::app()->user->logout();
        $this->redirect(Yii::app()->homeUrl);
    }
}

接着使用结果收集器进行测试:

<?php
class PhpUnderControl_SiteController_Test extends PHPUnit_Framework_TestCase {

    public function testActionLogout()
    {
        $rs = $this->siteController->actionLogout();
        $this->assertEquals('期望跳转的链接', Controller::$url);
    }
}

每次执行完单元测试后,都应在tearDown()中对Controller::$url、Controller::$data等收集的结果进行擦除,以免对其他测试用例造成干扰。

技巧8:模拟第三方接口返回的结果

很多时候,绝大数据情况,项目都需要调用第三方接口获取数据。这使得在开发过程中、测试过程中都带来了极大不稳定的环境因素。对此,更好的建议是将接口请求的处理细分为两个环节:接口请求、结果解析。
例如,在接口层中调用大数据排序的接口进行排序的实现。

<?php
/**
 * 自动化排序
 */
class Module_ActivityV3HelperAutoSort extends Module_SpecialSort implements Module_ActivityV3Helper
{
    protected function _callApi($params)
    {
        // 进行接口请求 ...
    }

    protected function _parseApiRs($apiRs)
    {
        // 解析接口结果 ...
    }
}

然后,在测试时,当我们能指定接口返回的结果时,我们也就能确定最终获得的数据了。真实的单元测试片段代码如下:

<?php
class PhpUnderControl_ModuleActivityV3HelperAutoSort_Test extends PHPUnit_Framework_TestCase
{
    public $moduleActivityV3HelperAutoSort;
    protected function setUp()
    {
        parent::setUp();
        $this->moduleActivityV3HelperAutoSort = new Module_ActivityV3HelperAutoSort_Mock('VIP_NH', '24', 'a', '1');
    }
    public function testRun()
    {
        $floors = array(
            1 => array(
                'type' => 'data',
                'mixedData' => array(
                    array('id' => 11),
                    array('id' => 12),
                    // ……
                ),
            ),
            2 => array(
                'type' => 'data',
                'mixedData' => array(
                    array('id' => 21),
                    array('id' => 22),
                    // ……
                ),
            ),
            3 => array(
                'type' => 'html',
                'mixedData' => 'haha~',
            ),
        );
        $this->moduleActivityV3HelperAutoSort->run($floors);

        $this->assertNotEmpty($floors);
        $this->assertEquals(13, $floors[1]['mixedData'][0]['id']);
        $this->assertEquals(11, $floors[1]['mixedData'][1]['id']);
        // 更多精确的断言 ...
    }
}

而对于接口结果的返回,则通过下面的代码来模拟实现。

class Module_ActivityV3HelperAutoSort_Mock extends Module_ActivityV3HelperAutoSort {
    protected function _callApi($params) {
        return array(
            'code' => 200,
            'data' => array(
                array('brand_id' => 11, 'is_fixed' => 0, 'sequence' => 2),
                array('brand_id' => 12, 'is_fixed' => 0, 'sequence' => 3),
                // ……
                ),
            );
    }
}

技巧9:对protected方法进行模拟替换

在一个复杂的业务处理场景中,我们通常会把不同的子操作提取为protected成员函数。但过多的路径又难以覆盖到各个成员函数的调用。或者说,我们既想能模拟到这些protected方法的操作,又能真实对其进行调用。
这时,可通过继承子类添加成员属性的开关方式来进行控制。
为了简单,容易理解,假设我们有这样的收礼场景:

<?php
class Gift {
    public function pickup($uuid, $orderSn) {
        if ($this->hasBeenPickuped($orderSn)) {
            return;
        }
        // ... ...
    }
    protected function hasBeenPickuped($orderSn) {
        // ... ...
    }
}

为了造一个礼物已领取的假象,我们可以先这样添加一个模拟子类:

<?php
class Gift_Mock extends Gift {
    public $isPickUp = null;
    public function hasBeenPickuped($orderSn) {
        return $this->isPickUp === null ? parent::hasBeenPickuped($orderSn) : $this->isPickUp;
    }
}

请注意到两点,一点是我们在模拟子类中添加了成员属性$isPickUp,另一点我们将hasBeenPickuped()方法的访问级别提升到了public。
接着,但可这样同时进行模拟或真实的测试:

<?php
$mock = new Gift_Mock();
$mock->pickup($uuid, $orderSn);     //真实调用

$mock->isPickUp = true;
$mock->pickup($uuid, $orderSn);     //模拟已领取

$mock->isPickUp = false;
$mock->pickup($uuid, $orderSn);     //模拟非领取

综合以上九个造假技巧,可以发现一个原则:测试代码与产品代码分离,且测试时不能改动任何产品代码。此外,产品代码应尽量提供一个服务入口,即缝纫点,以便使用桩、替身。

4 断言的艺术

PHPUnit官方本身提供了40多个断言,例如常见的有:assertEquals()、assertArrayHasKey()、assertCount()、assertContains()等。此外,还有对输出、异常、正则等的断言。另外,在对实际项目进行单元测试时,还要注意两方面的断言。一类是对Controller类渲染输出的页面内容的断言,一类是针对接口服务返回的数据结果进行断言。如果需要渲染页面,可能使用的是原生PHP的模板方式,也有可能使用的是诸如Smarty、Twig这样的模板引擎。不管是何种渲染方式或视图模板格式,在对输出页面内容进行断言时,建议不要直接对输出内容断言,也不要直接对页面内容断言,而是改为对提供给视图模板的数据进行断言。这是有好处的。一方面,通常前、后端开发是分开的,在协作开发过程中,很有可能后端代码已经实现,如果依赖前端提供的模板文件才能测试,完成断言的话,工作进度就会受到阻塞,达不到高效流转的效果。另一方面,如果数据与视图融合在一起,想要还原或提炼数据就非常困难。虽然可以使用正则或者对输出的内容进行断言,但那样往往都是取巧的做法,不够严谨,同时成本也很大。何必那么复杂呢?直接对提供给视图的数据进行断言即可,如果数据都是正确的,那么接下来只需要关注页面展示是否正确就可以了。
同样地,针对接口服务返回的结果,如果想进行自我验证,在测试时,不需要真的输出最终的返回结果。而是针对最终将要返回的数据结果本身进行断言即可,因为返回的方式和格式也有可能是多种多样的,比如HTTP+JSON格式,SOAP+对象格式,JSONP格式等。这里所说的断言艺术,很简单,那就是对最原始的数据进行断言,而非对二次处理输出的结果进行断言。

发表评论