🤝

Laravel Mockery - Route Model Binding をモック

2022/03/22に公開

探すのが大変だったので、ログがてら。

ルートモデルバインディング

Laravelの便利なやつやん、です。
これ使っていろんなシステムを組んでいるんですが、これをモックするのがどうやったら良いのか、というのが事の発端。ちなみに、ルートモデルバインディングというのは以下のような感じの構文です。

# route/web.php のルーティング
Route::post(
    '/horse/{horse}/edit',
    'HorseController@edit'
)->name('horse.edit');

# HorseController@edit のメソッド
# new でモデルクラスを変数に宣言して、変数でチェーンして find メソッドとかを
# 呼び出して、URLのID値を使って検索してモデルインスタンスを用意して ... 
# みたいなことを、メソッドインジェクションの一つで全てまかなわれてしまいます。シンプルッッッ!!
public function edit(Horse $horse)
{
    return view('horse.edit', compact('horse'));
}

どこモックすんの..?

ただこれをFearuteテストで検証しようとすると、Modelインスタンスとの依存性をLaravelのコアな部分で解決しちゃうので、Mokceryの差し込む隙がなく、強制的にデータベースを経由されてしまう、みたいな感じになります。
僕自身、これではMockeryを導入しにくい(データベースを経由させないテストの実現がしにくい)なと思ってたのですが、公式にヒントが書いてありました。

https://readouble.com/laravel/6.x/ja/routing.html

別の方法として、EloquentモデルのresolveRouteBindingメソッドをオーバーライドすることもできます。このメソッドはURIセグメントの値を受け取り、ルートへ注入すべきクラスのインスタンスを返す必要があります。

ルーティングを指定するファイルがたとえば routes/web.php ですが、ここでコールされている getpost のstaticメソッドでは、URLに含まれる { *** } という形式の文字列をモデルとバインドするパラメータとして取り出して、Routeクラスのクラス内メソッドから、最終的にEloquent/Modelクラスの resolveRouteBinding を呼び出して、取り出したいレコードを取り出してコレクション化しているみたいです。(長い)

最終的に、URLのパラメータから特定の情報をモデル経由で取り出す、ということを resolveRouteBinding メソッドでやっていることがわかりました。

以下、僕がわかったふうになるまで辿った形跡です。

# vendor/laravel/framework/src/Illuminate/Routing/Router.php

....

    /**
     * Register a model binder for a wildcard.
     *
     * @param  string  $key
     * @param  string  $class
     * @param  \Closure|null  $callback
     * @return void
     */
    public function model($key, $class, Closure $callback = null)
    {
        $this->bind($key, RouteBinding::forModel($this->container, $class, $callback));
    }

....
# vendor/laravel/framework/src/Illuminate/Routing/RouteBinding.php

....

    public static function forModel($container, $class, $callback = null)
    {
        return function ($value) use ($container, $class, $callback) {
            if (is_null($value)) {
                return;
            }

            // For model binders, we will attempt to retrieve the models using the first
            // method on the model instance. If we cannot retrieve the models we'll
            // throw a not found exception otherwise we will return the instance.
            $instance = $container->make($class);

            if ($model = $instance->resolveRouteBinding($value)) {
                return $model;
            }

....
# vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php

....

    /**
     * Retrieve the model for a bound value.
     *
     * @param  mixed  $value
     * @return \Illuminate\Database\Eloquent\Model|null
     */
    public function resolveRouteBinding($value)
    {
        return $this->where($this->getRouteKeyName(), $value)->first();
    }

....

結合しているメソッドをモックして、テスト側で代わりに結合してあげる

ここまでくると、あとはデータベースを参照する部分をモックしてあげれば、テストで不必要に経由はしませんし、Mockeryではあたかも結合したかのような状態を作ることができます。便利なやつを"あざけ"させた結果がこんな感じです。

ちなみに、モックインスタンスのところで makePartial を使っているんですが、これは部分的にモックをしたかったし、おおよその部分は元々のモデルを模倣してほしかったため利用しています。

https://readouble.com/mockery/1.0/ja/partial_mocks.html

# モックインスタンスを作ります
$m = \Mockery::mock(\App\Horse::class)->makePartial();

# モデルの継承元のメソッドをモックします。
# コールしても自身のインスタンスを返すようにして、そのあとの処理が続くようにしてあげます。(結局、何もおこらなかった、というような状態にします)
$m->shouldReceive('resolveRouteBinding')->andReturnSelf();

# バインド済みが前提のロジックが想定されるので、
# Controller や Usecase 層では、単一データのモデルインスタンスとして $horse->name というように呼び出されることに備え、
# モックインスタンスに、必要なプロパティを追加してあげています。
$params = factory(\App\Horse::class)->make();
foreach ($params->toArray() as $key => $val) {
    $m->{$key} = $val;
}

# モックしたクラスに、実際のクラスを差し替えます
$this->app->instance(\App\Horse::class, $m);

# あとはテストする
$response = $this->get(
    route('api.horse.show')
);
$response->assertOk();

Mockeryを導入し始めると「ここまではモックしたけど、ここからはモックされてない生身」というスタックの仕方が多くなる気がします。僕の経験っていう話ですけど多くの場合は、こうした根幹に関わる仕組みで、ユーザーが組み立ていない(いつの間にか組み立られている)部分が影響していることがほとんどです。

根幹をイジるの..?ってなるんですけど、Laravelってオーバーライドが簡易的にできるって仕組みなので、今回のような発想も馴染んでくるのが、Mockeryとの相性もいいんだなと勝手に思っていたりします。

Discussion