m

全干工程师

PHP单元测试基础教程

单元测试这个词相信大家也不陌生,本文将告诉你如何使用phpunit进行php单元测试,测试覆盖率等一些问题。单元测试用例越多,我们程序产生的BUG相对越少。那什么是单元测试呢?

什么是单元测试

单元测试又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。通过示例我们来了解怎么编写单元测试,怎么运行单元测试。

工程

创建一个测试工程,我们使有composer来作为phpunit启动器,不再使用官方的phpunit.phar文件了。


[meshell@phpunit]# composer init

更改生成的composer.json文件,添加以下依赖库。

...

    "require": {
        "php": ">=7.2",
        "phpunit/phpunit": "^7",
        "friendsofphp/php-cs-fixer": "^2.3",
        "squizlabs/php_codesniffer": "^3.0",
        "php-coveralls/php-coveralls": "^2.0"
    },
    "autoload": {
        "psr-4": {
            "Test\\": "tests/src"
        }
    }
...

安装依赖


[meshell@phpunit]# composer install

测试断言

phpunit提供50个断言函数。基本覆盖了所有的断言测试,还提供数十个注解功能。让我们来学习断言方法的使用。


<?php

namespace Test;

use PHPUnit\Framework\TestCase;

/**
 * Class AssertTest
 * @package Test`
 */
class AssertTest extends TestCase
{
    /**
     *
     */
    public function testDataType()
    {
        $this->assertIsInt(11); // 测试变量不是整型
        $this->assertIsArray([]); // 测试变量不是数组
        $this->assertIsFloat(1.1); // 测试变量不是符点型
        $this->assertIsBool(false); // 测试变量不是bool类型
        $this->assertIsCallable(function() {}); // 测试变量不是闭包
        $this->assertIsIterable(new \stdClass()); // 测试变量不是迭代器
    }

    /**
     *
     */
    public function testEqual()
    {
        $base = 111;
        $this->assertEquals($base, 2222); // 测试相等性
    }

    public function testContains()
    {
        $haystack = "test string contains";
        $this->assertContains("test", $haystack); // 测试字符包含关系
    }

    /**
     *
     */
    public function testArray()
    {
        $hash = [
            'body' => "hello world"
        ];
        $key = 'body';
        $this->assertArrayHasKey($key, $hash); // 测试key是否存在
    }
}

在建立测试单元时,测试类必须继承TestCase,测试方法必须是test开头,像上面的testDataType等方法。只需要在测试方法中调用你想测试的断言即可比如assertIsArray查看全部的断言方法

执行测试

使用命令来执行测试用例,你可以通过IDE或者phpunit.xml配置文件快速执行测试。我们将使用下面的命令执行上面的用例。


[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/AssertTest.php      

上面的命令将测试用例中的所有方法,不出意外你将会看到以下信息。

PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

FF..                                                                4 / 4 (100%)

Time: 31 ms, Memory: 4.00 MB

There were 2 failures:

1) Test\AssertTest::testDataType
Failed asserting that stdClass Object &000000001694dcb0000000001f4ce956 () is of type "iterable".

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/AssertTest.php:23

2) Test\AssertTest::testEqual
Failed asserting that 2222 matches expected 111.

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/AssertTest.php:32

FAILURES!
Tests: 4, Assertions: 9, Failures: 2.

结果中会告诉你有多少个测试方法,多少断言, 多少个断言失败。比如上面的结果对应的就是4个测试, 9个断言, 2两个失败。

如果你想测试单个方法可以使用下面的命令


# testDataType 需要测试的方法
# Test\AssertTest 需要测试的类

