php でクラスが呼び出す関数をスタブにしたいとき
コードテストを書いていて、クラスが呼び出すクラスのメソッドをダミー化してたいとき、スタブを使う。
スタブを作るには、クラスが呼び出すクラスのメソッドを書き換える必要がある。phpでは動的なクラス定義変更ができない。
結論 require_once を弄くればいい。
require_once
されているファイルを動的に差し替えればテストが可能。
当たり前なのだけど、気づかないときは気づかない。ちょっと盲点かもしれない。
mockery でやる
また、その実現のために、Mockery が使える。
<?php
$mock = Mockery::mock('alias:'.MyChecker::class);
$mock->shouldReceive('is_src_exists')->andReturn(faklse);
たとえば、次のようなクラスがあって。クラスが例外を吐くことをテストしたい。とする
MyChecker::is_src_exists
というメソッドを定義して。
<?php
class MyChecker {
public static function is_src_exists( $path ) {
return realpath($path) && is_readable($path);
}
}
MyFileClass
クラスの例外送出テストしたいとする。
<?php
class MyFileClass {
public static function move($src,$dst){
if (false == MyChecker::is_src_exists($src) ){
throw new RuntimeException("src not found");
}
rename($src,$dst);
}
}
テスト対象はMyFileClass
である。MyChecker::is_src_exists
を呼び出すMyFileClass
がテスト対象である。
上記のクラスをテストして、例外が起きることをテストしたい
<?php
class MyFileClassTest extends TestCase{
public function test_file_move_function_raise_exception(){
$this->expectException(RuntimeException::class);
MyFileClass::move('/no_exists','/tmp');
}
}
php では、動的に関数を上書きできない。runkit
を使えばできなくもないが、環境整備も面倒だ。
動的に上書きできないし、フラグや引数やENVをソースコードに書くのも避けたい。
環境変数の切り分けコードをテストのために追記すると、コード全体の見通しが著しく悪化する。
だったら、かわりに、require_once をうまく使えばいい。
<?php
require_once 'mock_is_src_exists.php';
class MyFileClassTest extends TestCase{
mock_is_src_exists.php
<?php
class MyChecker {
public static function is_src_exists( $path ) {
return false;
}
}
テストしたいモックオブジェクトを作るのではなく、モックオブジェクトの定義を本来の名前でファイルに書き出して、それをrequire
してしまえば、解決するのである。
require_onceを使った解決
// mock としてのロードをできるファイルを作る
function create_mock() {
$temp = tempnam();
file_put_contents($temp, <<<'EOS'
<?php
class MyChecker{
publick static function is_src_exists($path){
return false;
}
}
EOS);
require_once $temp;
register_shutdown_function(fn()=>@unlink($temp);
}
create_mock();
ただ、どうしても煩雑になる。
どうやら、これを快適にやってくれるのが、Mockey 関連であるようで、 phpunit が使っているものらしい。
*1
Mockery を使ったショートカット
require をもっと便利に動的に作れたら便利だろう。それがMockeyらしい。
冒頭で出てきた、mockey のサンプルコードがそれに当たる。
<?php
$mock = Mockery::mock('alias:'.MyChecker::class);
$mock->shouldReceive('is_src_exists')->andReturn(faklse);
Mockery で alias:MyClass
や overload:MyClass
などしたらrequire を変えていい感じにスタブができる。
ただし、php はrequireされたクラスをアンロードできないので。別の箇所でロード済みだと名前が被ってエラーになる。
Could not load mock MyChecker::class, class already exists
そこで、phpunit をprocessIsolation で動かして、テストごとにファイルロードをリセットしてから実行するようにする。
アノテーションを使ってphpunit に指示を出す。
@runTestsInSeparateProcesses
と@preserveGlobalState disabled
を使えばいい。
<?php
use PHPUnit\Framework\TestCase
class MyFileClassTest extends TestCase {
@runTestsInSeparateProcesses
@preserveGlobalState
public function test_file_move_function_raise_exception() {
$this->expectException(RuntimeException::class);
$mock = Mockery::mock('alias:'.MyChecker::class);
$mock->shouldReceive('is_src_exists')->andReturn(faklse);
MyFileClass::move('/no_exists', '/tmp');
}
}
これで無事にモック(スタブ)を使って、クラスが使ってる関数やクラスのメソッドを上書きして、テストコードを実行することができるわけだ。
phpでクラスが呼び出しているクラスの中にある関数やメソッドにダミー値を返却(スタブ)して、作ったクラスの条件判定をすべて網羅してテストしたい。
ネットワークや外部依存が増えた
現代では、プログラミングがオフラインの自己環境で解決しない時代。ネットワークや外部リソースにほぼ必ず依存する。
例えば、DNSやHTTPSなど外部のコンテンツを取得する関数に固定値を返却させてテストコードが現実世界に依存しないように記述したい。たとえばDNSのAレコードを変更するツールを作っているとして、コードのテストのために、実際のDNSレコードを書き換えに行くなどは、非現実的である。別の手段として、テスト用にDNSコンテンツサーバーとDNSリゾルバを組み込んだDocker環境を作り管理するとか変態的な手間がかかる。どれも正気なテスト実現方法とは言えない。考えるだけでおぞましい。だからモック(スタブ)が必要である。
動的変更は、型付や高速化と相反する
そこで、ある程度メソッドが「固定値」を返すようにスタブを作れば、テストコードのプログラミングが圧倒的に楽になるわけです。
Pythonやrubyなど「ゆるい」言語は何でも書き換えられるので、メソッドを上書きしてしまえばいいわけですが。ある程度の「型付」言語はそれを許してくれないし、厳密なコードから離れてしまう。PHPでやろうとすると、クラスを動的に書き換える必要がある。しかし、PHPはそれを許してくれない。高速化の足かせになるだろうし型チェックがききづらくなる。runkitという手もあるがそれもちょっとと思える。緩さと厳密さの板挟みの痛し痒しである。
laravel の場合
laravel の場合、サービスプロバイダでインスタンスを作るという役割を上手に活用することで、モックを作ることができるのだが。
<?php
namespace Tests\Feature\Rules;
use Mockery;
use Tests\TestCase;
use Mockery\MockInterface;
class CheckSrcRuleSeperateTest extends TestCase {
public function test_check_src_rule_has_enough_space(){
$this->instance(
MyChecker::class,
Mockery::mock(MyChecker::class, function (MockInterface $mock) {
$mock->shouldReceive('is_src_exists')->andReturn(true);
$mock->shouldReceive('storage_has_enough_space')->andReturn(false);
})
);
$rule = ['path' => new CheckSrcFile()];
$v = $this->app['validator']->make(['path' =>test_data('60min.mp4') ], $rule);
}
}
ただし、「サービスプロバイダ」でインスタンス
なので、static
なものをスタブすることはできない。
また、use Tests\TestCase;
でテストで使ってるIlluminate\Foundation\Testing\TestCase
やuse CreatesApplication
している箇所があり、app()
でたくさん登録している。
そういう経緯からかphpunit の processIsolation が動かない。
つまり、laravel でh static
なものは、スタブ作成が不能。
laravel では、static メソッドをスタブにしたテスト不可能
まとめるとこういうことになる。
スタブ作成にはMockery をつかう
-> Mockery は require を使う
-> Mockery は require のために、processIsolation が必要
-> laravel では artisan test から app()を使ってる
-> laravel では app()を使うので、processIsolation はサポート不可能
-> laravel では mock はオブジェクトに限ってサポートされている
-> mockオブジェクトは、サービスプロバイダを経由する必要がある。
-> static::method() はスタブできない。
なので、static::method()をスタブ化したテストはlaravelではかけない。
とくに、laravel 公式で「processIsolationは動かない」と書いてあって絶望するなど。
まぁめんどくさいですよね。phpの限界だろうね。