如何測試內含相依物件的函式?
談到unit test,就不能不談到isolated test,但實務上常常因為函式內有相依物件而無法做到isolated ,若是依照TDD的方式寫unit test,因為測試先寫,會一開始就考慮到可測試性,自然會將相依物件改用DI與service container的方式,但若是事後補測試,就常常會有因為相依物件而造成測試加不上去的問題。
本文歸納出幾個方法,有依賴注入方法,有OOP反璞歸真方法,最後還有PHP邪惡方法。
Version
PHP 5.6.15
Laravel 5.1.24
Homestead 0.3.0
傳統寫法
在Taylor Otwell的Laravel: From Apprentice To Artisan的第2頁談到一段code :1
2
3
4
5
6
7
8
9class UserController extends BaseController
{
public function index()
{
$users = User::all();
return view('users.index', compact('users'));
}
}
大部分人學MVC架構,都是這樣寫controller,別小看這兩行code,若我們想對controller做unit test時,User::all()這一行我們就面臨很大的問題 :
User是個model,也就是相依物件,它直接到資料庫去抓資料,若我們要對UserController@index寫測試時,勢必得直接去資料庫抓資料,由於資料庫比較慢,這就違反了unit test的FIRST原則的Fast。為了isolated test,我們會想將
User給mock掉,但因為User直接相依在index()內,導致我們無從注入mock,因此無法寫測試。
為什麼要隔離?
實際程式彼此之間一定都有關聯,所以一定會有相依物件,為了做unit test,我們需要將這些相依物件做隔離 : 1 1以下5點歸納來自於大澤木小鐵的在PHP上學會自動化測試與實戰TDD與陳仕傑在SkillTree開的自動測試與TDD開發實務(使用C#)第四梯的講義內容。
執行速度快
unit test的執行速度就是要快,才能方便我們不斷的測試與重構,若程式中使用了外部API、檔案存取、資料庫存取這些,一定要想辦法隔離掉,才能加速測試速度,若測試包含了外部API、檔案存取、資料庫存取,就不算是unit test了,而是integration test。關注點分離
現在unit test要測試的是controller,而非model,因此我們希望能將model給mock掉,將關注點鎖定在controller。單一職責
物件導向 SOLID 的第一條要求 單一職責,若以測試的角度來看,單一職責讓我們容易寫測試,試想controller內同時包含商業邏輯與資料庫邏輯時,由於功能太多,我們測試會多難寫呢?獨立測試
若被測試的程式包含外部API、檔案存取、資料庫存取等相依物件,我們在做測試時就必須要有很多前提,如- 網路必須暢通才能存取外部API。
- 檔案必須存在才能存取。
- 資料庫必須要有某些資料才能測試。
若我們能將這些相依物件加以隔離,也就是說不用再依賴
某些前提下才能做測試,我們就能單獨對每一個method做測試,也因為如此,我們會非常容易debug,若某個method的unit test出錯,就一定是該method的邏輯有問題,而不是外部API、檔案或資料庫有問題。測試程式的健壯性
一個好的unit test,應該只在需求異動才需要修改,若沒有隔離掉相依物件,則可能相依物件修改,則unit test也要跟著修改。
解決方法
依賴注入方法
最主流的方法就是使用DI搭配Laravel的service container,讓所有的相依都透過costructor注入,這樣在測試時,我們就可以使用mock的方式產生假物件,徹底隔離相依物件。
若是用在controller,就會搭配repository pattern,將所有的資料庫邏輯寫在repository內,透過constructor注入到controller,而在controller則專心寫商業邏輯,在對controller做unit test時,就可以將repository加以mock,徹底隔離資料庫。
1 | namespace App\Http\Controllers; |
Controller改用repository pattern,UserRepository透過DI由constructor注入到controller,如此做法就符合 SOLID 的最後一條 DIP,高層不再相依於底層物件,而是改用注入的方式。
之後controller所有對資料庫的操作,都是透過UserRepository,而不直接透過User model。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
26namespace App\Repositories;
use App\User;
class UserRepository
{
/**
* @var User
*/
protected $user;
/**
* UserRepository constructor.
* @param $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
public function getAll()
{
return $this->user->all();
}
}
在app目錄下新增Repositories目錄,專門負責放repository,建立UserRepository,專門放與User model相關的資料庫邏輯。
當然在repository一樣可以使用model facade去存取資料庫,不過我個人偏好使用DI的方式,也就是將相依的User model也透過DI由constructor注入到repository。2 2Facade是Laravel 4.2的產物,在Laravel 5也可以使用,雖然使用方便,但由於facade是透過PHP的__call()動態產生,所以PhpStorm的auto complete無法使用,必須額外安裝__ide_helper.php,透過描述PhpDoc Blocks的@method去描述,才能使用auto complete,另外facade的使用會讓人沒有namespace的概念,與Laravel 5重視namespace的理念格格不入,因此我個人比較不喜歡使用facade。
若production code能改成這樣,unit test就非常好寫啦。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
32
33
34
35
36
37class TestCase extends Illuminate\Foundation\Testing\TestCase
{
/**
* The base URL to use while testing the application.
*
* @var string
*/
protected $baseUrl = 'http://localhost';
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
/**
* 初始化mock物件
*
* @param string $class
* @return Mockery
*/
public function initMock($class)
{
$mock = Mockery::mock($class);
$this->app->instance($class, $mock);
return $mock;
}
}
TestCase是Laravel預設所提供給測試用的class,所有自己的測試class都會繼承於TestCase。
24行1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 初始化mock物件
*
* @param string $class
* @return Mockery
*/
public function initMock($class)
{
$mock = Mockery::mock($class);
$this->app->instance($class, $mock);
return $mock;
}
由於每個測試class都會使用到mock,因此將mock初始化的功能initMock() pull member up到上層的TestCase。
首先傳入要mock的class或interface,是字串。
使用Mockery::mock()去mock該class或interface,Mockery是PHP負責做mock的package,Laravel預設已經整合在內。
$this->app->instance()則是告訴Laravel的service container,當type hint為該class時,使用指定的物件。與$this->app->bind()的差異是 :
bind()用來將指定的interface與class做連結。(不需指定class與class連結,Laravel會自動處理)instance()則是用來將指定的interface或class與物件做連結。
因為mock出來的是物件,所以要使用instance()。
1 | use App\Http\Controllers\UserController; |
第9行1
2
3
4/**
* @var UserController
*/
protected $target;
建立$target property, 負責放待測物件,建議加上PHPDoc blocks描述$target物件的型別,這樣的優點是將來PHPStorm的auto complete就可以自動顯示出待測物件的property與method。
14行1
2
3
4/**
* @var Mock
*/
protected $mock;
建立$mock property,負責放mock物件,建議加上PHPDoc blocks描述$mock物件的型別,這樣的優點是PHPStorm的auto complete可以自動顯示mockery的property與method,且inspection也不會再將shouldReceive()反白。
19行1
2
3
4
5
6
7
8
9
10
11/**
* Setup the test environment.
*
* @return void
*/
public function setUp()
{
parent::setUp();
$this->mock = $this->initMock(UserRepository::class);
$this->target = $this->app->make(UserController::class);
}
使用PhpStorm的⌃ + o建立override method,選擇TestCase的setUp()與tearDown()來override。
每個TestCase執行時,都會自動執行setUp(),因此可以在此建立mock物件與待測物件。
PhpStorm會自動產生parent::setUp(),表示會先執行TestCase的setUp()。
將我們要mock的class字串傳入稍早建立的TestCase的initMock(),並將建立完的mock物件回傳給$mock property。
使用service container的$this->app->make()替我們建立UserController物件,也就是我們要測試的controller。
UserController的constructor已經使用UserRepository注入,因此PHPUnit會抱怨constructor沒有傳入參數,所以必須改寫成new UserController($this->mock);,改傳入我們自己mock的UserRepository。31行1
2
3
4
5
6
7
8
9
10
11/**
* Clean up the testing environment before the next test.
*
* @return void
*/
public function tearDown()
{
$this->target = null;
$this->mock = null;
parent::tearDown();
}
每個TestCase執行完,都會自動執行tearDown(),因此可以在此將$target與$mock設定為null,避免下次unit test受到干擾。
43行1
2
3
4
5
6
7/**
* Test UserController@index
*
* @group UserController
* @group UserController0
*/
public function testIndex()
建立UserController@index的測試方法,依照PHPUnit習慣,若method以test為prefix命名,則自動會視為測試方法,否則必須自己在PHPDoc block加上@test。
建議在PHPDoc block加上@group為測試分類,方便PHPUnit可選擇某個group獨立測試。
51行1
2
3
4
5
6
7
8
9
10// arrange
$expected = new Collection([
['name' => 'oomusou', 'email' => '[email protected]'],
['name' => 'sam', 'email' => '[email protected]'],
['name' => 'sunny', 'email' => '[email protected]'],
]);
$this->mock->shouldReceive('getAll')
->once()
->withAnyArgs()
->andReturn($expected);
寫controlller的unit test依然會依照3A原則 : arrange、act與assert。
arrange: 準備測試資料$fake、mock物件$mock、待測物件$target,與建立測試期望值$expected。act: 執行待測物件的method,建立實際結果值$actual。assert: 使用PHPUnit的assertXXX()測試$expected與$actual是否如預期。
先談arrange部分 :
由於mock物件與待測物件已經在setUp()建立,目前將焦點放在測試期望值與mock物件該mock什麼method。
UserRepository的getAll()是實際到資料庫去撈3筆資料,為了要符合isolated test要求,這三筆資料我們必須自己mock。
$expected自己使用new Collection()建立了3筆假資料。
之前只建立了mock物件,但卻還沒描述此mock物件到底該mock哪個method,我們使用Mockery的shouldReceive()來mock UserRepository的getAll()。
once()表示此method只會被執行一次,若你mock的method被執行超過一次,PHPUnit就會報錯,因此很適合驗證物件的互動,當然還提供twice()與times(),可依實際需求而使用。
withAnyArgs()則用來mock argument,表示任何argument都可接受,若你要單獨mock每一個argument,可以使用with()或withArgs()。不過一般來說,我們mock重視的是method與回傳值,所以使用withAnyArgs()即可。
andReture()用來mock回傳值,UserRepository的getAll()回傳的是collection,所以我們也要mock一個collection,否則PHPUnit會報錯。
經過這樣的mock之後,unit test就不會去資料庫撈資料了,而是執行我們自己mock的UserRepository的getAll()。3 3關於Mockery提供的method詳細用法,請參考Mockery Docs:Expectation Declarations
62行1
2
3
4// act
/** @var View $view */
$view = $this->target->index();
$actual = $view->users;
再來談act部分 :act就是要實際的去戳待測物件的method,並將執行結果傳回$actual。
以我們要測試UserController@index為例,就是要實際執行$this->target->index(),因為該controller的結果是傳回view,所以$view實際上是一個View型別的變數。
至於我們要驗證的$actual並不是view,而是view裡面的users,也就是我們透過compact('users')傳進去的collection。
理論上不加也可,但PHPStorm會將$view->users的users反白,因為PHPStorm不知道$view的型別為何,所以認為users為非法,不過加了也沒有完全解決問題,只是從反白的error,變成warning,因為users是magic method所產生的,所以PHPStorm一樣無能為力。
67行1
2// assert
$this->assertEquals($expected, $actual);
最後是assert部分 :
確認view所收到的collection與我們mock的collection是否相同。

在回答這個問題前,要先釐清一件事情,到底對controller的unit test該測試哪些事情,在Jeferry Way的Laravel Testing Decoded這本書的第10章 : Testing Controllers,對controller的unit test做了以下的定義 :
Controller tests should verify responses, ensure that the correct database access methods are triggered, and assert that the appropriate instance variables are sent to the view.
在之前的unit test中,我們mock了UserRepository的getAll(),也定義了once(),所以確定database access method已經被trigger。
也使用了assertEqual()確定了我們的$expected已經送到了view。
由於controller主要在放商業邏輯,由於商業邏輯的不同,會呼叫不同的repository的資料庫邏輯,所以controller的unit test最主要的就是要確認不同的測試案例下,repository的method是否有被正確trigger,並將預期的資料送到view。
至於user在瀏覽器的操作,那已經屬於integration test的事情,不算controller的unit test範圍。
OOP反璞歸真方法
DI與service container的方式,透過constructor與type hint,讓我們完成了isolated test,但若是legacy code,可能constructor另有用途,如傳進初始值,因此不太方便再使用constructor注入相依物件,此時我們該怎麼做isolated test呢?在91哥陳仕節在SkillTree開的自動測試與TDD開發實務(使用C#)第四梯與The Art of Unit Testing:with examples in C#中,提到一個絕妙的測試方法,可以套用在所有的OOP語言中,當然也包括PHP在內,91哥稱之為OOP反璞歸真方法。
原本的controller長這樣 :1
2
3
4
5
6
7
8
9class UserController extends BaseController
{
public function index()
{
$users = User::all();
return view('users.index', compact('users'));
}
}
為了能加上unit test,我們將controller動點手腳,但是這次我們不使用repository pattern,也不使用service controller,只將無法測試的User::all(),利用重構的extract method提出來。4 4在PhpStorm可將滑鼠放在User::all()之後,使用⌃ + t,呼叫Refactor This,所有重構的工具都在這裡。

輸入要重構的method名稱:getAll。
PhpStorm將替我們重構成如下的程式。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
27namespace App\Http\Controllers;
use App\Http\Requests;
use App\User;
class UserController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$users = $this->getAll();
return view('users.index', compact('users'));
}
/**
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
protected function getAll()
{
return User::all();
}
}
我們可以發現UserController並沒有被大改,沒用到repository pattern,也沒用到constructor,僅使用重構手法將User::all()提到getAll()裡面而已。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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79use App\Http\Controllers\UserController;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\View;
class UserControllerTest extends TestCase
{
/**
* @var StubUserController
*/
protected $target;
/**
* Setup the test environment.
*
* @return void
*/
public function setUp()
{
parent::setUp();
}
/**
* Clean up the testing environment before the next test.
*
* @return void
*/
public function tearDown()
{
$this->target = null;
parent::tearDown();
}
/**
* Test UserController@index
*
* @group UserController
* @group UserController0
*/
public function testIndex()
{
// arrange
$expected = new Collection([
['name' => 'oomusou', 'email' => '[email protected]'],
['name' => 'sam', 'email' => '[email protected]'],
['name' => 'sunny', 'email' => '[email protected]'],
]);
$this->target = new StubUserController($expected);
// act
/** @var View $view */
$view = $this->target->index();
$actual = $view->users;
// assert
$this->assertEquals($expected, $actual);
}
}
class StubUserController extends UserController
{
/**
* @var Collection
*/
private $users;
/**
* StubUserController constructor.
* @param Collection $users
*/
public function __construct(Collection $users)
{
$this->users = $users;
}
public function getAll()
{
return $this->users;
}
}
59行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class StubUserController extends UserController
{
/**
* @var Collection
*/
private $users;
/**
* StubUserController constructor.
* @param Collection $users
*/
public function __construct(Collection $users)
{
$this->users = $users;
}
public function getAll()
{
return $this->users;
}
}
關鍵就在這裡啦,我們在unit test內自己產生一個StubUserController,繼承於UserController,並在constructor內允許我們去注入假資料,由於StubUserController是寫在UserControllerTest內,並不是寫在UserController,因此不會影響到原來production code的constructor。
另外一個關鍵就是我們去override UserController的getAll(),將其改成return $this->users,也就是不再是原本的return User::all(),這樣就會將getAll()改成回傳我們自己傳進去的假資料,而沒透過資料庫。5 5PHP可能對OOP的virtual與override比較無感,別忘了PHP每個method天生就是virtual,每個method都可以被override。詳細請參考PHP與C#語法快速導覽之virtual與orverride
至於原本的index()因為會被繼承下來,所以可以拿此index()來測試,只是index()內所呼叫的$this->getAll()已經被我們override掉了。
41行1
2
3
4
5
6
7// arrange
$expected = new Collection([
['name' => 'oomusou', 'email' => '[email protected]'],
['name' => 'sam', 'email' => '[email protected]'],
['name' => 'sunny', 'email' => '[email protected]'],
]);
$this->target = new StubUserController($expected);
若能看懂之前靠繼承的stub class的手法,現在要測試就很容易了,待測物件只要去new我們剛剛建立的stub class即可,並將假資料透過constructor送進去。
之後的act與assert都完全不變。

