如何使用 PHPUnit mock Closure?

   2016-11-22 0
核心提示:若有需求需要抽換,物件導向編程教我們的是開 interface 達成解耦合,然後使用依賴注入,最後達成依賴反轉目標,隨著函數式編程越來越流行,函數式編程教我們將 closure 當成參數傳進函式,一樣可以解耦合與依賴反轉,尤其對於只使用一次的需求特別有效,不用

若有需求需要抽換,物件導向編程教我們的是開 interface 達成解耦合,然後使用依賴注入,最後達成依賴反轉目標,隨著函數式編程越來越流行,函數式編程教我們將 closure 當成參數傳進函式,一樣可以解耦合與依賴反轉,尤其對於只使用一次的需求特別有效,不用在另外開 interface 與 class,但在單元測試則面臨挑戰,我們該如何 mock closure 呢?

Motivation

在 PHPConf 第二天的 workshop,我曾經舉手問 PHPUnit 的原作者 Sabastian Bergmann: How to mock closure? ,Sabastian 的回答也很鏗鏘有力: You can't ,在這篇 PHPUnit 的 Closure mock Issue ,也有人建議 PHPUnit 支援 mock closure,不過 Sabastian 的回答是

I might accept a pull request that implements this but I won't implement it myself.

不過實務上,仍有 mock closure 的需求,如 closure 內包含外部 API 或別人的 package,為了單元測試,能不 mock closure 嗎? 顯然不可能,除非你不使用 closure 寫法,而改用傳統物件導向的 interface 方式。

Version

PHP 7.0.8

Laravel 5.3.10

PHPUnit 5.5.5

實際案例

假設目前有 3 家貨運公司,每家公司的計費方式不同,使用者可以動態選擇不同的貨運公司。 1 1 完整重構過程請參考深入探討依賴注入,本文只從 method injection 繼續重構成 closure。

Method Injection

單元測試 ShippingServiceTest.php 2 2 GitHub Commit : 單元測試 : ShippingService 使用 interface

tests/Services/ShippingServiceTest.php
declare(strict_types = 1);

use App\Services\BlackCat;
use App\Services\LogisticsInterface;
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{
    /** @test */
    public function 黑貓單元測試()
    {
        /** arrange */
        $mock = $this->createMock(BlackCat::class);
        $mock->expects($this->once())
            ->method('calculateFee')
            ->withAnyParameters()
            ->willReturn(110);

        App::instance(LogisticsInterface::class, $mock);

        /** act */
        $weight = 1;
        $actual = App::call(ShippingService::class . '@calculateFee', [
            'weight' => $weight,
        ]);

        /** assert */
        $expected = 110;
        $this->assertEquals($expected, $actual);
    }
}

13 行

$mock = $this->createMock(BlackCat::class);

PHPUnit 直接使用 createMock() 建立 mock 物件。

14 行

$mock->expects($this->once())
    ->method('calculateFee')
    ->withAnyParameters()
    ->willReturn(110);
  • expects() : 設定預期該 mock 的 method 要執行幾次, $this->once() 為只執行一次。
  • method() : 設定要 mock 的 method 名稱。
  • withAnyParameters() : 不考慮 method 的任何參數。
  • willReturn() : 設定該 mock 的 method 的回傳值。

19 行

App::instance(LogisticsInterface::class, $mock);

告訴 Laravel 的 service container,當遇到 LogisticsInterface 的依賴注入時,一律改注入成剛剛由 PHPUnit 所建立的 mock。

22 行

$weight = 1;
$actual = App::call(ShippingService::class . '@calculateFee', [
    'weight' => $weight,
]);

實際測試 ShippingServicecalculateFee()

由於 calculateFee() 使用了 method injection,所以必須改用 Laravel service container 所提供的 App::call() 執行,才會啟動 method injection,自動注入剛剛建立的 mock。

第一個參數要傳進 class 的完整名稱,加上 @ 與 method 名稱。

第二個參數為陣列,傳進其他參數,其中 key 為 參數名稱,value 為要傳進參數的變數。 3 3 關於 App::call() ,詳細請參考 深入探討依賴注入之 Method Injection

28 行

$expected = 110;
$this->assertEquals($expected, $actual);

實際測試結果是否為 110

ShippingService.php 4 4 GitHub Commit : 建立 ShippingService 使用 interface

Services/ShippingService.php
declare(strict_types = 1);

namespace App\Services;

class ShippingService
{
    /**
     * 計算運費
     * @param int $weight
     * @param LogisticsInterface $logistics
     * @return int
     */
    public function calculateFee(int $weight, LogisticsInterface $logistics) : int
    {
        return $logistics->calculateFee($weight);
    }
}

