幾個解決內含相依物件而無法隔離測試的方法

談到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 :

app/Http/Controllers/UserController.php
1
2
3
4
5
6
7
8
9
class UserController extends BaseController
{

public function index()
{

$users = User::all();

return view('users.index', compact('users'));
}
}

大部分人學MVC架構,都是這樣寫controller,別小看這兩行code,若我們想對controller做unit test時,User::all()這一行我們就面臨很大的問題 :

  1. User是個model,也就是相依物件,它直接到資料庫去抓資料,若我們要對UserController@index寫測試時,勢必得直接去資料庫抓資料,由於資料庫比較慢,這就違反了unit test的FIRST原則的Fast

  2. 為了isolated test,我們會想將User給mock掉,但因為User直接相依在index()內,導致我們無從注入mock,因此無法寫測試。

為什麼要隔離?


實際程式彼此之間一定都有關聯,所以一定會有相依物件,為了做unit test,我們需要將這些相依物件做隔離 : 1 1以下5點歸納來自於大澤木小鐵在PHP上學會自動化測試與實戰TDD陳仕傑SkillTree開的自動測試與TDD開發實務(使用C#)第四梯的講義內容。

  1. 執行速度快
    unit test的執行速度就是要快,才能方便我們不斷的測試與重構,若程式中使用了外部API檔案存取資料庫存取這些,一定要想辦法隔離掉,才能加速測試速度,若測試包含了外部API檔案存取資料庫存取,就不算是unit test了,而是integration test。

  2. 關注點分離
    現在unit test要測試的是controller,而非model,因此我們希望能將model給mock掉,將關注點鎖定在controller。

  3. 單一職責
    物件導向 SOLID 的第一條要求 單一職責,若以測試的角度來看,單一職責讓我們容易寫測試,試想controller內同時包含商業邏輯資料庫邏輯時,由於功能太多,我們測試會多難寫呢?

  4. 獨立測試
    若被測試的程式包含外部API檔案存取資料庫存取等相依物件,我們在做測試時就必須要有很多前提,如

    • 網路必須暢通才能存取外部API。
    • 檔案必須存在才能存取。
    • 資料庫必須要有某些資料才能測試。

    若我們能將這些相依物件加以隔離,也就是說不用再依賴某些前提下才能做測試,我們就能單獨對每一個method做測試,也因為如此,我們會非常容易debug,若某個method的unit test出錯,就一定是該method的邏輯有問題,而不是外部API檔案資料庫有問題。

  5. 測試程式的健壯性
    一個好的unit test,應該只在需求異動才需要修改,若沒有隔離掉相依物件,則可能相依物件修改,則unit test也要跟著修改。

解決方法


依賴注入方法

最主流的方法就是使用DI搭配Laravel的service container,讓所有的相依都透過costructor注入,這樣在測試時,我們就可以使用mock的方式產生假物件,徹底隔離相依物件。

若是用在controller,就會搭配repository pattern,將所有的資料庫邏輯寫在repository內,透過constructor注入到controller,而在controller則專心寫商業邏輯,在對controller做unit test時,就可以將repository加以mock,徹底隔離資料庫。

app/Http/Controller/UserController.php
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
namespace App\Http\Controllers;

use App\Http\Requests;
use App\Repositories\UserRepository;

class UserController extends Controller
{

/**
* @var UserRepository
*
*/

protected $userRepository;

/**
* UserController constructor.
* @param UserRepository $userRepository
*/

public function __construct(UserRepository $userRepository)
{

$this->userRepository = $userRepository;
}


/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/

public function index()
{

$users = $this->userRepository->getAll();

return view('users.index', compact('users'));
}
}

Controller改用repository pattern,UserRepository透過DI由constructor注入到controller,如此做法就符合 SOLID 的最後一條 DIP,高層不再相依於底層物件,而是改用注入的方式。

之後controller所有對資料庫的操作,都是透過UserRepository,而不直接透過User model。

app/Repositories/UserRepository.php
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
namespace 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就非常好寫啦。

tests/TestCase.php
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
class 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()

tests/UserControllTest.php
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
use App\Http\Controllers\UserController;
use App\Repositories\UserRepository;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\View;
use Mockery\Mock;

class UserControllerTest extends TestCase
{

/**
* @var UserController
*/

protected $target;

/**
* @var Mock
*/

protected $mock;

/**
* Setup the test environment.
*
* @return void
*/

public function setUp()
{

parent::setUp();
$this->mock = $this->initMock(UserRepository::class);
$this->target = $this->app->make(UserController::class);
}

/**
* Clean up the testing environment before the next test.
*
* @return void
*/

public function tearDown()
{

$this->target = null;
$this->mock = null;
parent::tearDown();
}

/**
* Test UserController@index
*
* @group UserController
* @group UserController0
*/

public function testIndex()
{

// arrange
$expected = new Collection([
['name' => 'oomusou', 'email' => 'oomusou@gmail.com'],
['name' => 'sam', 'email' => 'sam@gmail.com'],
['name' => 'sunny', 'email' => 'sunny@gmail.com'],
]);
$this->mock->shouldReceive('getAll')
->once()
->withAnyArgs()
->andReturn($expected);

// act
/** @var View $view */
$view = $this->target->index();
$actual = $view->users;

// assert
$this->assertEquals($expected, $actual);
}
}

第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,選擇TestCasesetUp()tearDown()來override。

每個TestCase執行時,都會自動執行setUp(),因此可以在此建立mock物件與待測物件。

PhpStorm會自動產生parent::setUp(),表示會先執行TestCasesetUp()
將我們要mock的class字串傳入稍早建立的TestCaseinitMock(),並將建立完的mock物件回傳給$mock property。

使用service container的$this->app->make()替我們建立UserController物件,也就是我們要測試的controller。

一定要先建立mock物件,才能建立待測物件,因為UserController需要依賴UserRepository注入,所以需要先使用$this->app->instance()先建立好mock物件,再執行$this->app->make(),否則PHPUnit會報錯。

可以使用DI方式,自己new UserController()嗎?
由於UserController的constructor已經使用UserRepository注入,因此PHPUnit會抱怨constructor沒有傳入參數,所以必須改寫成new UserController($this->mock);,改傳入我們自己mock的UserRepository

建議既然已經使用了service container,DI就完全交給Laravel處理,不用再自己new了。

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受到干擾。

在Laravel 5.1,Mockery::close()會在Illuminate\Foundation\Testing\TestCasetearDown()被執行,因此我們不必再自己加Mockery::close(),不過在Laravel 4.2,我們必須自己在tearDown()Mockery::close(),否則mock不會正常執行。

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' => 'oomusou@gmail.com'],
['name' => 'sam', 'email' => 'sam@gmail.com'],
['name' => 'sunny', 'email' => 'sunny@gmail.com'],
]);
$this->mock->shouldReceive('getAll')
->once()
->withAnyArgs()
->andReturn($expected);

