phpunit 快速入門

rovast發表於2019-01-08

phpunit 的官方文件對如何使用 phpunit 進行了詳細的說明。本人在通讀文件後進行了一些概要提升,
同時摘錄了一些示例 phpunit-demo,便於以後
理解和查閱。

文件較為簡潔,但是也涵蓋了平時使用的基本用法,適合入門使用。

安裝 phpunit

專案安裝

composer require --dev phpunit/phpunit

使用 ./vendor/bin/phpunit

全域性安裝

composer global require --dev phpunit/phpunit

使用 phpunit

快速入門

基本格式

  • 測試類命名: 類名 + Test , eg FooClassTest
  • 測試方法命名: test + 方法名, eg testFoo

也可以使用註釋 @test 來標註需要測試的方法

use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
    public function testSomething()
    {
        $this->assertTrue(true, 'This should already work.');
    }

    /**
     * @test
     */
    public function something()
    {
        $this->assertTrue(true, 'This should already work.');
    }
}

測試依賴(@depends)

有一些測試方法需要依賴於另一個測試方法的返回值,此時需要使用測試依賴。測試依賴
通過註釋 @depends 來標記。

下列中, depends 方法的 return 值作為 testConsumer 的引數傳入

use PHPUnit\Framework\TestCase;

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

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

    /**
     * @depends testProducerFirst
     * @depends testProducerSecond
     */
    public function testConsumer($a, $b)
    {
        $this->assertSame('first', $a);
        $this->assertSame('second', $b);
    }
}

資料提供器(@dataProvider)

在依賴中,所依賴函式的返回值作為引數傳入測試函式。除此之外,我們也可以用資料提供器
來定義傳入的資料。

use PHPUnit\Framework\TestCase;

class DataTest extends TestCase
{
    /**
     * @dataProvider additionProvider
     */
    public function testAdd($a, $b, $expected)
    {
        $this->assertSame($expected, $a + $b);
    }

    public function additionProvider()
    {
        return [
            'adding zeros' => [0, 0, 0], // 0 + 0 = 0 pass
            'zero plus one' => [0, 1, 1], // 0 + 1 = 1 pass
            'one plus zero' => [1, 0, 1], // 1 + 0 = 1 pass
            'one plus one' => [1, 1, 2], // 1 + 1 = 2 pass
        ];
    }
}

測試異常(expectException)

需要在測試方法的開始處宣告斷言,然後執行語句。而不是呼叫後再宣告

也可以通過註釋來宣告 @expectedException, @expectedExceptionCode,
@expectedExceptionMessage, @expectedExceptionMessageRegExp

use PHPUnit\Framework\TestCase;

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

        throw new \Exception('test');
    }

    /**
     * @throws \Exception
     * @test
     * @expectedException \Exception
     */
    public function exceptionExpect()
    {
        throw new \Exception('test');
    }
}

測試 PHP 錯誤

通過提前新增期望,來使測試正常進行,而不會報出 PHP 錯誤

use PHPUnit\Framework\TestCase;

class ExpectedErrorTest extends TestCase
{
    /**
     * @expectedException PHPUnit\Framework\Error\Error
     */
    public function testFailingInclude()
    {
        include 'not_existing_file.php';
    }
}

測試輸出

直接新增期望輸出,然後執行相關函式。和測試異常類似,需要先新增期望,再執行程式碼。

use PHPUnit\Framework\TestCase;

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

    public function testExpectBarActualBaz()
    {
        $this->expectOutputString('bar');
        print 'baz';
    }
}

命令列測試

此章節主要說明了命令列的一些格式和可用引數。可以參考官方文件
獲取細節。The Command-Line Test Runner

基境

基境就是在測試前需要準備的一系列東西。

比如有的測試需要依賴資料庫的資料,那麼在測試類運作前需要進行資料的準備。

主要有兩個函式 setUptearDown

那為什麼不直接用建構函式和解構函式呢?是因為這兩個有他用,當然你可可以直接用建構函式,然後
再執行 parent::__construct,但不是麻煩嘛;

組織你的測試程式碼

可以通過命令列的 --bootstrap 引數來指定啟動檔案,用於檔案載入。正常情況下,可以指向 composer 的 autoload 檔案。
也可以在配置檔案中配置(推薦)。

