如何測試內含相依物件的函式?
談到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上找到。