美文网首页
PHP单元测试基础实践(PHPUnit)

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

作者: JeansLin | 来源:发表于2021-08-22 18:07 被阅读0次

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

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

    相关文章

      网友评论

          本文标题:PHP单元测试基础实践(PHPUnit)

          本文链接:https://www.haomeiwen.com/subject/ztvsbltx.html