$ phpunit --bootstrap src/autoload.php tests
PHPUnit |version|.0 by Sebastian Bergmann and contributors.

.................................

Time: 636 ms, Memory: 3.50Mb

OK (33 tests, 52 assertions)
<phpunit bootstrap="src/autoload.php">
  <testsuites>
    <testsuite name="money">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

有風險的測試(Risky Tests)

無用測試(Useless Tests)

預設情況下,如果你的測試函式沒有新增預期或者斷言,就會被認為是無用測試。

通過設定 --dont-report-useless-tests 命令列引數,或者在 xml 配置
檔案中配置 beStrictAboutTestsThatDoNotTestAnything="false" 來更改
這一預設行為。

意外的程式碼覆蓋(Unintentionally Covered Code)

當開啟這個配置後,如果使用 @covers 註釋來包含一些檔案的覆蓋報告,就會被
判定為有風險的測試。

通過設定 --strict-coverage 命令列引數,或者在 xml 配置
檔案中配置 beStrictAboutCoversAnnotation="true" 來更改
這一預設行為。

測試過程中有輸出(Output During Test Execution)

如果在測試過程中輸出文字,則會被認定為有風險的測試。

通過設定 --disallow-test-output 命令列引數,或者在 xml 配置
檔案中配置 beStrictAboutOutputDuringTests="true" 來更改
這一預設行為。

測試超時(Test Execution Timeout)

通過註釋來限制某些測試不能超過一定時間:

  • @large 60秒
  • @medium 10秒
  • @small 1秒

通過設定 --enforce-time-limit 命令列引數,或者在 xml 配置
檔案中配置 enforceTimeLimit="true" 來更改
這一預設行為。

操作全域性狀態(Global State Manipulation)

phpunit 可以對全域性狀態進行檢測。

通過設定 --strict-global-state 命令列引數,或者在 xml 配置
檔案中配置 beStrictAboutChangesToGlobalState="true" 來更改
這一預設行為。

待完善的測試和跳過的測試

處於一些原因,我們希望跳過或者對某些測試方法標記未待完善

待完善的測試

使用 $this->markTestIncomplete 標記待完善的測試

use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
    public function testSomething()
    {
        $this->assertTrue(true, 'This should already work.');

        $this->markTestIncomplete(
          'This test has not been implemented yet.'
        );
    }

跳過的測試

使用 markTestSkipped 來標記跳過的測試。

use PHPUnit\Framework\TestCase;

class DatabaseTest extends TestCase
{
    protected function setUp()
    {
        if (!extension_loaded('mysqli')) {
            $this->markTestSkipped(
              'The MySQLi extension is not available.'
            );
        }
    }

    public function testConnection()
    {
        // ...
    }
}

結合 @require 來跳過測試

以下的示例中,使用 require 來限定測試需要依賴 mysqli 擴充和 5.3 以上
的 PHP 版本,否則跳過測試

use PHPUnit\Framework\TestCase;

/**
 * @requires extension mysqli
 */
class DatabaseTest extends TestCase
{
    /**
     * @requires PHP 5.3
     */
    public function testConnection()
    {
        // Test requires the mysqli extension and PHP >= 5.3
    }

    // ... All other tests require the mysqli extension
}

資料庫測試

使用資料庫測試之前先安裝擴充 composer require --dev phpunit/dbunit

總的來說,我們在好多的測試場景中都會用到資料庫,我們可以結合 PHPUnit 的基境
章節中提到的 setUp 來進行測試。

我們來看一個示例

use PHPUnit\DbUnit\DataSet\ArrayDataSet;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\Framework\TestCase;

/**
 * 測試之前,需要在 MySQL 中新建資料庫 phpunit,並且新建表 guestbook
 * CREATE TABLE `guestbook` (
 * `id` bigint(20) NOT NULL,
 * `content` varchar(255) COLLATE utf8mb4_bin NOT NULL,
 * `user` varchar(255) COLLATE utf8mb4_bin NOT NULL,
 * `created` datetime NOT NULL
 * ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
 * Class ConnectionTest.
 *
 * @requires extension pdo_mysql
 */
class ConnectionTest extends TestCase
{
    use TestCaseTrait;

