PHP单元测试基础实践(PHPUnit)

2021-08-22  本文已影响0人  JeansLin

新建一个空的项目目录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)

单元测试四个阶段:

PHPUnit 支持共享建立基境的代码:

一下我们用一个例子来说明一下,以下是一个被测试类来模拟数据库的插入和更新方法,我们针对该类构造一个测试基境。

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 提供了以下方法来自动生成对象,此对象可以充当任意指定原版类型(接口或类名)的测试替身。

注意:默认情况下,原版类的所有方法都会被替换为只会返回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

如果以上文章对你有用,请点个赞吧^_^

上一篇下一篇

猜你喜欢

热点阅读