[推薦] PHP 方便的測試 mock 套件 – AspectMock

之前已有一篇文章介紹如何使用 PHPUnit 的 Mock Builder 替換物件的一些函數, 不過使用時會注意到該方法只能用於實體 (new 出來的 object), 而如果是 static 就不能夠替換掉, 且該方式也只適用 object 是透過從外部注入的寫法, 如果是在 function 內直接 new class 方式就無法使用, 這邊介紹一個套件 AspectMock 可以解決以上兩個問題。

介紹

Aspect 是使用 goaop/framework 來實作 mock 功能, 其有以下幾個特點

  • 支援 mock static 函數
  • 可以隔空 (on the fly) 替換函數, 不需要先建立實體或對實體進行操作
  • 支援 mock PHP 內建的函數 (例如: 取現在的時間 date('Y-m-d H:i:s'))

安裝與設定

在使用 AspectMock 之前先確定已安裝 PHPUnit, 還沒安裝可以參考之前的文章

有安裝 PHPUnit 之後, 接著透過 composer 安裝 AspectMock

composer require --dev codeception/aspect-mock

並且建立 tests/bootstrap.php 設定 AspectMock

<?php

include __DIR__ . '/../vendor/autoload.php';

$kernel = \AspectMock\Kernel::getInstance();
$kernel->init([
    'debug' => true,
    'includePaths' => [__DIR__ . '/../src'], // 要 mock 的 class 原始碼存放位置 (也可以寫 vendor 底下的套件)
    'cacheDir' => '/tmp/xenby-AspectMock-tmp', // Cache 存放位置
]);

並且設定 phpunit.xml 讓 PHPUnit 去讀取此 bootstrap.php
(phpunit DOM 上的 bootstrap)

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

<phpunit backupGlobals="false"
  backupStaticAttributes="false"
  bootstrap="tests/bootstrap.php"
  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>

開始撰寫測試

接著就可以開始撰寫測試, 下面示範一些 AspectMock 使用範例

使用 double 函數進行基本的函數 mock

原始碼

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

測試程式碼, 使用 AspectMock 的 double 函數可以直接隔空取代掉函數功能,回傳 mock 的值

use AspectMock\Test as AspectMockTest;
use PHPUnit\Framework\TestCase;

class InvoiceClientTest extends TestCase
{
    public function testCreateNewInvoice()
    {
        $mockInvoiceId = 1854;
        AspectMockTest::double(InvoiceClient::class, ['createNewInvoice' => $mockInvoiceId]);

        $client = new InvoiceClient();
        $orderId = 193;
        $this->assertEquals($mockInvoiceId, $client->createNewInvoice($orderId));
    }
}

使用 double 函數進行 mock 使函數丟出例外

原始碼

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

使用 double 函數為指定函數替換實際功能

use AspectMock\Test as AspectMockTest;
use Exception;
use PHPUnit\Framework\TestCase;

class InvoiceClientTest extends TestCase
{
    public function testCreateNewInvoiceFailed()
    {
        $mockException = new Exception('Create Invoce Failed');
        AspectMockTest::double(InvoiceClient::class, [
            'createNewInvoice' => function () use ($mockException) {
                throw $mockException;
            },
        ]);

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

        $client = new InvoiceClient();
        $orderId = 5824;
        $client->createNewInvoice($orderId);
    }
}

使用 double 函數替換掉 static 函數

原始碼

class InvoiceClient
{
    /**
     * 取得發票伺服器狀態
     *
     * @return array [
     *     'status' => status id,
     *     'message' => message
     * ]
     */
    public static function getServerStatus()
    {
        // ...(實際的實作)
    }
}

測試程式碼

use AspectMock\Test as AspectMockTest;
use PHPUnit\Framework\TestCase;

class InvoiceClientTest extends TestCase
{
    public function testGetServerStatus()
    {
        AspectMockTest::double(InvoiceClient::class, [
            'getServerStatus' => [
                'status' => 0,
                'message' => 'OK',
            ],
        ]);

        $this->assertEquals([
            'status' => 0,
            'message' => 'OK',
        ], InvoiceClient::getServerStatus());
    }
}

使用 func 函數替換掉 PHP 內建函數

原始碼

class InvoiceClient
{
    /**
     * 取得在時間
     *
     * @return string
     */
    public static function getNowTime()
    {
        return date('Y-m-d H:i:s');
    }
}

測試程式碼 (實際是在該 namespace 下注入同名函數)

use AspectMock\Test as AspectMockTest;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Xenby\Project\Clients\InvoiceClient;

class InvoiceClientTest extends TestCase
{
    public function testGetNowTime()
    {
        $class = new ReflectionClass(InvoiceClient::class);
        AspectMockTest::func($class->getNamespaceName(), 'date', '2020-04-03');

        $this->assertEquals('2020-04-03', InvoiceClient::getNowTime());
    }
}

發表迴響