PHP单元测试基础实践(PHPUnit)
新建一个空的项目目录php-project,一下我们使用composer来管理类的自动加载,在cd到项目录,执行一下命令执行composer初始化
composer init
执行完成后项目根目录会生成composer.json配置文件
{
"name": "linjunda/phptest",
"authors": [
{
"name": "jeanslin",
"email": "jeanslin@xxx.com"
}
]
}
安装phpunit
我们使用composer安装phpunit
composer require --dev phpunit/phpunit
安装完成后在项目根目录的vendor/bin/目录会出现phpunit的可执行文件
创建phpunit.xml配置文件
phpunit.xml放置在项目的根目录中,这个文件是phpunit默认读取的配置文件
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="./test/bootstrap.php">
<testsuites>
<testsuite name="Tests">
<directory suffix="Test.php">./test</directory>
</testsuite>
</testsuites>
</phpunit>
此处的配置为:执行单元测试执行的初始化文件为“./test/bootstrap.php”,其中我们配置了一个测试套件(testsuite),该测试套件的名称为"Tests",将执行./test目录以"Test.php"结尾的文件。
其中“./test/bootstrap.php”文件内容如下:
<?php
require 'vendor/autoload.php';
此处加载了vendor/autoload.php文件,用以实现类的自动加载。
配置单元测试自动加载
我们在项目根目录里面创建一个app目录用来存放应用代码,创建一个Test目录用来存放单元测试的代码,目录结构如下:
├── app
├── composer.json
├── composer.lock
├── phpunit.xml
└── test
├── bootstrap.php
└── unit
然后我们在composer.json添加autoload规则,使用psr-4的自动加载类,文件内容如下:
{
"name": "linjunda/phptest",
"authors": [
{
"name": "jeanslin",
"email": "jeanslin@xxx.com"
}
],
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Test\\": "test/"
}
}
}
添加 autoload 字段后,你应该再次运行 composer install 命令来生成 vendor/autoload.php 文件。
编写一个测试用例
我们先写一个用于测试的对象类,目录为app/Service/MyLogic.php,里面我们写了一个待测试的add方法用以实现两个数相加的简单逻辑,内容如下
<?php
namespace App\Service;
class MyLogic
{
public function add($num1, $num2)
{
return $num1 + $num2;
}
}
然后我们为MyLogic创建一个单元测试类,目录为test/unit/Service/MyLogicTest.php,内容如下
<?php
namespace Test\unit\Service;
use App\Service\MyLogic;
use PHPUnit\Framework\TestCase;
class MyLogicTest extends TestCase
{
public function testAdd()
{
$logic = new MyLogic();
$ret = $logic->add(1,1);
$this->assertSame($ret,2);
}
}
此处我们通过对MyLogic::add方法进行测试,并断言其返回的结果。
编写完成后代码目录结构如下:
├── app
│ └── Service
│ └── MyLogic.php
├── composer.json
├── composer.lock
├── phpunit.xml
└── test
├── bootstrap.php
└── unit
└── Service
└── MyLogicTest.php
执行单元测试
先进入到项目根目录,我们使用vendor/bin/phpunit(使用composer安装phpunit后存在)执行单元测试
./vendor/bin/phpunit -c ./phpunit.xml
输出结果如下:
PHPUnit 9.5.8 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.003, Memory: 6.00 MB
OK (1 test, 1 assertion)
此结果显示了执行了1个单元测试,1个断言,结果为OK
基境(fixture)
编写代码来将整个场景设置成某个已知的状态,并在测试结束后将其复原到初始状态,这个已知的状态称为测试的基境(fixture)
单元测试四个阶段:
- 建立基境(fixture)
- 执行被测系统
- 验证结果
- 拆除基境(fixture)
PHPUnit 支持共享建立基境的代码:
- setUpBeforeClass(): 在测试用例类的第一个测试运行之前调用
- setUp():在运行某个测试方法前调用
- tearDown():当测试方法运行结束后调用,不管是成功还是失败都会调用
- tearDownAfterClass():在测试用例类的最后一个测试运行之后调用
- onNotSuccessfulTest(): 当测试用例类有不成功的测试方法时调用
一下我们用一个例子来说明一下,以下是一个被测试类来模拟数据库的插入和更新方法,我们针对该类构造一个测试基境。
class Table
{
//模拟插入方法
public function insert(&$data, $row)
{
$id = uniqid();
$data[$id] = $row;
return $id;
}
//更新方法
public function update(&$data, $id, $row)
{
$data[$id] = $row;
}
}
单元测试类为:
use PHPUnit\Framework\TestCase;
class TableTest extends TestCase
{
private static $tableLink; //模拟数据库连接
private $tableData; //模拟表数据
//该方法在第一个测试方法前执行
public static function setUpBeforeClass(): void
{
echo __METHOD__ . "\n";
self::$tableLink = new Table();//初始化表对象
}
//该方法在调用每个测试方法前执行
public function setUp(): void
{
echo "\n" . __METHOD__ . "\n";
//设置基境(测试数据)用于测试
$this->tableData = [
'id' => 'this is init row data',
];
}
//测试插入方法
public function testInsert()
{
echo __METHOD__ . "\n";
$rowData = 'this is row data.';
$id = self::$tableLink->insert($this->tableData, $rowData);
$this->assertSame($this->tableData[$id], $rowData, '插入失败');
}
//测试更新方法
public function testUpdate()
{
echo __METHOD__ . "\n";
$rowId = 'id';
$rowData = 'this is update data';
self::$tableLink->update($this->tableData, $rowId, $rowData);
$this->assertSame($this->tableData[$rowId], $rowData, '更新失败');
}
//该方法在调用每个测试方法后执行
public function tearDown(): void
{
echo __METHOD__ . "\n";
//在此处我们可以拆除基境,恢复原来的数据
unset($this->tableData['id']);
}
//该方法在调用最后一个测试方法后执行
public static function tearDownAfterClass(): void
{
echo __METHOD__ . "\n";
self::$tableLink = null; //模拟释放数据库链接
}
}
./vendor/bin/phpunit -c phpunit.xml 执行结果为:
TableTest::setUpBeforeClass
.
TableTest::setUp
TableTest::testInsert
TableTest::tearDown
. 2 / 2 (100%)
TableTest::setUp
TableTest::testUpdate
TableTest::tearDown
TableTest::tearDownAfterClass
Time: 00:00.010, Memory: 6.00 MB
OK (2 tests, 2 assertions)
数据提供器
数据提供器可以在测试方法提供任意组入参,用 @dataProvider
标注来指定要使用的数据供给器方法。
以下我们通过一个例子来说明,以下方法有3个入参,方法里面有3个分支:
class Branch
{
public function operate($op, $num1, $num2)
{
$ret = 0;
if ($op == 'add') {//两数相加
$ret = $num1 + $num2;
} else if ($op == 'sub') {//两数相减
$ret = $num1 - $num2;
} else {
$ret = $num1 * $num2;
}
return $ret;
}
}
我们用数据提供器来测试以上方法的3个分支
use PHPUnit\Framework\TestCase;
class BranchTest extends TestCase
{
/**
* operate方法数据提供器
* @return array[]
*/
public function operateProvider()
{
return [
['add', 2, 1, 3],//测试加法
['sub', 2, 1, 1],//测试减法
['mul', 2, 2, 4],//测试乘法
];
}
/**
* @param string $op 操作
* @param int $num1 左操作数
* @param int $num2 右操作数
* @param int $ret 结果
* @dataProvider operateProvider
*/
public function testOperate($op, $num1, $num2, $ret)
{
echo "\n".__METHOD__ . "\n";
$branch = new Branch();
$this->assertSame($branch->operate($op, $num1, $num2), $ret);
}
}
单元测试结果为(可见testOperate被执行了3次):
.
App\Service\BranchTest::testOperate
.
App\Service\BranchTest::testOperate
. 3 / 3 (100%)
App\Service\BranchTest::testOperate
Time: 00:00.004, Memory: 6.00 MB
OK (3 tests, 3 assertions)
测试替身
单元测试侧重于应用程序的单个组件。组件的所有外部依赖项都应替换为测试替身。
PHPUnit 提供了以下方法来自动生成对象,此对象可以充当任意指定原版类型(接口或类名)的测试替身。
- createStub():用来创建一个桩件(stub),伪造一个方法,阻断对原来方法的调用。
- createMock():用来创建一个仿件(mock),返回指定类型(接口或类)的测试替身实例,像stub一样伪造方法,阻断对原来方法的调用,并且期望程序执行必须调用这个伪造的方法,如果没有被调用到,测试就失败了
- getMockBuilder():可以用getMockBuilder()方法来创建使用了流式接口的类的测试替身
注意:默认情况下,原版类的所有方法都会被替换为只会返回null的伪实现(其中不会调用原版方法),final、private与static,无法对其进行上桩(stub)或模仿(mock)
桩件(Stubs)
将对象替换为(可选地)返回配置好的返回值的测试替身的实践方法称为打桩(stubbing),以下我们通过一个例子来说明:
想要打桩的类:
<?php declare(strict_types=1);
class SomeClass
{
public function doSomething()
{
// 随便做点什么。
}
}
对某个方法的调用进行上桩,返回固定值
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class StubTest extends TestCase
{
public function testStub(): void
{
// 为 SomeClass 类创建桩件。
$stub = $this->createStub(SomeClass::class);
// 配置桩件。
$stub->method('doSomething')
->willReturn('foo');
// 现在调用 $stub->doSomething() 会返回 'foo'。
$this->assertSame('foo', $stub->doSomething());
}
}
仿件对象(Mock Object)
将对象替换为能验证预期行为(例如断言某个方法必会被调用)的测试替身的实践方法称为模仿(mocking)。
以下我们用一个观察者模式的例子来说明
//主题类
class Subject
{
protected $observers = [];
protected $name;
public function __construct($name)
{
$this->name = $name;
}
public function getName()
{
return $this->name;
}
//添加观察者
public function attach(Observer $observer)
{
$this->observers[] = $observer;
}
public function doSomething()
{
// 随便做点什么。
// ...
// 通知观察者我们做了点什么。
$this->notify('something');
}
//通知已监听观察者的方法
protected function notify($argument)
{
foreach ($this->observers as $observer) {
$observer->update($argument);
}
}
}
//观察者类
class Observer
{
public function update($argument)
{
// 随便做点什么。
}
}
测试某个方法会以特定参数被调用一次
use PHPUnit\Framework\TestCase;
final class SubjectTest extends TestCase
{
public function testObserversAreUpdated(): void
{
// 为 Observer 类建立仿件
// 只模仿 update() 方法。
$observer = $this->createMock(Observer::class);
// 为 update() 方法建立预期:
// 只会以字符串 'something' 为参数调用一次。
$observer->expects($this->once())
->method('update')
->with($this->equalTo('something'));
// 建立 Subject 对象并且将模仿的 Observer 对象附加其上。
$subject = new Subject('My subject');
$subject->attach($observer);
// 在 $subject 上调用 doSomething() 方法,
// 我们预期会以字符串 'something' 调用模仿的 Observer
// 对象的 update() 方法。
$subject->doSomething();
}
}
getMockBuilder
替身的创建使用了最佳实践的默认值(不可执行原始类的__construct()和__clone()方法,且不对传递给测试替身的方法的参数进行克隆),如果这些默认值非你所需,可以用getMockBuilder()方法来创建使用了流式接口的类的测试替身
以下我们用一个例子说明,此处a方法内部调用b方法,建设b方法调用的代价非常大(如调第三方接口、操作数据库等),我们就可以用getMockBuilder进行模仿,让其返回指定的结果值
class MyLogic
{
public function a($value='')
{
$bRet = $this->b($value);
return "a:".$bRet;
}
public function b($value='')
{
return "b:".$value;
}
}
use PHPUnit\Framework\TestCase;
class MyLogicTest extends TestCase
{
public function testA()
{
$value = 'test';
//获取模仿对象
$logic = $this->getMockBuilder(MyLogic::class)->setMethods(['b'])->getMock();
//给MyLogic::b方法上桩,让其返回"c:".$value(原方法为"b:".$value)
$logic->expects($this->any())->method('b')->willReturn("c:".$value);
//调用a方法
$ret = $logic->a($value);
$this->assertSame($ret, 'a:c:' . $value);
}
静态方法上桩
由于PHPUnit的局限性,无法对final、private与static方法进行上桩(stub)或模仿(mock),因此我们需要借助第三方扩展包AspectMock实现该场景。
安装AspectMock
composer require --dev codeception/aspect-mock
如果在phpunit集成AspectMock,需要在phpunit的bootstrap.php文件配置AspectMock
<?php
require 'vendor/autoload.php';
//初始化AspectMock
$kernel = \AspectMock\Kernel::getInstance();
$kernel->init([
'debug' => true,
'includePaths' => [__DIR__ . '/../app'],
'excludePaths' => [__DIR__], // tests dir should be excluded
'cacheDir' => __DIR__ . '/../runtime',
]);
接下来我们就是对静态方法进行模仿或上桩了,下面我们用一个例子来说明AspectMock的用法
被测试的类
class A
{
public static function doSomeThings()
{
return 'a:' . B::doSomeThings();
}
}
class B
{
public static function doSomeThings()
{
return "b";
}
}
上面A类的doSomeThings方法调用了B类的doSomeThings方法,假设B::doSomeThings调用的代价比较高,我们需要对该方法进行上桩
use AspectMock\Test;
use PHPUnit\Framework\TestCase;
class ATest extends TestCase
{
public function testDoSomeThings()
{
test::double(B::class, ['doSomeThings' => 'c']);
$ret = A::doSomeThings();
echo "\n ret: $ret \n";
$this->assertSame("a:c", $ret);
}
}
单元测试执行结果如下:
. 1 / 1 (100%)
ret: a:c
Time: 00:00.047, Memory: 10.00 MB
OK (1 test, 1 assertion)
至此我们实现了静态方法的上桩。
参考文章:
https://phpunit.readthedocs.io/zh_CN/latest/installation.html
https://github.com/Codeception/AspectMock
如果以上文章对你有用,请点个赞吧^_^