PHP邪惡方法
回想之前的OOP反璞歸真方式,其實說白了,就是想去改掉UserController的getAll(),改成不要從資料庫抓資料,希望直接傳回假資料,但是靜態語言不允許我們直接去修改method,所以我們只能透過繼承的方式去override掉getAll(),但問題來了,因為是繼承,所以getAll()還是在StubUserController內,而不是在UserControllerTest內,所以我們只好透過contructor將假資料傳進去。
但別忘了PHP是動態語言,只要我們能在UserControllerTest去直接改掉UserController物件的getAll(),並在修改的時候,直接將假資料一起放進去,這樣就連繼承也不用使用了。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
32
33
34namespace App\Http\Controllers;
use App\Http\Requests;
use App\User;
use Closure;
class UserController extends Controller
{
/**
* @var Closure
*/
protected $getAll;
/**
* UserController constructor.
*/
public function __construct()
{
$this->getAll = function () {
return User::all();
};
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$users = $this->getAll->__invoke();
return view('users.index', compact('users'));
}
}
第9行1
2
3
4
5
6
7
8
9
10
11
12
13
14/**
* @var Closure
*/
protected $getAll;
/**
* UserController constructor.
*/
public function __construct()
{
$this->getAll = function () {
return User::all();
};
}
在OOP反璞歸真方法時,我們是將getAll()直接定義成class的method,但這種方式只要定下去,就無法在改了,除非使用繼承與virtual/override方式。為了讓getAll()可以被動態修改,我們改使用closure,將getAll()成為property,並且註解為Closure型別。
將原本getAll() method改在constructor去定義getAll closure。
在此我們雖然使用了constructor,但並沒有去使用constructor的argument,因此對legacy code完全沒有影響。
24行1
2
3
4
5
6
7
8
9
10/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$users = $this->getAll->__invoke();
return view('users.index', compact('users'));
}
原本$this->getAll()改成$this->getAll->__invoke(),因為PHP的function並非如JavaScript的一級函式,$this->getAll()是呼叫method的語法,而目前getAll已經成為closure,是物件,需採用closure本身的method:__invoke()來執行。
1 | use App\Http\Controllers\UserController; |
22行1
2
3
4
5
6
7
8
9$target = new UserController();
$closure = function () use ($expected) {
$this->getAll = function () use ($expected){
return $expected;
};
};
$closure = $closure->bindTo($target, $target);
$closure();
待測物件$target直接new UserController,而不使用stub class。
重點在這裡,定義$closure,其目的就是去修改getAll closure,將其return User::all()改成return $expected,但問題來了,$expected是外部資料,PHP並無法如JavaScript一樣可以直接取用外部資料,所以必須使用use,一層一層將$expected傳進去。
另一個問題,closure內的$this是什麼?我們希望的是$this就是UserController,沒問題,我們使用bindTo()將UserController直接取代掉$this。
最後使用$closure()自己執行自己,執行完之後,UserController的getAll()就被我們取代掉了。6 6關於bindTo()的玄妙,詳細請參考深入探討bindTo()
之後的act與assert都完全不變。

Conclusion
- 本篇重點雖然是在解決函式內有
相依物件而無法寫unit test的問題,不過因為是拿controller來當範例,所以事實上也學到了controller的unit test寫法,不過實務上若使用TDD,會先寫controller的unit test,而非如本篇先寫controller再補unit test。 - 實務上該使用哪種方法呢?
DI + service container的方法是首選,這是最主流的方法,若是legacy code,使用constructor注入有執行上的困難時,則OOP反璞歸真方法也很棒,至於PHP邪惡方法,因為bindTo()太玄妙,懂得人並不多,這種寫法恐有維護上的考量。
Sample Code
完整的範例可以在我的GitHub上找到。