[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration --filter "/(::testDataType)( .*)?$/" Test\AssertTest tests/src/AssertTest.php

查看phpunit参数

测试依赖

通过测试依赖,可以将一个测试方法的结果传递给另外一个测试方法。这项功能我们不需要编写任何代码,只需要为主法加上一个注解名就可以。


<?php


namespace Test;


use PHPUnit\Framework\TestCase;

class DependenciesTest extends TestCase
{
    public function testEmpty()
    {
        $stack = [];
        $this->assertEmpty($stack);

        return $stack;
    }

    /**
     * @depends testEmpty
     */
    public function testPush(array $stack)
    {
        array_push($stack, 'foo');
        $this->assertEquals('foo', $stack[count($stack)-1]);
        $this->assertNotEmpty($stack);

        return $stack;
    }

    /**
     * @depends testPush
     */
    public function testPop(array $stack)
    {
        $this->assertEquals('foo', array_pop($stack));
        $this->assertEmpty($stack);
    }
}

我们为需要依赖的测试方法加上了@depends testPush注解,testPushtestEmpty都是被依赖的方法名。


[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/DependenciesTest.php

PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 72 ms, Memory: 4.00 MB

OK (3 tests, 5 assertions)

数据供给器

测试方法可以接受任意参数。这些参数由数据供给器方法additionProvider()提供。用@dataProvider标注来指定使用哪个数据供给器方法。

数据供给器方法必须声明为public,其返回值要么是一个数组,其每个元素也是数组;要么是一个实现了 Iterator 接口的对象,在对它进行迭代时每步产生一个数组。每个数组都是测试数据集的一部分,将以它的内容作为参数来调用测试方法。


<?php


namespace Test;


use PHPUnit\Framework\TestCase;

/**
 * Class ProviderTest
 * @package Test
 */
class ProviderTest extends TestCase
{

    /**
     * @dataProvider additionProvider
     */
    public function testAdd($a, $b, $expected)
    {
        $this->assertEquals($expected, $a + $b);
    }

    /**
     * @return array
     */
    public function additionProvider()
    {
        return [
            [0, 0, 0],
            [0, 1, 1],
            [1, 0, 1],
            [1, 1, 3]
        ];
    }
}

[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/ProviderTest.php
PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

...F                                                                4 / 4 (100%)

Time: 28 ms, Memory: 4.00 MB

There was 1 failure:

1) Test\ProviderTest::testAdd with data set #3 (1, 1, 3)
Failed asserting that 2 matches expected 3.

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/ProviderTest.php:21

FAILURES!
Tests: 4, Assertions: 4, Failures: 1.

如果测试同时从@dataProvider方法和一个或多个@depends测试接收数据,那么来自于数据供给器的参数将先于来自所依赖的测试的。来自于所依赖的测试的参数对于每个数据集都是一样的。

<?php


namespace Test;


use PHPUnit\Framework\TestCase;

/**
 * Class ProviderTest
 * @package Test
 */
class ProviderTest extends TestCase
{
    ...

    public function provider()
    {
        return [['provider1'], ['provider2']];
    }

    public function testProducerFirst()
    {
        $this->assertTrue(true);
        return 'first';
    }

    public function testProducerSecond()
    {
        $this->assertTrue(true);
        return 'second';
    }

    /**
     * @depends testProducerFirst
     * @depends testProducerSecond
     * @dataProvider provider
     */
    public function testConsumer()
    {
        $this->assertEquals(
            ['provider1', 'first', 'second'],
            func_get_args()
        );
    }
}

[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/ProviderTest.php

PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

...F...F                                                            8 / 8 (100%)

Time: 51 ms, Memory: 4.00 MB

There were 2 failures:

1) Test\ProviderTest::testAdd with data set #3 (1, 1, 3)
Failed asserting that 2 matches expected 3.

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/ProviderTest.php:21

2) Test\ProviderTest::testConsumer with data set #1 ('provider2')
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
-    0 => 'provider1'
+    0 => 'provider2'
     1 => 'first'
     2 => 'second'
 )

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/ProviderTest.php:63

FAILURES!
Tests: 8, Assertions: 8, Failures: 2.

Note:如果一个测试依赖于另外一个使用了数据供给器的测试,仅当被依赖的测试至少能在一组数据上成功时,依赖于它的测试才会运行。使用了数据供给器的测试,其运行结果是无法注入到依赖于此测试的其他测试中的。
所有的数据供给器方法的执行都是在对setUpBeforeClass静态方法的调用和第一次对setUp方法的调用之前完成的。因此,无法在数据供给器中使用创建于这两个方法内的变量。这是必须的,这样 PHPUnit 才能计算测试的总数量。

异常测试

使用expectException方法或者@expectedException注解可以进行代码的异常测试。

<?php

namespace Test;


use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class ExceptionTest extends TestCase
{
    public function testException()
    {
        $this->expectException(InvalidArgumentException::class);
    }

    /**
     * @expectedException InvalidArgumentException
     */
    public function testAException()
    {
        throw new InvalidArgumentException("无效的参数", 500);
    }
}
[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/ExceptionTest.php
PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

F.                                                                  2 / 2 (100%)

Time: 28 ms, Memory: 4.00 MB

There was 1 failure:

1) Test\ExceptionTest::testException
Failed asserting that exception of type "InvalidArgumentException" is thrown.

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

可以看执行之后的结果,第二个方法抛出了异常被断言捕获。除了expectException@expectedException还有测试code,message的方法和注解expectExceptionCodeexpectExceptionMessage@expectedExceptionCode@expectedExceptionMessage。官方后期会移除expectException@expectedException的支持需要使用明确的code,message测试。

错误测试

默认情况下,PHPUnit将测试在执行中触发的PHP错误、警告、通知都转换为异常。利用这些异常,就可以测试。比如说,预期测试将触发PHP错误比如加载了一个不存在的文件。

<?php


namespace Test;


use PHPUnit\Framework\Error\Error;
use PHPUnit\Framework\TestCase;

class ErrorTest extends TestCase
{
    /**
     * @expectedException Error
     */
    public function testNotFile()
    {
        include "not.file.php";
    }
}
[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/ErrorTest.php

PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 27 ms, Memory: 4.00 MB

There was 1 error:

1) Test\ErrorTest::testNotFile
include(not.file.php): failed to open stream: No such file or directory

