[教學] 如何使用 PHPUnit 進行測試與 Mock Function

在開發程式時, 寫單元測試可以幫助我們之後在程式更動時能更有把握, 對於未來上可以減少一些維護上的風險與成本, 各程式語言都有各自的工具或框架進行測試, 本篇將介紹 PHP 基本的測試工具 PHPUnit 設定與基本的 mock 方法。

基本資料

官網:https://phpunit.de/
原始碼:https://github.com/sebastianbergmann/phpunit
文件 (英文):https://phpunit.readthedocs.io/
文件 (中文):https://phpunit.readthedocs.io/zh_CN/latest/

安裝與設定 PHPUnit

安裝 phpunit

首先在專案中使用 composer 安裝 PHPUnit

composer require --dev phpunit/phpunit

安裝 php-xdebug

如果要能夠看到測試的 Code Coverage 就還需要安裝 php-xdebug

pecl install xdebug

設定 phpunit.xml

安裝完畢需要在專案目錄建立 phpunit 設定檔 phpunit.xml

<?xml version="1.0" encoding="UTF-8"?>

<phpunit backupGlobals="false"
  backupStaticAttributes="false"
  colors="true"
  convertErrorsToExceptions="true"
  convertNoticesToExceptions="true"
  convertWarningsToExceptions="true"
  processIsolation="false"
  stopOnFailure="false">
  <testsuites>
    <testsuite name="xenby-project">
      <directory suffix="Test.php">tests</directory>
    </testsuite>
  </testsuites>

  <logging>
    <log type="coverage-text" target="php://stdout" />
  </logging>

  <filter>
      <whitelist>
          <directory suffix=".php">src</directory>
      </whitelist>
  </filter>
</phpunit>

上面這個範例中為, 分別設定為:

  • backupGlobals:每此測試執行時時會先備份 $GLOBALS, 而測試結束會將 $GLOBALS 恢復, 使得每個測試之間的 $GLOBALS 內容不會互相影響
  • backupStaticAttributes:每此測試執行時時會先備份以物件的 static 屬性的值, 而測試結束會將恢復, 使得每個測試之間的 static 屬性不會互相影響
  • colors:在 termimal 顯示的文字增加顏色
  • convertErrorsToExceptions:執行過程中發生 Error 時會以 Exception 方式丟出
  • convertNoticesToExceptions:執行過程中發生 Notice 錯誤時會以 Exception 方式丟出
  • convertWarningsToExceptions:執行過程中發生 Warning 時會以 Exception 方式丟出
  • processIsolation 每次的測試都使用獨立的 process 進行運作 (可達到每個都完全獨立, 不過會讓測試執行較慢)
  • stopOnFailure 當有任何錯誤時就停止, 不繼續執行其他測試
  • testsuites 中的 suffix 與 directory 為設定要掃描哪個資料夾下面什麼檔案名結尾的檔案進行測試, 以這邊範例是 tests 資料夾下 Test.php 的檔案

撰寫測試

基本測試與執行

這時就可以開始寫測試了, 只需要在 tests 資料夾下面建立 Test.php 結尾的檔案, 並且該 class 繼承 PHPUnit\Framework\TestCase 就會是一個測試檔案

例如建立一個 DemoTest.php 裡面的程式碼為:

<?php

namespace Xenby\Project\Test;

use PHPUnit\Framework\TestCase;

class DemoTest extends TestCase
{
    public function testBase()
    {
        $this->assertEquals(2, 1 + 1);
    }
}

這時只需要輸入指令執行測試就可以了

./vendor/bin/phpunit

可以看到類似如下的結果

PHPUnit 9.5.10 by Sebastian Bergmann and contributors.

.                                                                1 / 1 (100%)

Time: 00:00.152, Memory: 12.00 MB

OK (1 tests, 1 assertions)

這樣就可以開始寫基本測試了

使用 Mock Builder 替換部分方法

有時可能會遇到有些函數或功能無法在測試中實際去執行的功能, 例如使用第三方的付費功能或是開發票功能時, 這時可以使用 PHPUnit 提供的 Mock Builder 功能

此功能能夠替換掉物件部分函數, 使其在測試時不會實際執行函數內的功能, 並且產生自己指定要的結果

以實際程式碼做示範, 假如我們寫了一個 InvoiceClient 用來呼叫第三的 API 建立發票函數 createNewInvoice

class InvoiceClient
{
    /**
     * 請第三方建立發票
     *
     * @param  int   $order_id 訂單編號
     * @return int   收據編號
     * @throws Exception 建立失敗
     */
    public function createNewInvoice($order_id)
    {
        // ...(實際的實作)
    }
}

而這時我們可以使用 MockBuilder 產生一個假的 InvoiceClient, 其 createNewInvoice 函數功能是被替換的

接著便可以把 $mockInvoiceClient 傳進其他物件中, 給其他物件呼叫並測試

use PHPUnit\Framework\TestCase;

class InvoiceClientTest extends TestCase
{
    public function testCreateNewInvoice()
    {
        $mockInvoiceClient = $this->getMockBuilder(InvoiceClient::class)
            ->onlyMethods(["createNewInvoice"]) // 指定哪些函數會被 mock 掉
            ->disableOriginalConstructor() // 建立此物件時不呼叫建構元
            ->getMock();

        $orderId = 5824;
        $mockInvoiceId = 4520312;
        $mockInvoiceClient->method('createNewInvoice')
            ->with($this->equalTo($orderId)) // 預期呼叫此函數收到的參數
            ->will($this->returnValue(true)); // 此函數 return 的值

        $this->assertEquals($mockInvoiceId, $mockInvoiceClient->createNewInvoice($orderId));
    }
}

Mock Builder 不只能夠讓函數回傳資料, 也可以讓函數丟出例外, 用來測試失敗的情境

use PHPUnit\Framework\TestCase;

class InvoiceClientTest extends TestCase
{
    public function testCreateNewInvoiceFailed()
    {
        $mockInvoiceClient = $this->getMockBuilder(InvoiceClient::class)
            ->onlyMethods(["createNewInvoice"]) // 指定哪些函數會被 mock 掉
            ->disableOriginalConstructor() // 建立此物件時不呼叫建構元
            ->getMock();

        $orderId = 5824;
        $mockException = new Exception('Create Invoce Failed');
        $mockInvoiceClient->method('createNewInvoice')
            ->with($this->equalTo($orderId)) // 預期呼叫此函數收到的參數
            ->willThrowException($mockException); // 此函數丟出的例外

        $this->expectException(Exception::class);
        $this->expectExceptionMessage($mockException->getMessage());

        $mockInvoiceClient->createNewInvoice($orderId);
    }
}

發表迴響