寫controlller的unit test依然會依照3A原則 : arrangeactassert

  • arrange : 準備測試資料 $fakemock物件 $mock待測物件 $target,與建立測試期望值 $expected
  • act : 執行待測物件的method,建立實際結果值 $actual
  • assert : 使用PHPUnit的assertXXX()測試$expected$actual是否如預期。

先談arrange部分 :
由於mock物件待測物件已經在setUp()建立,目前將焦點放在測試期望值mock物件該mock什麼method

UserRepositorygetAll()是實際到資料庫去撈3筆資料,為了要符合isolated test要求,這三筆資料我們必須自己mock。

$expected自己使用new Collection()建立了3筆假資料。

之前只建立了mock物件,但卻還沒描述此mock物件到底該mock哪個method,我們使用Mockery的shouldReceive()來mock UserRepositorygetAll()

once()表示此method只會被執行一次,若你mock的method被執行超過一次,PHPUnit就會報錯,因此很適合驗證物件的互動,當然還提供twice()times(),可依實際需求而使用。

withAnyArgs()則用來mock argument,表示任何argument都可接受,若你要單獨mock每一個argument,可以使用with()withArgs()。不過一般來說,我們mock重視的是method回傳值,所以使用withAnyArgs()即可。

andReture()用來mock回傳值UserRepositorygetAll()回傳的是collection,所以我們也要mock一個collection,否則PHPUnit會報錯。

經過這樣的mock之後,unit test就不會去資料庫撈資料了,而是執行我們自己mock的UserRepositorygetAll()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。

為什麼要替$view加上PHPDoc block描述型別為View?

理論上不加也可,但PHPStorm會將$view->usersusers反白,因為PHPStorm不知道$view的型別為何,所以認為users為非法,不過加了也沒有完全解決問題,只是從反白的error,變成warning,因為users是magic method所產生的,所以PHPStorm一樣無能為力。

不過在此學到一個技巧,若在PHPStorm反白了一個property或method,而你確定這個method沒問題,只是PHPStorm不知道該變數型別而已,可以在該變數上面馬上補上PHPDoc block描述該變數型別,就可解掉反白問題。

67行

1
2
// assert
$this->assertEquals($expected, $actual);

