一、测试入门
1.1 安装xdebug,查看代码测试覆盖率
安装xdebug:https://www.cnblogs.com/wlphp...
./vendor/bin/phpunit --coverage-html ./tests/codeCoverage
1.2 PHPUnit 命令
1.创建测试类
php artisan make:test XXXTest --unit
2.使用
--filter
参数指定单元测试执行的函数
vendor\bin\phpunit --filter test_oper
/** @test **/
方法明前就不用加test
了。
php artisan make:test ArticleTest --unit
1.3 对于每个测试的运行,PHPUnit 命令行工具输出一个字符来指示进展:
. 成功时输出
F 运行过程中一个断言失败时输出
E 运行过程中产生一个错误时输出
R 被标记为有风险时输出
S 被跳过时输出
I 被标记为不完整或未实现时输出
w 运行过程中产生一个警告
1.4 基境(fixture)
什么是基境(fixture)?基境(fixture)是对开始执行某个测试时应用程序和数据库所处初始状态的描述。
PHPUnit 支持共享建立基境的代码。在运行某个测试方法前,会调用一个名叫 setUp() 的模板方法。setUp() 是创建测试所用对象的地方。当测试方法运行结束后,不管是成功还是失败,都会调用另外一个名叫 tearDown() 的模板方法。tearDown() 是清理测试所用对象的地方。
在编写测试用例的时候可能最费时间的就是编写那些将程序设置到使用状态和测试完毕之后将其再设置回初始状态的代码了。PHPUnit给我们提供了setUp和tearDown这两个方法来解决这个问题。
测试类的每个测试方法都会运行一次 setUp() 和 tearDown() 模板方法(同时,每个测试方法都是在一个全新的测试类实例上运行的)。
如果你把登录写在TestCase抽象类,那么每个测试方法都会创建一个新的用户。
protected $user;
public function signIn($user = null)
{
if(! $user){
$user = factory('User::class')->create();
}
$this->actingAs($user);
$this->user = $user;
return $this;
}
setUpBeforeClass() 与 tearDownAfterClass() 模板方法将分别在测试用例类的第一个测试运行之前和测试用例类的最后一个测试运行之后调用。
1.5 数据库配置
我们删除了以下形如DB_XXXX
的配置信息:
'sqlite_testing' => [
'driver' => 'sqlite',
'database' => env('DB_DATABASE', database_path('database_testing.sqlite')),
'prefix' => '',
],
$ touch database/database_testing.sqlite
php artisan migrate --database=sqlite_testing
touch database/database_testing.sqlite
清理数据库:
use DatabaseTransactions;
DB::connection('sqlite_testing')->tables('xxx')->truncate();
php artisan migrate --database=sqlite_testing
重新生成一遍:php artisan migrate:refresh --database=sqlite_testing
二、单元测试进阶
1.1 一个单元测试通常包含3个步骤
(1)准备对象,创建对象,进行必要的设置,也包括建立初始数据。
(2)操作对象
(3)断言某件事情符合预期
1.2 入门:使用参数化测试
isValidLogFileName($filename);
$this->assertEquals($result, $boo);
}
public function isValidFileName_Provider()
{
return array(
array("file_with_bad_extension.foo", false),
array("file_with_good_extension.slf", true),
array("file_with_good_extension.SLF", true),
);
}
}
数据提供器
PHPUnit 还可以通过@dataProvider
注解为多个测试用例提供初始化数据:
public function initDataProvider()
{
return [
['学院君'],
['Laravel学院']
];
}
/**
* @depends testInitStack
* @dataProvider initDataProvider
*/
public function testIsStackContains()
{
$arguments = func_get_args();
$service = $arguments[1];
$value = $arguments[0];
$this->assertTrue($service->stackContains($value));
}
/**
* @dataProvider provider
*/
public function testAdd($a, $b, $c)
{
$this->assertEquals($c, $a + $b);
}
public function provider()
{
return array(
array(0, 0, 0),
array(0, 1, 1),
array(1, 1, 1),
array(1, 2, 3)
);
}
在这个测试用例中,我们通过initDataProvider
方法作为数据提供器,数据供给器方法必须声明为public
,其返回值要么是一个数组,其每个元素也是数组;要么是一个实现了Iterator
接口的对象,在对它进行迭代时每步产生一个数组。每个数组都是测试数据集的一部分,将以它的内容作为参数来调用测试方法。
然后我们在需要用到这个数据提供器的测试用例上用@dataProvider
注解进行声明,在这个示例中我们迭代数据提供器数组,将其中的数据作为参数传入TestService
的stackContains
方法以判断对应值在stack
属性中是否存在。
1.3 入门:异常测试
/**
* 判断文件名是否有效,.slf结尾的文件名就是有效的,返回真
* @param string $filename
* @return bool
*/
public function isValidLogFileName($filename)
{
if (empty(trim($filename))) {
throw new \Exception("参数不能为空的异常出现");
}
if (!preg_match('/\.SLF$/i', $filename)){
return false;
}
return true;
}
注意,异常的判断放到了php的方法注释里去了,看起来很先进的样子。同时注意到,这里不但断言了异常的类型是Exception,同时断言了异常包含的消息内容
。也可以不断言消息内容,随意。
/**
* @test
* @expectedException Exception
* @expectedExceptionMessage 参数不能为空的异常出现
*/
public function isValidFileName_EmptyFileName_Throws()
{
$this->analyzer->isValidLogFileName('');
// $this->fail('参数不能为空的异常出现');
}
异常测试
/**
* @expectedException InvalidArgumentException
*/
public function testTagException()
{
throw new InvalidArgumentException;
}
public function testApiException()
{
$this->setExpectedException('InvalidArgumentException');
throw new InvalidArgumentException;
}
public function testTryException()
{
try
{
throw new InvalidArgumentException;
}
catch (InvalidArgumentException $expected)
{
return;
}
$this->fail('An expected exception has not been raised.');
}
1.3 入门:函数依赖测试
public function testOne()
{
echo "testOne\n";
$this->assertTrue(TRUE);
}
public function testTwo()
{
echo "testTwo\n";
$this->assertTrue(FALSE);
}
/**
* @depends testOne
* @depends testTwo
*/
public function testThree()
{
}
@depends标签还可以依赖返回值。
public function testOne()
{
$this->assertTrue(TRUE);
return "testOne";
}
public function testTwo()
{
$this->assertTrue(TRUE);
return "testTwo";
}
/**
* @depends testOne
* @depends testTwo
*/
public function testThree($param1, $param2)
{
echo 'First param: '.$param1."\n";
echo 'Second param: '.$param2."\n";
}
值得注意的是,函数的顺序与依赖标签的数序一致。
主要就是各种标签的使用,标记函数和自动生成测试代码
1.标签使用
1)@depends
2)@dataProvider
2. 标记函数
1)markTestSkipped
2)markTestIncomplete
3. 自动生成测试框架代码
phpunit --skeletion-test YourClassName ?????
1.4 核心技术 - 桩件(stub)
一个桩件(stub)是对系统中存在的一个依赖项(或者协作者)的可控制的替代物。通过使用桩件,你在测试代码时无需直接处理这个依赖项。
1.4.1 构造函数注入桩件
public function setUp()
{
parent::setUp(); // TODO: Change the autogenerated stub
$this->fakeExtensionManager = app(FakeExtensionManager::class);
$this->analyzer = new LogAnalyzer($this->fakeExtensionManager);
}
/**
* @test
* 使用构造器注入桩件的方法 进行测试
* 注意,尽量使得测试的方法名称有意义,这非常重要,便于维护测试代码。有规律
*/
public function isValidFileName_NameSupportedExtension_ReturnTrue()
{
//准备好一个返回true的桩件
$this->fakeExtensionManager->willBeValid = true;
//开始创建被测类的对象,准备测试
$result = $this->analyzer->isValidLogFileNameByIJ("short.ext");
$this->assertTrue($result);
}
1.4.2 属性注入桩件
上一篇文章介绍了如何用构造方法注入桩件,代码特别容易看懂。可是缺点是修改了原先的设计,改构造方法算是修改了代码意图,同时假如桩件太多,代码就特别丑陋。 可以用依赖注入类库例如pimple来解决,但还是不好。
本文介绍使用属性获取和设置的方法来注入桩件,代码易读易写。其实这个方法和构造方法注入没有多大差别。
一个接口和它的两个实现无需改代码,需要修改的类有被测类日志分析器类,和测试类。
/**
* 日志分析器类,也是被测类
*
* 注意,这是用属性注入的例子。
*/
class LogAnalyzer
{
/**
* @var IExtensionManager
*/
private $manager;
public function __construct()
{
$this->manager = new FileExtensionManager();
}
public function setManager($mgr)
{
$this->manager = $mgr;
}
public function getManager()
{
return $this->manager;
}
/**
* 判断文件名是否有效,调用另一个类来实现
* @param string $filename
*/
public function isValidLogFileName($filename)
{
return $this->manager->isValid($filename);
}
}
/**
* 测试用的类
*/
class LogAnalyzerTest extends \think\testing\TestCase
{
/**
* @test
* 使用属性注入桩件的方法 进行测试
* 注意,尽量使得测试的方法名称有意义,这非常重要,便于维护测试代码。有规律
*/
public function isValidFileName_NameSupportedExtension_ReturnTrue()
{
//准备好一个返回true的桩件
$myFakeManager = new FakeExtensionManager();
$myFakeManager->willBeValid = true;
//开始创建被测类的对象,准备测试
$analyzer = new \app\index\controller\LogAnalyzer();
$analyzer->setManager($myFakeManager); // 属性注入
$result = $analyzer->isValidLogFileName("short.ext");
$this->assertTrue($result);
}
}
1.5 核心技术 - mock对象
工作单元可能有三种最终结果,目前为止你编写过的测试只针对前两种:返回值(_基于值的测试_)和改变系统状态(_基于状态的测试_)。
现在我们要检验一个对象(被测对象)是否正确的调用了其他对象(被调用对象)
,即检验_交互测试_。被测试的对象可能不会返回任何结果,或者保存任何状态。而被调用的对象不受你的控制,或者不是被测试单元的一部分。之前的办法不适用。因为没有外部API可以检验被测对象内部是否发生了变化。这时我们需要用mock,即模拟对象。
1.5.1 mock对象和桩件的差别
桩件:被测类和桩件通信交互,测试类中对被测类断言,永远不会对桩件断言。
mock对象:被测类和mock对象通信交互,测试类中,对mock对象断言。
1.5.2 手工创建mock对象
创建和使用mock对象的方法和使用桩件类似,只是mock对象比桩件多做一件事:它保存通信交互的历史记录,这些记录之后用于预期验证(就是可以被断言)。
原作者认为:
- 一个测试中,应该最多只有一个mock对象,所有其他伪对象都应该是桩件。如有多个mock对象,应分成多个测试,确保每个测试只有一个mock对象。
- 一个测试只能断言工作单元三种最终结果中的一种。3种结果是,断言返回值,断言对象或系统状态,断言对象交互。目的要明确。如果有多个不同的测试意图,应分成多个测试。
1.5.3 Mock使用进阶
public function testBit()
{
$oClientMock = $this->getMock('SomeClient'); // 创建mock对象
$oClientMock->expects($this->once()) // 设定次数
->method('ExecuteCommand') // 设定方法
->with(CPU_BIT_CMD) // 设定方法入参
->will($this->returnValue('some')); // 设定方法返回值
$oHardware = new MHardware($oClientMock);
$this->assertEquals('32', $oHardware->CpuBit()); // 调用方法并断言
}
简而言之,使用mock一般有下面几步:
- getMock创建mock对象(必须有)
- method设置期望调用的方法(必须有)
- expects设置方法调用的次数(必须有)
- with设置调用方法时的入参(可选)
- will设置调用方法后的返回值(可选)
(1)匹配器(Matchers)
匹配器相当于调用mock方法的量词,作为expects函数的参数传给mock对象,用于设定期望的调用次数,主要有下面几个:
once()期望方法只调用一次,否则测试失败
never()期望方法从不被调用,否则测试失败
any()期望调用任意次,测试永远不会因此失败。
(2)约束(Constraints)
约束和with
一起使用,用于设定mock
函数的输入,约束有很多,主要分为一下几大类
[数组]
arrayHasKey(mixed $key)断言入参数组是否有指定的键
contains(mixed $value)断言入参数组是否有指定的值
[字符串]
stringEndsWith( $suffix)断言入参是否有此后缀
stringStartsWith(string $prefix)断言入参是否有次前缀
[比较]
identicalTo($value)断言入参===当前值
[基本类型]
isFalse()断言当前值为FALSE
isNull()断言当前对象是否为NULL
isType($type)断言当前对象是某个具体的类型
[其他]
anything()接受任何入参
fileExists()断言当前入参代表的文件是否存在
(3)返回
设定返回值,与will
一起使用,用于设定mock函数的返回值,主要方法方法如下:
returnValue($value)返回字面意思
throwException($exception)此方法在调用时抛出指定异常
1.5.4 简单的测试例子
IFruit接口:
namespace Test;
interface IFruit
{
//获取水果的单格
public function getPrice();
//计算购买$number个单位的水果是需要的价格
public function buy($number);
}
Apple类:
namespace Test;
class Apple implements IFruit
{
public function getPrice()
{
return 5.6;
}
public function buy($number)
{
return $number * $this->getPrice();
}
}
Custom类:
namespace Test;
class Custom
{
private $totalMoney;
public function __construct($money)
{
$this->totalMoney = $money;
}
public function buy(IFruit $fruit, $number)
{
$needMoney = $fruit->buy($number);
if ($needMoney > $this->totalMoney) {
return false;
} else {
$this->totalMoney -= $needMoney;
return true;
}
}
}
use \Test\Apple;
use \Test\Custom;
class CustomTest extends PHPUnit_Framework_TestCase
{
protected $custom;
public function setUp()
{
$this->custom = new Custom(50);
}
public function testBuy()
{
$result = $this->custom->buy(new Apple(), 10);
$this->assertFalse($result);
}
}
1. 使用桩件(stub)减少依赖
public function testBuy()
{
$appleStub = $this->createMock(Apple::class);
$appleStub->method("buy")
->willReturn(5.6 * 10);
$result = $this->custom->buy($appleStub, 10);
$this->assertFalse($result);
}
2. 仿件对象(Mock)
public function testMockBuy()
{
$appleMock = $this->getMockBuilder(Apple::class)
->setMethods(['getPrice', 'buy'])
->getMock();
//建立预期情况,buy方法会被调用一次
$appleMock->expects($this->once())
->method('buy');
$this->custom->buy($appleMock, 10);
}
$request->merge(['id' => 31]);
其他
1. make VS create
你可能已经注意到,上面调用了 ->make() 而不是 ->create()。 这两种方法做了两件不同的事情:create 尝试将其存储在数据库中,跟 Eloquent 中的 save 方法一样。 而 make 仅仅只是创建了模型,不会向数据库插入数据。 如果你熟悉 Eloquent,make 执行起来就像这样:
$issue = new \App\Issue(['subject' => 'My Subject']);
2. 实战项目运行情况
整体覆盖率>80%
3. 注意事项
1. 不要在一个测试方法里断言多个分支,代码覆盖率覆盖不完全。
2.一个方法名不要用到其它方法名当前缀如:show_index和show_index_error
,当你--filter=show_index
的时候,show_index_error
测试方法也会被执行。
参考文档资料
(1)PHPUnit单元测试对桩件(stub)和仿件对象(Mock)的理解
(2)php单元测试进阶
(3)Testing Laravel