/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/ErrorTest.php:17
/Users/meshell/Projects/php/loocode-example/phpunit/tests/src/ErrorTest.php:17

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

输出测试

有时候,想要断言(比如说)某方法的运行过程中生成了预期的输出(例如,通过echoprint)。PHPUnit\Framework\TestCase类使用PHP的输出缓冲特性来为此提供必要的功能支持。通过expectOutputString()方法来设定所预期的输出。如果没有产生预期的输出,测试将计为失败。

<?php


namespace Test;


use PHPUnit\Framework\TestCase;

class PrintEchoTest extends TestCase
{
    public function testPrint()
    {
        $this->expectOutputString('foo');
        print 'foo';
    }

    public function testEcho()
    {
        $this->expectOutputString('hell');
        echo "hello";
    }
}
[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --no-configuration tests/src/PrintEchoTest.php

PHPUnit 7.5.11 by Sebastian Bergmann and contributors.

.F                                                                  2 / 2 (100%)

Time: 26 ms, Memory: 4.00 MB

There was 1 failure:

1) Test\PrintEchoTest::testEcho
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'hell'
+'hello'

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

用XML配置测试套件

为当前测试定义一个xml配置文件。例如下文的配置示例,它将查找当前tests目录下所有以Test.php结尾的文件进行测试。你可以查看这里获取更详细的配置说明。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
        backupGlobals="true"
        colors="true"
        stopOnFailure="false"
        bootstrap="tests/bootstrap.php">
    <testsuites>
        <testsuite name="App test">
            <directory suffix="Test.php">tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

执行下面命令就可以测试全部文件。如果--configuration 没有跟文件名,它将默认用phpunit.xmlphpunit.xml.dist作为文件来加载。


[meshell@phpunit]# php vendor/phpunit/phpunit/phpunit --configuration phpunit.xml.dist

代码覆盖率

代码覆盖率是一种用于衡量特定测试套件对程序源代码测试程度的指标。拥有高代码覆盖率的程序相较于低代码低概率的程序而言测试的更加彻底、包含软件bug的可能性更低。

在测试覆盖率之前,你必须安装PHP_CodeCoverageXdebug扩展。在测试之前我们编写一些需要测试覆盖率的代码。

<?php

namespace App;

class Coverage
{
    public function add()
    {
        return 1 + 1;
    }

    public function sub()
    {
        return 2 - 1;
    }

    public function div()
    {
        return 4 / 2;
    }

    public function mul()
    {
        return 2 * 2;
    }

    public function find(array $sort, int $find)
    {
        return $this->binarySearch($sort, $find);
    }

    public function binarySearch(array $sort, $find)
    {
        $low = 0;
        $high = count($sort) - 1;

        while ($low <= $high) {
            $mid = (int) floor(($low + $high) / 2);
            if($sort[$mid] == $find) {
                return true;
            }
            if ($find < $sort[$mid]) {
                $high = $mid -1;
            } else {
                $low = $mid + 1;
            }
        }
        return false;
    }
}

上面就是我们需要测试的代码,我们再为它增加一个测试用例.


<?php


namespace Test;


use App\Coverage;
use PHPUnit\Framework\TestCase;

class CoverageTest extends TestCase
{
    /**
     * @var null | Coverage
     */
    public $coverage = null;

    public function setUp()
    {
        parent::setUp(); // TODO: Change the autogenerated stub
        $this->coverage = new Coverage();
    }

    public function testCoverage()
    {
        $this->assertIsInt($this->coverage->add());
        $this->assertIsInt($this->coverage->div());
        $this->assertIsInt($this->coverage->mul());
        $this->assertIsInt($this->coverage->sub());
        $array = [
            1, 2, 3, 4, 5, 6, 7, 8, 9, 10
        ];
        $find = 2;
        $this->assertIsBool($this->coverage->find($array, $find));
    }
}

[meshell@phpunit]# php -dxdebug.coverage_enable=1 vendor/phpunit/phpunit/phpunit --coverage-clover CoverageTest.xml --configuration phpunit.xml.dist Test\CoverageTest tests/src/CoverageTest.php 


PHPUnit 7.5.12 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 65 ms, Memory: 6.00 MB

OK (1 test, 5 assertions)

Generating code coverage report in Clover XML format ... done

在目录会生成一个CoverageTest.xml文件。这时候就要分析里面的XML文件。我们就不做人肉分析了,让PHPStorm来为我们解决吧。

  1. 单击 File -> New Project 之后选择该工程。
  2. 打开CoverageTest.php文件。
  3. 单击 Run -> Run CoverageTest With Coverage 点击运行, 之后你会看到下面这样的图。

phpstorm-phpunit.png

相信应该已经学会了怎么使用phpunit来进行单元测试了吧。

推荐阅读

  1. 工程源码
  2. phpunit官网

留言