剛開始學習寫測試時,最多人的疑問就是該如何測試 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
為什麼不該測試 priavate 與 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 1 GitHub Commit : 新增 ShippingService
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 2 GitHub Commit : 單元測試 : ShippingService 使用 Closure::call()
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) { return $this->calculateFee($weight); }; /** act */ $weight = 1; $actual = $__calculateFee->call($target, $weight); /** assert */ $expected = 110; $this->assertEquals($expected, $actual); } }
13 行
$target = App::make(ShippingService::class);
建立 $target
測試物件,在本範例寫 $target = new ShippingService()
亦可,不過若在 class 內有使用到依賴注入,則必須使用 App::make()
, 此時 Laravel 的 service container 會自動幫你啟動依賴注入,注入相對應的物件,實務上在寫測試時,建議使用 App::make()
取代 new
。
15 行
$__calculateFee = function (int $weight) { return $this->calculateFee($weight); };
因為 calculateFee()
為 private
,我們無法測試,因此特別建立一個 $__calculateFee
closure,由 closure 內部去呼叫 private
的 calculateFee()
。
20 行
$weight = 1; $actual = $__calculateFee->call($target, $weight);
在 PHP 7, Closure
物件新提供了 call()
,可以讓我們直接將自己建立的 closure 動態綁定到一個物件上。 3 3 關於 PHP 7 的 Closure::call()
,詳細請參考 Closure::call
closure 內的 $this
,就會如同 this
一樣,自動指向被綁定的物件,如此我們就可以由自己建立的 closure,透過 this
去存取 private
method。
call()
的第一個參數為要綁定的物件,之後的參數為要傳給 closure 的參數。 4 4 事實上這就是 PHP 5.4 所提供的 bindTo()
,只是 PHP 7 的 Closure::bind()
可讀性更高,若想了解 bindTo()
,詳細請參考深入探討 bindTo()
24 行
$expected = 110; $this->assertEquals($expected, $actual);
測試結果是否如預期。
實際跑單元測試,會得到 綠燈 。
Conclusion
- 非到萬不得已,不應該直接測試
private
與protected
。 - 本文以
private
為範例,也可以套用在protected
,一樣使用Closure::call()
的方式。 -
Closure::call()
違反物件導向封裝原則,實務上不建議使用,除非真的萬不得已。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
PHP, Closure::call