使用 Closure::call() 測試 private 與 protected method

剛開始學習寫測試時,最多人的疑問就是該如何測試 privateprotected method? 理論上不該去測試 privateprotected,本文會介紹一個 PHP 邪惡的技巧來完成測試,但建議除非萬不得已,不要使用此方法。

Motivation


在 PHPConf 第二天的 workshop,我曾經舉手問 PHPUnit 的原作者 Sabastian Bergmann:How to test private and protected method?,Sabastian 的回答也很鏗鏘有力:You can't,實務上我也真的沒測試過 privateprotected method,不過藉此機會理解為什麼不該測試 privateprotected 也是不錯的。

Version


PHP 7.0.8
Laravel 5.3.10
PHPUnit 5.5.5

為什麼不該測試 private 與 protected?


  1. 測試案例是來自於需求,public 才是來自於需求,而 privateprotected 則是來自於重構,所以不應該特別去測試,而應該由 public 的測試案例自然去測試 privateprotected

  2. 若特別去測試 privateprotected,則 coverage 將沒有意義,可以特別只針對 privateprotected 寫測試,而達成 coverage 為 100%,正確方法應該根據需求只測試 public,若有些 privateprotected 因而沒測試到,則有兩種可能:一個是測試案例不足,導致 privateprotected 沒測到,另一個則是目前根本無此需求,沒測到的 privateprotected 就是 over design。

  3. 根據物件導向的封裝特性,public 會根據 interface 而穩定,但 privateprotected 則會隨著日後重構而變化,若直接針對 privateprotected 寫測試,則日後只要一重構,就必須修改測試,則將影響測試程式的健狀性,測試應該隨著需求改變而修改,不該隨著程式碼重構而修改。

實際案例


若真的萬不得已,需要對 privateprotected 做測試時,以下介紹一個簡單的方式。

ShippingService.php 1 1GitHub Commit : 新增 ShippingService

Services/ShippingService.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 ShippingService
{

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

private function calculateFee(int $weight) : int
{

return 100 * $weight + 10;
}
}

calculateFee()private,我們該怎麼為這段程式補上測試呢?

單元測試 ShippingServiceTest.php 2 2GitHub Commit : 單元測試 : ShippingService 使用 Closure::call()

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
declare(strict_types = 1);

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

class ShippingServiceTest extends TestCase
{

/** @test */
public function 當重量為1kg時費用為110元()
{

/** arrange */
$target = App::make(ShippingService::class);

$__calculateFee = function (int $weight) : int {
return $this->calculateFee($weight);
};

/** act */
$weight = 1;
$actual = $__calculateFee->call($target, $weight);

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

13 行

1
$target = App::make(ShippingService::class);

建立 $target 測試物件,在本範例寫 $target = new ShippingService() 亦可,不過若在 class 內有使用到依賴注入,則必須使用 App::make(), 此時 Laravel 的 service container 會自動注入相對應的物件,實務上在寫測試時,建議使用 App::make() 取代 new

15 行

1
2
3
$__calculateFee = function (int $weight) : int {
return $this->calculateFee($weight);
};

因為 calculateFee()private,我們無法測試,因此特別建立一個 $__calculateFee closure,由 closure 內部去呼叫 privatecalculateFee()

20 行

1
2
$weight = 1;
$actual = $__calculateFee->call($target, $weight);

在 PHP 7,Closure 物件新提供了 call(),可以讓我們直接將一個物件動態綁定到 closure 的 $this3 3關於 PHP 7 的 Closure::call(),詳細請參考 Closure::call

closure 內的 $this,就會如同 JavaScript 的 this 一樣,自動指向被綁定的物件,如此我們就可以由自己建立的 closure,透過 this 去存取 private method。

call() 的第一個參數為要綁定的物件,之後的參數為要傳給 closure 的參數。4 4事實上這就是 PHP 5.4 所提供的 bindTo(),只是 PHP 7 的 Closure::bind() 可讀性更高,若想了解 bindTo(),詳細請參考 深入探討 bindTo()

24 行

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

測試結果是否如預期。

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

Conclusion


  • 非到萬不得已,不應該直接測試 privateprotected,不要為了測試而測試。
  • 本文以 private 為範例,也可以套用在 protected,一樣使用 Closure::call() 的方式。
  • Closure::call() 違反物件導向封裝原則,實務上不建議使用,除非真的萬不得已。

Sample Code


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

Reference


PHP, Closure::call

2016-11-20