如何使用 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