    /**
     * @return \PHPUnit\DbUnit\Database\DefaultConnection
     */
    public function getConnection()
    {
        $pdo = new \PDO(
            'mysql:host=127.0.0.1:33060;dbname=phpunit;charset=utf8mb4',
            'root',
            '112233'
        );

        return $this->createDefaultDBConnection($pdo);
    }

    /**
     * 請注意,phpunit每次會先 TRUNCATE 資料庫,然後把下面陣列的資料插入進去.
     *
     * @return ArrayDataSet
     */
    public function getDataSet()
    {
        return new ArrayDataSet(
            [
                'guestbook' => [
                    [
                        'id' => 1,
                        'content' => 'Hello buddy!',
                        'user' => 'joe',
                        'created' => '2010-04-24 17:15:23',
                    ],
                    [
                        'id' => 2,
                        'content' => 'I like it!',
                        'user' => 'mike',
                        'created' => '2010-04-26 12:14:20',
                    ],
                ],
            ]
        );
    }

    public function testCreateDataSet()
    {
        $this->markTestSkipped('just an example, skipped');
        $tableNames = ['guestbook'];
        $dataSet = $this->getConnection()->createDataSet();
    }

    public function testCreateQueryTable()
    {
        $this->markTestSkipped('just an example, skipped');
        $tableNames = ['guestbook'];
        $queryTable = $this->getConnection()->createQueryTable('guestbook', 'SELECT * FROM guestbook');
    }

    public function testGetRowCount()
    {
        $this->assertSame(2, $this->getConnection()->getRowCount('guestbook'));
    }

    /**
     * 測試表的內容和給定的資料集相等.
     */
    public function testAddEntry()
    {
        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', 'SELECT * FROM guestbook'
        );
        $expectedTable = $this->createFlatXmlDataSet(__DIR__.'/expectedBook.xml')
            ->getTable('guestbook');
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}

其中 getConnectiongetDataSet 都是 TestCaseTrait 中提供的方法,
我們在 getConnection 中設定資料庫的連結動作,同時在 getDataSet 中設定
需要往資料庫中寫入的資料,注意,每次執行這個測試類時,都會執行

  1. 清空資料庫 TRUNCATE
  2. 填充資料

如何驗證呢?加一個欄位為 datetime 型別,設定位資料庫自動更新時間,即可看到每次執行測試時,時間都在變化

測試樁

所謂的樁測試,其實就是對一些類的方法進行一番 mock,強制其返回一些資料。因為在開發中,有一些類
依賴於第三方服務,而第三方服務又屬於“不可控”因素,所以這個時候就需要使用“樁”了。

use PHPUnit\Framework\TestCase;

class StubTest extends TestCase
{
    public function testStub()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createMock(SomeClass::class);

        // Configure the stub.
        $stub->method('doSomething')
            ->willReturn('foo');

