如何使用 PHPUnit 測試 private 與 protected method?
剛開始學習寫測試時,最多人的疑問就是該如何測試 private 與 protected method? 理論上不該去測試 private 與 protected,本文會介紹一個 PHP 邪惡的技巧來完成測試,但建議除非萬不得已,不要使用此方法。
Motivation
在 PHPConf 第二天的 workshop,我曾經舉手問 PHPUnit 的原作者 Sabastian Bergmann:How to test private and protected method?,Sabastian 的回答也很鏗鏘有力:You can't,實務上我也真的沒測試過 private 與 protected method,不過藉此機會理解為什麼不該測試 private 與 protected 也是不錯的。
Version
PHP 7.0.8
Laravel 5.3.10
PHPUnit 5.5.5
為什麼不該測試 private 與 protected?
測試案例是來自於需求,
public才是來自於需求,而private與protected則是來自於重構,所以不應該特別去測試,而應該由public的測試案例自然去測試private與protected。若特別去測試
private與protected,則 coverage 將沒有意義,可以特別只針對private與protected寫測試,而達成 coverage 為100%,正確方法應該根據需求只測試public,若有些private與protected因而沒測試到,則有兩種可能:一個是測試案例不足,導致private與protected沒測到,另一個則是目前根本無此需求,沒測到的private與protected就是 over design。根據物件導向的封裝特性,
public會根據 interface 而穩定,但private與protected則會隨著日後重構而變化,若直接針對private與protected寫測試,則日後只要一重構,就必須修改測試,則將影響測試程式的健狀性,測試應該隨著需求改變而修改,不該隨著程式碼重構而修改。
實際案例
若真的萬不得已,需要對 private 與 protected 做測試時,以下介紹一個簡單的方式。
ShippingService.php 1 1GitHub Commit : 新增 ShippingService1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16declare(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()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
27declare(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 內部去呼叫 private 的 calculateFee()。
20 行1
2$weight = 1;
$actual = $__calculateFee->call($target, $weight);
在 PHP 7,Closure 物件新提供了 call(),可以讓我們直接將一個物件動態綁定到 closure 的 $this。3 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
- 非到萬不得已,不應該直接測試
private與protected,不要為了測試而測試。 - 本文以
private為範例,也可以套用在protected,一樣使用Closure::call()的方式。 Closure::call()違反物件導向封裝原則,實務上不建議使用,除非真的萬不得已。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
PHP, Closure::call