それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

php でクラスが呼び出す関数をスタブにしたいとき require を工夫すればいいが、laravelでは面倒が起きる

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:MyClassoverload: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 disabled
  */
  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でクラスが呼び出しているクラスの中にある関数やメソッドにダミー値を返却(スタブ)して、作ったクラスの条件判定をすべて網羅してテストしたい。

ネットワークや外部依存が増えた

現代では、プログラミングがオフラインの自己環境で解決しない時代。ネットワークや外部リソースにほぼ必ず依存する。

例えば、DNSHTTPSなど外部のコンテンツを取得する関数に固定値を返却させてテストコードが現実世界に依存しないように記述したい。たとえばDNSのAレコードを変更するツールを作っているとして、コードのテストのために、実際のDNSレコードを書き換えに行くなどは、非現実的である。別の手段として、テスト用にDNSコンテンツサーバーとDNSゾルバを組み込んだDocker環境を作り管理するとか変態的な手間がかかる。どれも正気なテスト実現方法とは言えない。考えるだけでおぞましい。だからモック(スタブ)が必要である。

動的変更は、型付や高速化と相反する

そこで、ある程度メソッドが「固定値」を返すようにスタブを作れば、テストコードのプログラミングが圧倒的に楽になるわけです。

Pythonrubyなど「ゆるい」言語は何でも書き換えられるので、メソッドを上書きしてしまえばいいわけですが。ある程度の「型付」言語はそれを許してくれないし、厳密なコードから離れてしまう。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\TestCaseuse 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の限界だろうね。

*1:mockey を使っているとrequire関連のエラーが出まくるので、requireを動的にやっていると想像した。コードは見ていません。