        // Calling $stub->doSomething() will now return
        // 'foo'.
        $this->assertSame('foo', $stub->doSomething());
    }

    public function testStub2()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->getMockBuilder(SomeClass::class)
            ->disableOriginalConstructor()
            ->disableOriginalClone()
            ->disableArgumentCloning()
            ->disallowMockingUnknownTypes()
            ->getMock();

        // Configure the stub.
        $stub->method('doSomething')
            ->willReturn('foo');

        // Calling $stub->doSomething() will now return
        // 'foo'.
        $this->assertSame('foo', $stub->doSomething());
    }

    public function testReturnArgumentStub()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createMock(SomeClass::class);

        // Configure the stub.
        $stub->method('doSomething')
            ->will($this->returnArgument(0));

        // $stub->doSomething('foo') returns 'foo'
        $this->assertSame('foo', $stub->doSomething('foo'));

        // $stub->doSomething('bar') returns 'bar'
        $this->assertSame('bar', $stub->doSomething('bar'));
    }

    public function testReturnSelf()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createMock(SomeClass::class);

        // Configure the stub.
        $stub->method('doSomething')
            ->will($this->returnSelf());

        // $stub->doSomething() returns $stub
        $this->assertSame($stub, $stub->doSomething());
    }

    public function testReturnValueMapStub()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createMock(SomeClass::class);

        // Create a map of arguments to return values.
        $map = [
            ['a', 'b', 'c', 'd'],
            ['e', 'f', 'g', 'h'],
        ];

        // Configure the stub.
        $stub->method('doSomething')
            ->will($this->returnValueMap($map));

        // $stub->doSomething() returns different values depending on
        // the provided arguments.
        $this->assertSame('d', $stub->doSomething('a', 'b', 'c'));
        $this->assertSame('h', $stub->doSomething('e', 'f', 'g'));
    }

    public function testReturnCallbackStub()
    {
        // Create a stub for the SomeClass class.
        $stub = $this->createMock(SomeClass::class);

        // Configure the stub.
        $stub->method('doSomething')
            ->will($this->returnCallback('str_rot13'));

        // $stub->doSomething($argument) returns str_rot13($argument)
        $this->assertSame('fbzrguvat', $stub->doSomething('something'));
    }

    /**
     * 按照指定順序返回列表中的值
     */
    public function testOnConsecutiveCallsStub()
    {
        // 為 SomeClass 類建立樁件。
        $stub = $this->createMock(SomeClass::class);

        // 配置樁件。
        $stub->method('doSomething')
            ->will($this->onConsecutiveCalls(2, 3, 5, 7));

        // $stub->doSomething() 每次返回值都不同
        $this->assertSame(2, $stub->doSomething());
        $this->assertSame(3, $stub->doSomething());
        $this->assertSame(5, $stub->doSomething());
    }

    public function testThrowExceptionStub()
    {
        $this->expectException(\Exception::class);

        // 為 SomeClass 類建立樁件
        $stub = $this->createMock(SomeClass::class);

        // 配置樁件。
        $stub->method('doSomething')
            ->will($this->throwException(new \Exception()));

        // $stub->doSomething() 丟擲異常
        $stub->doSomething();
    }
}

class SomeClass
{
    public function doSomething()
    {
        // Do something.
    }
}

我們看到, SomeClassdoSomething() 本身沒有返回資料,我們通過樁動作,來完成了測試。

這個在測試依賴於第三方服務的相關程式碼時很管用。

程式碼覆蓋度

白名單檔案(Whitelisting Files)

新增到白名單檔案的檔案或者資料夾,會進行程式碼覆蓋度統計的工作。具體的引數可以看幫助文件

 忽略程式碼塊(Ignoring Code Blocks)

我們可以新增部分程式碼不進行覆蓋度統計。通過一些註釋來標記即可

use PHPUnit\Framework\TestCase;

/**
 * @codeCoverageIgnore
 */
class Foo
{
    public function bar()
    {
    }
}

class Bar
{
    /**
     * @codeCoverageIgnore
     */
    public function foo()
    {
    }
}

if (false) {
    // @codeCoverageIgnoreStart
    print '*';
    // @codeCoverageIgnoreEnd
}

exit; // @codeCoverageIgnore

執行方法進行統計(Specifying Covered Methods)

同樣通過新增註釋標記的方法來執行需要覆蓋的方法。

use PHPUnit\Framework\TestCase;

class BankAccountTest extends TestCase
{
    protected $ba;

    protected function setUp()
    {
        $this->ba = new BankAccount;
    }

    /**
     * @covers BankAccount::getBalance
     */
    public function testBalanceIsInitiallyZero()
    {
        $this->assertSame(0, $this->ba->getBalance());
    }

    /**
     * @covers BankAccount::withdrawMoney
     */
    public function testBalanceCannotBecomeNegative()
    {
        try {
            $this->ba->withdrawMoney(1);
        }

        catch (BankAccountException $e) {
            $this->assertSame(0, $this->ba->getBalance());

            return;
        }

        $this->fail();
    }

    /**
     * @covers BankAccount::depositMoney
     */
    public function testBalanceCannotBecomeNegative2()
    {
        try {
            $this->ba->depositMoney(-1);
        }

        catch (BankAccountException $e) {
            $this->assertSame(0, $this->ba->getBalance());

            return;
        }

        $this->fail();
    }

    /**
     * @covers BankAccount::getBalance
     * @covers BankAccount::depositMoney
     * @covers BankAccount::withdrawMoney
     */
    public function testDepositWithdrawMoney()
    {
        $this->assertSame(0, $this->ba->getBalance());
        $this->ba->depositMoney(1);
        $this->assertSame(1, $this->ba->getBalance());
        $this->ba->withdrawMoney(1);
        $this->assertSame(0, $this->ba->getBalance());
    }
}