如何測試PHP原生函式?
PHP雖然有了Laravel,但實務上還是常常會使用PHP原生函式。假如是Laravel,因為函式都封裝在class內,因此很容易使用Mockery將某個class的method加以mock,但若使用的是PHP原生函式,因為並不是包在class內,所以無從mock,實務上我們該如何測試PHP原生函式呢?
Version
PHP 7.0.0
Laravel 5.2.22
PHPUnit 4.8.24
Mockery 0.9.4
測試str_shuffle()
實務上想寫個password產生器,會使用到PHP原生函式str_shuffle()
,但因為str_shuffle()
每次回傳值並不一樣,因此無法做單元測試,希望能透過mockery對str_shuffle()
做mock,但str_shuffle()
是個function,並不隸屬任何class,因此不知道該如何mock。
TDD
我們採用TDD方式開發,因此先寫Password產生器的測試程式。
PasswordGeneratorServiceTest.php1 1GitHub Commit : 新增PasswordGeneratorServiceTest.php1
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
36use App\Servives\PasswordGeneratorService;
class PasswordGeneratorServiceTest extends TestCase
{
/** @var PasswordGeneratorService */
protected $target;
protected function setUp()
{
parent::setUp();
$this->target = App::make(PasswordGeneratorService::class);
}
protected function tearDown()
{
parent::tearDown();
$this->target = null;
}
/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$origin = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
}
第5行1
2
3
4
5
6
7
8/** @var PasswordGeneratorService */
protected $target;
protected function setUp()
{
parent::setUp();
$this->target = App::make(PasswordGeneratorService::class);
}
每次跑測試時,都會執行setUp()
,因此適合在setUp()
將待測物件準備好。
實務上建議使用App::make()
物件,而不要使用new
,因為若待測物件包含costructor依賴注入時,使用new
必須一一準備constructor參數物件,非常麻煩,若使用App::make()
,Laravel將會自動幫我們將constructor參數的物件注入,非常方便。
$target
的PHPDoc非常重要,因為目前PHP 7對field仍然沒有支援type hint,必須自行加上PHPDoc,PhpStorm才能得知field的型別。
14行1
2
3
4
5protected function tearDown()
{
parent::tearDown();
$this->target = null;
}
每次跑完測試時,都會執行tearDown()
,因此適合在tearDown
將待測物件清為null
。
23行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$origin = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
實際的測試案例,為了更人性化描述測試案例的意義,可以直接使用中文為function名稱。
測試案例的命名,以白話描述測試案例為原則,不要以測試 + 函式名稱命名,如testGenerate()
,這樣無法明確顯示該測試案例。
因為使用中文function名稱,所以要加在PHPDoc加上@test
,PHPUnit才會認為這是測試程式。
依照3A原則執行單元測試 :
arrange : 準備測試資料,與測試期望值
$expected
。act : 實際執行待測物件的method,並獲得測試實際值
$actual
。assert : 實際比較測試期望值與測試實際值是否相等。
執行測試,獲得第一個紅燈,因為我們只寫了測試程式,還沒寫真正的程式。
PasswordGeneratorService.php2 2GitHub Commit : 新增PasswordGeneratorService.php
1 | namespace App\Servives; |
第1個參數傳入欲shuffle的字串,第2個參數傳入密碼長度。
使用了str_shuffle()
重整字串。
使用了substr()
擷取字串長度。
測試
得到紅燈,因為期望值與實際值不同。
再次測試,還是得到紅燈,因為期望值與實際值不同。
注意兩次測試的結果,實際值皆不相同,因為str_shuffle()
每次的結果都不同。
封裝PHP原生函式
對於str_shffle()
這種每次執行結果都不同的函式,我們無法進行測試,因此需要對str_shffle()
進行mock。
但str_shuffle()
為function,無法進行mock,因此我們必須將str_shuffle()
封裝在獨立的class內。
PasswordGeneratorHelper.php3 3GitHub Commit : 新增PasswordGeneratorHelper.php,對str_shuffle()封裝
1 | namespace App\Servives; |
取一個完全同名的str_shuffle()
,因為是完全封裝,所以只需呼叫PHP原生的str_shuffle()
即可。
注入Helper
將PasswordGeneratorHelper
注入進PasswordGeneratorService
,改使用封裝過的str_shuffle()
。
PasswordGeneratorService.php4 4GitHub Commit : 改使用PasswordGeneratorHelper的str_shuffle()
1 | namespace App\Servives; |
第5行1
2
3
4
5
6
7
8
9
10
11/** @var PasswordGeneratorHelper */
private $passwordGeneratorHelper;
/**
* PasswordGeneratorService constructor.
* @param PasswordGeneratorHelper $passwordGeneratorHelper
*/
public function __construct(PasswordGeneratorHelper $passwordGeneratorHelper)
{
$this->passwordGeneratorHelper = $passwordGeneratorHelper;
}
將PasswordGeneratorHelper
注入。
$passwordGeneratorHelper
的PHPDoc非常重要,因為目前PHP 7對field仍然沒有支援type hint,必須自行加上PHPDoc,PhpStorm才能得知field型別。
17行1
2
3
4
5
6
7
8
9
10
11
12/**
* 產生密碼
*
* @param string $origin
* @param int $length
* @return string
*/
public function generate(string $origin, int $length) : string
{
$target = $this->passwordGeneratorHelper->str_shuffle($origin);
return substr($target, 0, $length);
}
改使用PasswordGeneratorHelper
的str_shuffle()
。
Mockery
將str_shuffle()
包在class內之後,我們就能使用mockery來mock了。
TestCase.php5 5GitHub Commit : 修改TestCase.php,建立initMock()
1 | use Mockery\MockInterface; |
26行1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 初始化Mock物件
*
* @param string $className
* @return MockInterface
*/
public function initMock(string $className) : MockInterface
{
$mock = Mockery::mock($className);
App::instance($className, $mock);
return $mock;
}
因為實務上initMock()
在很多測試案例都會用到,所以將其pull member up到TestCase
。
使用Mockery::mock()
建立新的mock物件,並傳回給測試程式的field。
使用App::instance()
將原來的物件以mock物件取代。實務上使用App::instance()
的機會並不多,大概就只有在mock會用到,一般常使用的是App::bind()
、App::make()
與App::call()
。
PasswordGeneratorServiceTest.php1
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
50use App\Servives\PasswordGeneratorHelper;
use App\Servives\PasswordGeneratorService;
use Mockery\Mock;
class PasswordGeneratorServiceTest extends TestCase
{
/** @var PasswordGeneratorService */
protected $target;
/** @var Mock */
protected $mock;
protected function setUp()
{
parent::setUp();
$this->mock = $this->initMock(PasswordGeneratorHelper::class);
$this->target = App::make(PasswordGeneratorService::class);
}
protected function tearDown()
{
parent::tearDown();
$this->target = null;
}
/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$this->mock->shouldReceive('str_shuffle')
->once()
->withAnyArgs()
->andReturnUsing(function (string $origin) : string {
return $origin;
});
$origin = 'abc123XYZ';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
}
10行1
2
3
4
5
6
7
8
9/** @var Mock */
protected $mock;
protected function setUp()
{
parent::setUp();
$this->mock = $this->initMock(PasswordGeneratorHelper::class);
$this->target = App::make(PasswordGeneratorService::class);
}
新增$mock
field,存放initMock()
回傳的mock物件。
$mock
的PHPDoc非常重要,因為目前PHP 7對field仍然沒有支援type hint,必須自行加上PHPDoc,PhpStorm才能得知field的型別。
因為要將PasswordGeneratorHelper
加以mock,因此將PasswordGeneratorHelper::class
傳入initMock()
。
27行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$this->mock->shouldReceive('str_shuffle')
->once()
->withAnyArgs()
->andReturnUsing(function (string $origin) : string {
return $origin;
});
$origin = 'abc123XYZ';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
使用shouldReceive()
來mockstr_shuffle()
。
這裡要特別強調的是andReturnUsing()
,一般我們會使用andReturn()
,直接傳入欲回傳的值,不過像str_shuffle()
這類函式,我們不會直接mock其回傳值,只希望其回傳值可預測就好,andReturnUsing()
允許我們傳進一個closure取代原本的str_shuffle()
。
其實傳進去的closure也很單純,只是回傳原來的$origin
而已,目的只是讓str_shuffle()
具可預測性,讓我們可以寫測試。
str_shuffle()
是PHP的原生函式,正確性不用懷疑,因此不需要測試,我們只需確定str_shuffle()
是否在程式中有被呼叫過,因此我們特別在$mock
加上once()
,要求mockery特別檢查str_shuffle()
是否被執行過,若str_shuffle()
沒被執行,或執行超過一次,PHPUnit會亮紅燈。
實際執行測試,PHPUnit會抱怨所mock的closure與原本str_shuffle()
不相容而亮紅燈。
原因是因為PHP 7雖然支援return type,不過closure尚未支援return type寫法,因此PHPUnit認為所mock的closure與原本的str_shuffle()
型別並不相同。
PasswordGeneratorHelper.php6 6GitHub Commit : 更新PasswordGeneratorHelper.php,不使用return type1
2
3
4
5
6
7
8
9
10
11
12
13
14
15namespace App\Servives;
class PasswordGeneratorHelper
{
/**
* 將原生的str_shuffule()封裝
*
* @param string $origin
* @return string
*/
public function str_shuffle(string $origin)
{
return str_shuffle($origin);
}
}
將原本str_shuffle()
放棄使用return type。
PasswordGeneratorServiceTest.php7 7GitHub Commit : 更新PasswordGeneratorServiceTest.php,closure不使用return type1
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
49use App\Servives\PasswordGeneratorHelper;
use App\Servives\PasswordGeneratorService;
use Mockery\Mock;
class PasswordGeneratorServiceTest extends TestCase
{
/** @var PasswordGeneratorService */
protected $target;
/** @var Mock */
protected $mock;
protected function setUp()
{
parent::setUp();
$this->mock = $this->initMock(PasswordGeneratorHelper::class);
$this->target = App::make(PasswordGeneratorService::class);
}
protected function tearDown()
{
parent::tearDown();
$this->target = null;
}
/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$this->mock->shouldReceive('str_shuffle')
->once()
->withAnyArgs()
->andReturnUsing(function (string $origin) {
return $origin;
});
$origin = 'abc123XYZ';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
}
26行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/**
* @test
*/
public function 產生6位數密碼()
{
/** arrange */
$this->mock->shouldReceive('str_shuffle')
->once()
->withAnyArgs()
->andReturnUsing(function (string $origin) {
return $origin;
});
$origin = 'abc123XYZ';
$length = 6;
$expected = 'abc123';
/** act */
$actual = $this->target->generate($origin, $length);
/** assert */
$this->assertEquals($expected, $actual);
}
andReturnUsing()
傳入的closure,也放棄使用return type。
再跑一次測試,就會得到綠燈了。
Conclusion
- 對於PHP原生函式,若其回傳結果每次都不一樣,或不想測試,可以將其包在
Helper
內,再利用依賴注入的方式取代PHP原生函式,最後再靠mockery去mock該Helper
的method。 - 可直接在測試程式中使用中文替測試案例的method命名,可讀性更高。
- Mockery的
andReturnUsing()
,允許我們直接傳入clousre去mock一個method。 - PHP 7的closure尚未支援return type,因此若要使用closure去mock method,必須放棄使用return type。
Sample Code
完整的範例可以在我的GitHub上找到。