透過Mockery與依賴注入對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.php

tests/Services/PasswordGeneratorServiceTest.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
use 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
5
protected 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原則執行單元測試 :

  1. arrange : 準備測試資料,與測試期望值$expected

  2. act : 實際執行待測物件的method,並獲得測試實際值$actual

  3. assert : 實際比較測試期望值測試實際值是否相等。

執行測試,獲得第一個紅燈,因為我們只寫了測試程式,還沒寫真正的程式。

PasswordGeneratorService.php2 2GitHub Commit : 新增PasswordGeneratorService.php

app/Services/PasswordGeneratorService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace App\Servives;

class PasswordGeneratorService
{

/**
* 產生密碼
*
* @param string $origin
* @param int $length
* @return string
*/

public function generate(string $origin, int $length) : string
{

$target = str_shuffle($origin);
return substr($target, 0, $length);
}
}

第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()封裝

app/Services/PasswordGeneratorHelper.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace App\Servives;

class PasswordGeneratorHelper
{

/**
* 將原生的str_shuffule()封裝
*
* @param string $origin
* @return string
*/

public function str_shuffle(string $origin) : string
{

return str_shuffle($origin);
}
}

取一個完全同名的str_shuffle(),因為是完全封裝,所以只需呼叫PHP原生的str_shuffle()即可。

注入Helper


PasswordGeneratorHelper注入進PasswordGeneratorService,改使用封裝過的str_shuffle()

PasswordGeneratorService.php4 4GitHub Commit : 改使用PasswordGeneratorHelper的str_shuffle()

app/Services/PasswordGeneratorService.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
namespace App\Servives;

class PasswordGeneratorService
{

/** @var PasswordGeneratorHelper */
private $passwordGeneratorHelper;

/**
* PasswordGeneratorService constructor.
* @param PasswordGeneratorHelper $passwordGeneratorHelper
*/

public function __construct(PasswordGeneratorHelper $passwordGeneratorHelper)
{

$this->passwordGeneratorHelper = $passwordGeneratorHelper;
}

/**
* 產生密碼
*
* @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);
}
}

第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);
}

改使用PasswordGeneratorHelperstr_shuffle()

Mockery


str_shuffle()包在class內之後,我們就能使用mockery來mock了。

TestCase.php5 5GitHub Commit : 修改TestCase.php,建立initMock()

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
38
39
use Mockery\MockInterface;

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 $className
* @return MockInterface
*/

public function initMock(string $className) : MockInterface
{

$mock = Mockery::mock($className);
App::instance($className, $mock);

return $mock;
}
}

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.php

tests/Services/PasswordGeneratorServiceTest.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
use 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()了嗎?

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 type

tests/Services/PasswordGeneratorHelper.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace 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 type

tests/Services/PasswordGeneratorServiceTest.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
use 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上找到。