一、背景
如果在项目中经常用phpunit来做单元测试的话(所以看此文章的伙伴们需要单元测试基础),应该都知道,最重要的是依赖的模拟,也就是仿件或者打桩,所以你一定遇到过各种情况的依赖模拟困难,最近我就遇到一个大部分人或者代码中都会出现的模拟依赖困难的情况。这也是本文中通用讲到的一个例子,场景如下:
其中我们只需测试UserService类,UserService类写成代码为:
<?php
namespace app\service\tanjiajun;
use app\lib\App;
use app\model\tanjiajun\UserModel;
class UserService
{
public function getUserOrderList()
{
$userModel = App::make(UserModel::class);
$userList = $userModel::find()
->select()
->asArray()
->all();
$result = [];
foreach ($userList as $user) {
$result[$user['uid']] = $userModel->getOrdersByUid($user['uid']);
}
return $result;
}
}
其中App::make()方法是框架中的方法,在IOC容器中取出一个对象,相当于new UserModel()。
二、继承版Mock仿件
看完UserService类后,我们知道它依赖了UserModel的各种查询方法,有一些Model自带的方法,如select、all和自定义的方法getOrdersByUid()。要测试这个UserService类,我们必须把UserModel的这些方法给模拟掉才行,因为我们不能让UserModel的变化而影响结果的断言。要怎么模拟呢?如果用phpunit本身自带的桩件和Mock是做不到的,除了这个,一般采用一种匿名类继承被模拟类,然后覆盖父类(也就是被模拟类)的一些方法。所以,我们可以这么来写UserModel的Mock,下面是测试类的测试方法:
<?php
namespace tests;
use app\lib\App;
use app\service\tanjiajun\UserService;
use app\model\tanjiajun\UserModel;
class UserServiceTest extends TestCase
{
/**
* @test
*/
public function getUserList()
{
/*创建UserModel的仿件,继承被模拟类方式*/
$userModelMock = new class extends UserModel
{
private static $returnMap = [];
public static function find()
{
return self::$returnMap['find'] ?: null;
}
public function slave()
{
return self::$returnMap['slave'] ?: null;
}
public function select()
{
return self::$returnMap['select'] ?: null;
}
public function asArray()
{
return self::$returnMap['asArray'] ?: null;
}
public function all()
{
return self::$returnMap['all'] ?: null;
}
public function getOrdersByUid($uid)
{
return self::$returnMap['getOrdersByUid'][$uid] ?: null;
}
public function setReturnMap($map)
{
self::$returnMap = $map;
}
};
$map = [
'find' => $userModelMock,
'select' => $userModelMock,
'slave' => $userModelMock,
'asArray' => $userModelMock,
'all' => [
['uid' => 1, 'name' => 'jack'],
['uid' => 2, 'name' => 'tom'],
],
'getOrdersByUid' => [
'1' => ['uid' => 1, 'order_id' => 1, 'name' => 'jack'],
'2' => ['uid' => 2, 'order_id' => 2, 'name' => 'tom'],
]
];
$userModelMock->setReturnMap($map);//设置仿件返回值
App::getContainer()->instance(UserModel::class, $userModelMock);//替换IOC容器中的UserModel
//调用被测类UserService
$userService = App::make(UserService::class);
$ret = $userService->getUserOrderList();
//断言结果
$this->assertEquals([
'1' => ['uid' => 1, 'order_id' => 1, 'name' => 'jack'],
'2' => ['uid' => 2, 'order_id' => 2, 'name' => 'tom'],
], $ret);
}
}
这种方法是同个继承UserModel类,然后重写掉在UserService调用的一些方法,然后通过一个指定map变量,返回我们期望的值。这种方法的好处是:
1、完全基于UserModel的特性环境去改造返回
2、实现比较简单
而坏处是:
1、建立的仿件userModelMock代码量太多
2、重复的复写了很多返回一样的配置,如select、find这些方法
3、无法复用,只能是在特定的方法中使用,如果下一个被测service还是用到这些方法,还得写一次同样代码
三、通用的万能仿件SupperMock
基于上面的实现方式带来的缺点,我们是不是可以改装一下,把仅限于UserModel的Mock改成通过的方法或者类去生成呢?要通过,必须解决这几点:
1、像model这种本身已经具有的基础方法,像select、where、find等,很多时候都是用来做连贯操作查询的,我们统统默认返回$this,也就是当前类。怎么实现呢,我们这里用了一个小技巧,也是实现万能仿件的关键,就是魔术方法__call()和__callStatic()。这次我们的匿名类不用继承被仿类,直接当调用者调用到不存在的方法,如select、where等时,默认返回$this。而当调用到需求返回特定结果的方法时,读预先配置好的返回Map数组,返回指定的结果即可。这样达到的效果就是动态的生成了类中的方法,这也是我们这个仿件中非常关键的特性。
2、对于方法输入不同的参数,返回不同值的配置Map又怎么去实现?这里我们直接用方法名+参数做数据Map的key,但是参数可能是数组,所成生产唯一key的方法变成MD5(方法名+json_encode(输入参数数组))。
所有问题都解决后,大致关系流程总结如下:
代码实现为
单元测试类UserService.php(仿件调用方)
<?php
namespace tests;
use app\lib\App;
use app\service\tanjiajun\UserService;
use app\model\tanjiajun\UserModel;
class UserServiceTest extends TestCase
{
/**
* @test
*/
public function getUserList()
{
/*创建UserModel的仿件*/
$userModelMock = $this->createSuperMock(UserModel::class);
/*设置普通方法返回的Map*/
$methodMap = [
'all' => array(
array('return' => [['uid' => 1, 'name' => 'jack'], ['uid' => 2, 'name' => 'tom']])
),
'getOrdersByUid' => array(
/*args为方法输入参数,return是对应返回值,args为null的话默认返回当前类$this*/
array('return' => ['uid' => 1, 'order_id' => 1, 'name' => 'jack'], 'args' => [1]),
array('return' => ['uid' => 2, 'order_id' => 2, 'name' => 'tom'], 'args' => [2]),
),
];
$userModelMock->willReturn($methodMap);
/*设置静态方法返回的Map*/
$staticMethodMap = [
'find' => array(
array('return' => $userModelMock)
)
];
$userModelMock::staticWillReturn($staticMethodMap);
App::getContainer()->instance(UserModel::class, $userModelMock);//替换IOC容器中的UserModel
//调用被测类UserService
$userService = App::make(UserService::class);
$ret = $userService->getUserOrderList();
//断言结果
$this->assertEquals([
'1' => ['uid' => 1, 'order_id' => 1, 'name' => 'jack'],
'2' => ['uid' => 2, 'order_id' => 2, 'name' => 'tom'],
], $ret);
}
}
创建SupperMock::class的统一方法,我放在了TestCase.php下
TestCase.php
<?php
namespace tests;
use app\lib\App;
use tests\mock\SupperMock;
class TestCase extends \PHPUnit\Framework\TestCase
{
/**
* 创建超级仿件
* @param String $className
* @return mixed
*/
public function createSuperMock(String $className)
{
return App::makeWith(SupperMock::class, ['className' => $className]);
}
}
最后是这个万能仿件SupperMock.php
<?php
/**
* 超级仿件
* User: TanJiaJun
* Date: 2018/11/10
* Time: 14:25
*/
namespace tests\mock;
class SupperMock
{
private $methodReturnMap;
protected $mockClass;
protected static $mockClassName;
protected static $mockClassMethod;
protected $mockClassStaticMethod;
public static $staticMethodReturnMap = [];
public function __construct($className)
{
self::$mockClassName = $className;
}
/**普通方法返回处理
* @param $name
* @param $arguments
* @return SupperMock
*/
function __call($name, $arguments)
{
$mapKey = $this->generateMapKey($name, $arguments);
return $this->methodReturnMap[$mapKey] ?: $this;
}
/**静态方法返回处理
* @param $name
* @param $arguments
* @return SupperMock
*/
function __callStatic($name, $arguments)
{
$mapKey = self::generateMapKey($name, $arguments);
return self::$staticMethodReturnMap[$mapKey] ?: new self(self::$mockClassName);
}
/**
* 设置普通方法返回Map
* @param $willReturn
*/
public function willReturn($willReturn)
{
foreach ($willReturn as $method => $methodMap) {
foreach ($methodMap as $val) {
$mapKey = $this->generateMapKey($method, $val['args']);
$this->methodReturnMap[$mapKey] = $val['return'];
}
}
}
/**设置静态方法返回Map
* @param $willReturn
*/
public static function staticWillReturn($willReturn)
{
foreach ($willReturn as $method => $methodMap) {
foreach ($methodMap as $val) {
$mapKey = self::generateMapKey($method, $val['args']);
self::$staticMethodReturnMap[$mapKey] = $val['return'];
}
}
}
/**
* 生产MapKey:MD5(方法名+json_encode(参数))
* @param $method
* @param $args
* @return string
*/
private static function generateMapKey($method, $args)
{
if (empty($args)) {
return md5($method);
}
return md5($method . json_encode($args));
}
}
测试结果:
四、其他场景应用
例如service中依赖了cache之类的
service类
<?php
namespace app\service\tanjiajun;
use app\lib\App;
class CommonService
{
public function testMc($key = "")
{
$cache = App::getCache();
$mc = $cache::getMemcached();
return $mc->get($key);
}
}
依赖的缓存工具类
class Cache {
public static function getMemcached($server_id = 2) {
$cacheKey = __METHOD__ . '-' . $server_id;
return Process::staticCache($cacheKey, function() use ($server_id) {
$serverInfo = get_memcache_config_array()[$server_id] ?? null;
if (empty($serverInfo)) {
throw new ConfigException('Memcached缓存配置不存在');
}
if (is_array($serverInfo)) {
$host = $serverInfo['host'];
$port = $serverInfo['port'];
$user = $serverInfo['user'];
$pwd = $serverInfo['pwd'];
} else {
list($host, $port) = explode(':', $serverInfo);
}
$memcached = new Memcached();
$memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); //使用binary二进制协议
$memcached->addServer($host, $port); //添加实例地址 端口号
if(!empty($user)) {
$memcached->setSaslAuthData($user, $pwd); //设置OCS帐号密码进行鉴权
}
return $memcached;
});
}
}
测试类
<?php
namespace tests;
use app\lib\App;
use app\service\tanjiajun\CommonService;
use infra\tool\Cache;
class CommonServiceTest extends TestCase
{
/**
* @test
*/
public function testMc()
{
/*创建MemcacheMock*/
$mcMock = $this->createSuperMock("Memcache");
$mcMap = [
'get' => array(
array('return' => 'key1_result', 'args' => ['key1']),
array('return' => 'key2_result', 'args' => ['key2']),
),
];
$mcMock->willReturn($mcMap);
/*创建CacheMock*/
$cacheMock = $this->createSuperMock(Cache::class);
$staticMethodMap = [
'getMemcached' => array(
array('return' => $mcMock)
)
];
$cacheMock::staticWillReturn($staticMethodMap);
App::getContainer()->instance(Cache::class, $cacheMock);//替换IOC容器中的Cache
$testObj = App::make(CommonService::class);
$ret = $testObj->testMc('key1');
$this->assertEquals('key1_result', $ret);
}
}