為了隔離測試,實務上還是有 mock closure 的需求,不過 PHPUnit 目前無法直接 mock 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 2GitHub Commit : 單元測試 : ShippingService 使用 interface

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 行

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

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

14 行

1
2
3
4
$mock->expects($this->once())
->method('calculateFee')
->withAnyParameters()
->willReturn(110);

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

19 行

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

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

22 行

1
2
3
4
$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 行

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

實際測試結果是否為 110

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

Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 5GitHub Commit : 建立 LogisticsInterface

Services/LogisticsInterface.php
1
2
3
4
5
6
7
8
9
10
11
12
13
declare(strict_types = 1);

namespace App\Services;

interface LogisticsInterface
{

/**
* 計算運費
* @param int $weight
* @return int
*/

public function calculateFee(int $weight) : int;
}

定義 LogisticsInterfacecalculateFee()

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

Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(),將實際的計算運費演算法在此實現。

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

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

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 行

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

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

15 行

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

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

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

重構成 Closure


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

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

Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 10GitHub Commit : 單元測試 : ShippingService 使用 closure

Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 行

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

這一行有 3 個重點 :

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

為什麼要 mock __invoke?

PHP
1
2
3
4
5
6
7
8
$anonyFunc = function ($name) {
return 'Hello ' . $name;
};

echo $anonyFunc("Josh");

// Result:
// Hello Josh

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

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

PHP
1
2
3
4
5
6
7
8
$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 行

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

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

19 行

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

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

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

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

Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 行

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

整合測試與單元測試的差異就是不 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 的函式

2016-11-19