最後是assert部分 :
確認view所收到的collection與我們mock的collection是否相同。

測試controller只測試這樣而已嗎?感覺與期待有落差...

在回答這個問題前,要先釐清一件事情,到底對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.

Jeffery Way  - Laravel Testing Decoded

在之前的unit test中,我們mock了UserRepositorygetAll(),也定義了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長這樣 :

app/Http/Controllers/UserController.php
1
2
3
4
5
6
7
8
9
class 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將替我們重構成如下的程式。

app/Http/Controllers/UserController.php
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
namespace 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()裡面而已。

tests/UserControllTest.php
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
79
use 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' => 'oomusou@gmail.com'],
['name' => 'sam', 'email' => 'sam@gmail.com'],
['name' => 'sunny', 'email' => 'sunny@gmail.com'],
]);
$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
21
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;
}
}

關鍵就在這裡啦,我們在unit test內自己產生一個StubUserController,繼承於UserController,並在constructor內允許我們去注入假資料,由於StubUserController是寫在UserControllerTest內,並不是寫在UserController,因此不會影響到原來production code的constructor。

另外一個關鍵就是我們去override UserControllergetAll(),將其改成return $this->users,也就是不再是原本的return User::all(),這樣就會將getAll()改成回傳我們自己傳進去的假資料,而沒透過資料庫5 5PHP可能對OOP的virtualoverride比較無感,別忘了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' => 'oomusou@gmail.com'],
['name' => 'sam', 'email' => 'sam@gmail.com'],
['name' => 'sunny', 'email' => 'sunny@gmail.com'],
]);
$this->target = new StubUserController($expected);

若能看懂之前靠繼承的stub class的手法,現在要測試就很容易了,待測物件只要去new我們剛剛建立的stub class即可,並將假資料透過constructor送進去。
之後的actassert都完全不變。

這是一個OOP語言的通用手法,只要程式語言支援繼承,有virtualoverride機制, 就可以使用這個技巧,所以C#也能用,PHP一樣也能用。
OOP其實有2招可以封裝變化,一招是用interface,也就是用多型,一招就是用繼承,將變化封裝在virtual內,由子類別去override,只是因為design pattern的盛行,強調『多用組合,少用繼承』,大家漸漸不敢用繼承,事實上繼承只要用的巧,還是很妙的,所以91哥稱此為『OOP反璞歸真方法』。

PHP邪惡方法

回想之前的OOP反璞歸真方式,其實說白了,就是想去改掉UserControllergetAll(),改成不要從資料庫抓資料,希望直接傳回假資料,但是靜態語言不允許我們直接去修改method,所以我們只能透過繼承的方式去overridegetAll(),但問題來了,因為是繼承,所以getAll()還是在StubUserController內,而不是在UserControllerTest內,所以我們只好透過contructor將假資料傳進去。

但別忘了PHP是動態語言,只要我們能在UserControllerTest去直接改掉UserController物件的getAll(),並在修改的時候,直接將假資料一起放進去,這樣就連繼承也不用使用了。

app/Http/Controller/UserController.php
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
namespace 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()來執行。

tests/UserControllerTest.php
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
use App\Http\Controllers\UserController;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\View;

class UserControllerTest extends TestCase
{

/**
* Test UserController@index
*
* @group UserController
* @group UserController0
*/

public function testIndex()
{

// arrange
$expected = new Collection([
['name' => 'oomusou', 'email' => 'oomusou@gmail.com'],
['name' => 'sam', 'email' => 'sam@gmail.com'],
['name' => 'sunny', 'email' => 'sunny@gmail.com'],
]);

$target = new UserController();

$closure = function () use ($expected) {
$this->getAll = function () use ($expected){
return $expected;
};
};
$closure = $closure->bindTo($target, $target);
$closure();

// act
/** @var View $view */
$view = $target->index();
$actual = $view->users;

// assert
$this->assertEquals($expected, $actual);
}
}

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()自己執行自己,執行完之後,UserControllergetAll()就被我們取代掉了。6 6關於bindTo()的玄妙,詳細請參考深入探討bindTo()

之後的actassert都完全不變。

PHP的bindTo(),最邪惡的地方在於它可以打破物件的封裝,直接去改變內部的private與protected變數,所以我稱為『PHP邪惡方法』,不過它的限制就是只能改變property,不能改變method,所以特別將method改成closure,讓bindTo()可以將手伸進來去改變$this,進而改變其closure,這樣就可以不用透過繼承與virtual/override,直接將getAll()改掉。

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上找到。

  1. 傳統寫法
  2. 依賴注入方法
  3. OOP反璞歸真方法
  4. PHP邪惡方法