使用 method injection 由 LogisticsInterface 注入 $logistics

由於 LogisticsInterface 有定義 calculateFee() ,因此我們可以在 $logistics 物件使用 calculateFee()

LogisticsInterface.php 5 5 GitHub Commit : 建立 LogisticsInterface

Services/LogisticsInterface.php
declare(strict_types = 1);

namespace App\Services;

interface LogisticsInterface
{
    /**
     * 計算運費
     * @param int $weight
     * @return int
     */
    public function calculateFee(int $weight) : int;
}

定義 LogisticsInterfacecalculateFee()

BlackCat.php 6 6 GitHub Commit : 建立 BlackCat 實現 LogisticsInterface

Services/BlackCat.php
declare(strict_types = 1);

namespace App\Services;

class BlackCat implements LogisticsInterface
{
    /**
     * 計算運費
     * @param int $weight
     * @return int
     */
    public function calculateFee(int $weight) : int
    {
        return 100 * $weight + 10;
    }
}

BlackCat 必須實現 LogisticsInterface 所定義的 calculateFee() ,將實際的計算運費演算法在此實現。

如何使用 PHPUnit mock Closure?

實際跑單元測試,會得到第 1 個 綠燈

整合測試 ShippingServiceTest.php 7 7 GitHub Commit : 整合測試 : ShippingService 使用 interface

tests/Services/ShippingServiceTest.php
declare(strict_types = 1);

use App\Services\BlackCat;
use App\Services\LogisticsInterface;
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{
    /** @test */
    public function 黑貓整合測試()
    {
        /** arrange */
        App::bind(LogisticsInterface::class, BlackCat::class);

        /** act */
        $weight = 1;
        $actual = App::call(ShippingService::class . '@calculateFee', [
            'weight' => $weight,
        ]);

        /** assert */
        $expected = 110;
        $this->assertEquals($expected, $actual);
    }
}

12 行

/** arrange */
App::bind(LogisticsInterface::class, BlackCat::class);

整合測試就不再 mock 了,而是實際由 App::bind() 告訴 Laravel service container,當遇到 LogisticsInterface 時,要依賴注入 BlackCat 物件。

15 行

/** act */
$weight = 1;
$actual = App::call(ShippingService::class . '@calculateFee', [
    'weight' => $weight,
]);

一樣使用 App::call() 呼叫 ShippingServicecalculateFee() ,因為使用了 method injection。

如何使用 PHPUnit mock Closure?

實際跑整合測試,會得到第 2 個 綠燈

重構成 Closure

計算運費 的邏輯在整個專案只使用一次,為此大費周章建立 interface 與 class, 或許殺雞用牛刀了,對於這種只使用一次的需求,函數式編程就特別有效,我們可以再繼續將 interface 重構成 closure。

ShippingService.php 8 8 GitHub Commit : 重構 ShippingService 使用 closure

Services/ShippingService.php
declare(strict_types = 1);

namespace App\Services;

class ShippingService
{
    /**
     * 計算運費
     * @param int $weight
     * @param callable $logistics
     * @return int
     */
    public function calculateFee(int $weight, callable $logistics) : int
    {
        return $logistics($weight);
    }
}

$logistics 的 type hint 從原本的 LogisticsInterface 改成 callable ,因為我們要重構成 closure 方式。 9 9 關於 callable ,詳細請參考 如何使用 Closure?

既然 $logistics 為 closure,我們就可以直接以 $logistics($weight) 的方式執行了。

單元測試 ShippingServiceTest.php 10 10 GitHub Commit : 單元測試 : ShippingService 使用 closure

Services/ShippingServiceTest.php
declare(strict_types = 1);

use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{
    /** @test */
    public function 黑貓單元測試()
    {
        /** arrange */
        $mock = $this->createPartialMock(stdClass::class, ['__invoke']);

        $mock->expects($this->once())
            ->method('__invoke')
            ->withAnyParameters()
            ->willReturn(110);

        /** act */
        $weight = 1;
        $actual = App::call(ShippingService::class . '@calculateFee', [
            'weight'    => $weight,
            'logistics' => $mock,
        ]);

        /** assert */
        $expected = 110;
        $this->assertEquals($expected, $actual);
    }
}

11 行

$mock = $this->createPartialMock(stdClass::class, ['__invoke']);

這一行有 3 個重點 :

  1. 為什麼要 mock __invoke ?
  2. 為什麼是 stdClass ?
  3. 什麼是 Partial Mock?

為什麼要 mock __invoke ?

