🦍

LaravelのFacadeはなぜ簡単にモックできるのか?

2023/08/01に公開

対象読者

  • サービスコンテナ,サービスプロバイダ,ファサードの使い方をざっくり理解している
  • DI(依存性の注入)について理解している
  • phpunitを利用したことがある
  • モックの使い所は理解しているが、肝心のLaravelでの実装方法が分からない

目次

  1. モックのパターン一覧
  2. なぜFacadeは簡単にモックできるのか

モックのパターン一覧

Facadeがなぜ簡単にモックできるのかを理解するために、まずはモックのパターン一覧を紹介します。

  1. モッククラスを作成して、モック対象のクラスと差し替える
  2. Mockeryを用いて、モックインスタンスを作成して、モック対象のクラスと差し替える
  3. Laravelのmockメソッドを使う(②の短縮記法)
  4. FacadeのshoudReceiveメソッドを使う

1.モッククラスを作成して、モック対象へ差し替える

①Targetクラスに対応するMockクラスを作成する
②bind()を用いて、TargetクラスをMockクラスへ差し替える

class TargetClass
{
    public function handle(int $number): int
    {
        $stripeResponse = Stripe::pay($number);

        return $stripeResponse->id;
    }
} 
class MockClass
{
    public function handle(int $number): int
    {
        return 1234;
    }
}
$this->bind(TargetClass::class, MockClass::class);

2.Mockeryを用いたモック

任意のクラスをモックするためにモック専用のクラスを毎回作成するのは面倒くさいです。そんな時に、即席でモックを作成できるのがMockeryです。

①モックインスタンスを作成
②モックインスタンスに対してメソッドを定義
③サービスコンテナを用いて、Targetクラスが呼ばれたらモックインスタンスを返すように定義

$instance=\Mockery::mock(Target::class);  //①
$instance->shouldReceive("handle")->andReturn(123);  //②
$this->instance(TargetClass::class,$instance);  //③

Targetクラスを モックへ差し替える場合にbind()もしくはinstance()を呼び出しています。
この2つのメソッドの違いは、Targetクラスを任意のクラスへ差し替える場合はbind()、任意のインスタンスへ差し替えたい場合はinstance()でサービスコンテナに差し替える対応を指定しています。

3.Laravelのmockメソッドを使う(②の短縮記法)

Laravelでは、Mockeryを用いて作成したインスタンスを任意のクラスに結びつけるための短縮記法が提供されています。私はこの記法が楽なので気に入ってます。

$this->mock(Target::class,function (MockInterface $mock)
{
    $mock->shouldReceive("handle")->andReturn(123);
});

4.Facadeのモック

Facadeに関しては、より短い記法でモック可能です。
例として、Cache Facadeのgetメソッドを用います。

Cache::shouldReceive('get')->andReturn(9);

なぜFacadeは簡単にモックできるのか

ここからは、Facadeの内部コードを簡単に解説し、Facade::shouldReceive()が呼ばれた際の挙動を理解できるようになることを目指します。

まず、Facadeのテストのしやすさを理解するために、静的メソッドが呼ばれたときの処理の流れを追ってみましょう。Facadeの静的メソッドが呼ばれると、実際にはインスタンスが生成され、動的メソッドが呼ばれています。

例えば、Cacheクラスのファサードに対してgetメソッドを呼び出す場合、以下のようになります。

Cache::get(321);  

しかし、Cacheクラスにgetメソッドは定義されていません。(同様に継承元のFacadeクラスにも未定義)

namespace Illuminate\Support\Facades;

class Cache extends Facade
{
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'cache';   //注目
    }
}

Facadeには__callStaticメソッドが定義されているので、未定義の静的メソッドである::get()が呼び出された際には継承元の__callStaticメソッドが呼び出されます。そして、このメソッド内のgetFacadeRoot()に注目します。

    public static function __callStatic($method, $args)
    {
        $instance = static::getFacadeRoot();   

        if (! $instance) {
            throw new RuntimeException('A facade root has not been set.');
        }

        return $instance->$method(...$args);
    }