PHP
$anonyFunc = function ($name) {
    return 'Hello ' . $name;
};

echo $anonyFunc("Josh");

// Result:
// Hello Josh

將 anoymous function 指定給 anonyFunc 變數,但實際上 anonyFuncClosure 物件,它看起來像函式,卻是個物件,與函式相同語法,可接受參數,也可回傳參數,但是卻沒有函式名稱。

但事實上在 PHP 底層,這個 anonymous function 是實作在 Closure 物件的 __invoke() 這個 magic method,因此我們也可以這樣寫

PHP
$anonyFunc = function ($name) {
    return return 'Hello ' . $name;
};

echo $anonyFunc->__invoke("Josh");

// Result:
// Hello Josh

執行結果完全一樣。

實務上我們不會去使用 __invoke() ,但 PHP 底層卻是靠 __invoke() 去實作 anonymous function。 11 11 關於 __invoke() ,詳細請參考 如何使用 Closure?

但因為我們現在是要去 mock closure,因此就相當於要去 mock __invoke()

為什麼是 stdClass ?

我們知道 anonymous function 底層是 Closure 物件,理論上要去 mock Closure class,但令人沮喪的是, Closure 在 PHP 內部已經被宣告成 final ,因此無從 mock。

The predefined final class Closure was introduced in PHP 5.3.0. It is used for representing anonymous functions.

別忘了 PHP 的 Duck Type 特性 :

當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。

既然如此,我們就可以對 stdClass 動刀,讓他加上 __invoke() ,並對 __invoke() 加以 mock,讓他看起來像 Closure

什麼是 Partial Mock?

一般我們只會使用普通 Mock,但有兩個地方可能會用到 Partial Mock :

  1. 當你不想 mock 一個 class 所有 method,只想 mock 其中一兩個 method 時。
  2. 當你想 mock 一個無中生有的 method 時。

stdClass 並沒有任何 method,且 __invoke() 本應屬於 Closure ,對於 stdClass 是無中生有的,因此我們必須使用 Partial Mock 才能將 __invoke() 加在 stdClass 上。

13 行

$mock->expects($this->once())
    ->method('__invoke')
    ->withAnyParameters()
    ->willReturn(110);

接下來則跟普通 mock 方式一樣,可以發現我們是直接對 __invoke() 做 mock。

19 行

$weight = 1;
$actual = App::call(ShippingService::class . '@calculateFee', [
   'weight'    => $weight,
   'logistics' => $mock,
]);

將 mock 物件透過 App::call() 傳入。

如何使用 PHPUnit mock Closure?

實際跑單元測試,會得到第 3 個 綠燈

整合測試 ShippingServiceTest.php 12 12 GitHub Commit : 整合測試 : ShippingService 使用 closure

Services/ShippingServiceTest.php
declare(strict_types = 1);

use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{
    /** @test */
    public function 黑貓整合測試()
    {
        /** arrange */

        /** act */
        $weight = 1;
        $actual = App::call(ShippingService::class . '@calculateFee', [
            'weight'    => $weight,
            'logistics' => function (int $weight) {
                return 100 * $weight + 10;
            },
        ]);

        /** assert */
        $expected = 110;
        $this->assertEquals($expected, $actual);
    }
}

14 行

$actual = App::call(ShippingService::class . '@calculateFee', [
    'weight'    => $weight,
    'logistics' => function (int $weight) {
        return 100 * $weight + 10;
    },
]);

整合測試與單元測試的差異就是不 mock,因此我們直接將 closure 傳入。

如何使用 PHPUnit mock Closure?

實際跑整合測試,會得到第 4 個 綠燈

Conclusion

  • Mock closure 並非是炫技,實務上經常用到,尤其當你從物件導向編程,慢慢趨向函數式編程時,常常會重構成 closure,Taylor Otwell 在 Laravel 內部也大量使用 closure,只要使用到 closure,就可能面臨在單元測試時隔離 closure 的需求,透過本文的方式,你將可輕鬆的將 closure 加以 mock。 13 13 關於 closure 實務上的應用,詳細請參考 實務上如何活用 Closure?

Sample Code

完整的範例可以在我的 GitHub 上找到。

Reference

PHPUnit, Closure mock Issue

Sabastian Bergmann, PHPConf Taiwan 2016 Lately in PHP(Unit)

大澤木小鐵, 在 PHPUnit 中測試需要 closure 的函式

 
标签: PHPUnit
反对 0举报 0 评论 0
 