getFacadeRoot()はstatic::resolveFacadeInstance("cache")を呼び出しています。

    public static function getFacadeRoot()
    {
        return static::resolveFacadeInstance(static::getFacadeAccessor());
    }

static::resolveFacadeInstance("cache")では以下の処理が行われています。
Facadeの静的メンバであるresolvedInstance["cache"]が存在すればそれを返すが①[1]、存在しない場合はapp["cache"]を静的メンバのresolvedInstance["cache"]に代入してからapp["cache"]を返します②。

    protected static function resolveFacadeInstance($name)
    {
        if (is_object($name)) {
            return $name;
        }

        if (isset(static::$resolvedInstance[$name])) {
            return static::$resolvedInstance[$name];   //①
        }

        if (static::$app) {
            return static::$resolvedInstance[$name] = static::$app[$name]; //②
        }
    }

Lravelは初期起動時にサービスプロバイダをロードしており、そのロード対象であるCacheServiceProviderにおいて、cacheというキーワードに対して、CacheManagerクラスが指定されています。
なので、先程のapp["cache"]ではCacheManagerクラスが呼び出されており、CacheManagerクラスのgetメソッドが最終的に呼び出されています。

class CacheServiceProvider extends ServiceProvider implements DeferrableProvider
{
   public function register()
    {
        $this->app->singleton('cache', function ($app) {
            return new CacheManager($app);   
        });
   }

FacadeのshouldReceiveメソッドの解説

Facadeの静的メソッドが呼ばれた際の処理を説明し終えたので、本題に入ります。
こちらがFacadeのshouldReceiveメソッドのコードです。

    public static function shouldReceive()
    {
        $name = static::getFacadeAccessor(); 

        $mock = static::isMock() //①
                    ? static::$resolvedInstance[$name] //②
                    : static::createFreshMockInstance(); //③

        return $mock->shouldReceive(...func_get_args()); //④
    }

先程、Facadeは適切なクラスのインスタンスを作成するために、まずはFacadeの静的メンバを参照し、次点でサービスコンテナを参照すると述べました。

①Facadeの静的メンバにモックインスタンスがセットされているか確認している。
②セット済みなら、そのモックインスタンスを返す。
③セットされていないならMockeryでモックインスタンスを作成して、サービスコンテナに設定する。そして、Facadeの静的メンバにもモックインスタンスをセットする。
④作成または取得したモックインスタンスに対して、shouldReceiveメソッドを呼び出して任意のメソッドを設定する。

Cacheファサードを例に考えると、③では以下のような処理をしています。

$instance=Mockery::mock("cache");
$app->instance("cache",$instace); 
Facade::$resolvedInstance["cache"]=$instance; 

よって、FacadeのshouldReceiveメソッドは内部でMockeryを用いてインスタンスを作成し、サービスコンテナへの登録もしているので、2章で紹介した短縮記法をより短縮した記法とも捉えることが出来るとも思っています。余談ですが、これを踏まえるとCacheファサードに対するモックは以下のようにも書くことも出来ます。

①FacadeのshouldReceiveを用いた書き方

Cache::shouldReceive(“get”)->andReturn(1234);

②Mockeryを用いたモック

$instance = Mockery::mock(CacheManager::class);
$instance->shouldAllowMockingProtectedMethods();
$instance->shouldReceive('get')->andReturn(1234);
$this->app->instance('cache', $instance);

最後に

今回はFacadeがなぜ簡単にモックできるのかを調べてみました!
Laravel×PHPUnitには、様々なモック方法があるので混乱する人も多いと思います。
DIコンテナの概念を理解した上で、紹介したモック方法を使っておけば基本的なテストケースには対応できると思います。
Twitterもしているので、フォローお願いいたします!


脚注
  1. FacadeクラスのgetFacadeRoot静的メソッドにはシングルトンパターンが用いられている。 ↩︎

マナリンク Tech Blog

Discussion