免责声明:本文仅代表作者个人观点,与乐学笔记(本网)无关。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
    本网站有部分内容均转载自其它媒体,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责,若因作品内容、知识产权、版权和其他问题,请及时提供相关证明等材料并与我们留言联系,本网站将在规定时间内给予删除等相关处理.

  • PHPUnit 5.7.9 发布,PHP 单元测试框架
    PHPUnit 5.7.9 发布,PHP 单元测试框架
    PHPUnit 5.7.9 发布,PHP 单元测试框架王练 发布于2017年02月03日 收藏 0 MongoDB漏洞是什么?应该如何防护? PHPUnit 5.7.9发布了,PHPUnit是面向 PHP 的测试框架,是单元测试框架xUnit 的一个实例。 更新内容:修复:Reverted backwards incompatible chang
  • PHPUnit 6.0.3 和 5.7.10 发布,PHP 单元测试框架
    PHPUnit 6.0.3 和 5.7.10 发布,PHP 单元测试框
    PHPUnit 6.0.3 和 5.7.10 发布,PHP 单元测试框架王练 发布于2017年02月05日 收藏 0 MongoDB漏洞是什么?应该如何防护? PHPUnit 6.0.3和 5.7.10 发布了,PHPUnit是面向 PHP 的测试框架,是单元测试框架xUnit 的一个实例。 6.0.3 更新内容: Fixed #2460 : 更
  • PHPUnit入门基础教程
    PHPUnit入门基础教程
    PHPUnit是PHP语言的单元测试框架、工具,xunit单元测试工具系列成员之一,可以单独运行在Linux或windows系统下面,也可以集成到zend studio等IDE工具中。 工具下载: https://phpunit.de/index.html在线手册: https://phpunit.de/manual/4.6/zh_cn/index.htm
    02-05 PHPUnit
  • 组合使用Laravel和vfsStream测试文件上传
    核心要点 在应用开发中,测试是很重要的,在诸多的开发工具中,测试驱动开发是很伟大的一项; 测试文件上传并不像人们想象的那么简单; 目前,有很多很棒,但不为大家所熟知的测试工具; Larval能够让请求的校验更容易; 测试并不需要实际的文件系统,因为如
  • 如何使用 PHPUnit 測試 private 與 protected method?
    如何使用 PHPUnit 測試 private 與 protected m
    剛開始學習寫測試時,最多人的疑問就是該如何測試 private 與 protected method?,理論上不該去測試 private 與 protected ,本文會介紹一個 PHP 邪惡的技巧來完成測試,但建議除非萬不得已,不要使用此方法。 Motivation 在 PHPConf 第二天的 workshop,我曾
    11-22 PHPUnit
  • Phpstorm配置phpunit对php进行单元测试
    Phpstorm配置phpunit对php进行单元测试
    在 phpstorm 中配置 php 项目的单元测试,项目使用 Composer 进行管理,为了避免在项目中直接引入 phpunit 相关代码包,使项目的 vendor 目录变得臃肿,这里采用全局安装方式安装了 phpunit 代码包。 composer global require phpunit/phpunit=5.0.* 安装完成
  • PhpStorm 10 发布,PHP 7 和 PHPUnit 5 支持
    PhpStorm 10 发布,PHP 7 和 PHPUnit 5 支持
    PhpStorm 10 正式版发布 下载 ,此版本最主要的是 PHP 语言支持,编辑体验改进,调试器改进,代码分析改进和其他强大的新特性。 改进列表:PHP 语言和编辑体验:PHP 7支持 (including PHP 7 compatibility inspections),改进代码完成功能 新调试体验:PHP 交
  • PHP单元测试利器 PHPUNIT初探第1/2页
    PHP单元测试利器 PHPUNIT初探第1/2页
    你是否在程序开发的过程中遇到以下的情况:当你花了很长的时间开发一个应用后,你认为应该是大功告成了,可惜在调试的时候,老是不断的发现bug,而且最可怕的是,这些bug是重复出现的,你可能发现这些bug之间会有关联,但却老是找不到问题的所在。
    11-15 PHPUNIT
  • PHP单元测试利器 PHPUNIT深入用法(二)第1/2页
    PHP单元测试利器 PHPUNIT深入用法(二)第1/2页
    在上一篇PHP单元测试利器:PHPUNIT初探文章中,我们对phpunit有了一个初步的认识,在本文中将继续深入讲解下phpunit中的一些用法。
  • PHPUnit PHP测试框架安装方法
    PHPUnit是一个轻量级的PHP测试框架。它是在PHP5下面对JUnit3系列版本的完整移植,是xUnit测试框架家族的一员(它们都基于模式先锋Kent Beck的设计)。
点击排行