🚑️

どうしても Eloquent Model をモックしないといけないあなたへ

2022/07/26に公開2

はじめに

Laravel を使っていると避けては通れない ORM の Eloquent.
データベースをオブジェクト指向っぽく扱えるため非常に便利で,複雑なリレーションも簡単に扱えちゃう魔法の道具です.

https://readouble.com/laravel/9.x/ja/eloquent.html

例えば,主キーでモデルを取得するには以下のように書けば簡単に DB からデータが取得できます.

// id = 1 のユーザー情報を取得
$user = User::find(1);

また,レコード中の各カラム(フィールド)の値は,PHP におけるオブジェクトのプロパティの値として取得できます.

// id = 1 のユーザー情報を取得
$user = User::find(1);

// id = 1 のユーザー名を取得
$userName = $user->name;

簡単ですね😁

さて,Laravel を使ったプロジェクトに限らず,プロダクトを開発する中で「テストコード」を書くことがありますが,テストを書く際にしばしば モック を使うことがあります.

モック(オブジェクト)は,テスト対象となるクラスが依存する外部クラスの振る舞いを定義するもので,本来使われるはずのオブジェクトの代わりに差し込むことで,テスト結果が外部のロジックによって左右されるのを防ぐことができます.

Laravel 使って開発をしているなら, Mockery というモックライブラリを使ってテストコードを書くのが一般的だと思います.

https://github.com/mockery/mockery

今回は,この Mockery を使って,Laravel の Eloquennt\Model (以下 Model)クラスをモックしてテストを書く方法を解説します.

今回 Model のモックにフォーカスする理由は以下の通りです,

  • Model もモックは一般的なクラスのモックよりも(特に初心者にとって)難易度が高いと感じる
  • Model のモックに関する記事があまり豊富ではない

しかしながら,筆者は Model のモックはおすすめしません

https://zenn.dev/fuwasegu/articles/ae349b2dcbddad

上記の記事やスライドでも触れていますが,Model のモックはやりずらく,できれば避けて通りたい茨の道です.
具体的な理由としては,

  • find()query() など,頻出するメソッドが静的呼び出しで使うことが多いため,そもそもモックできないパターンが多い(なぜ静的メソッドがモックできないかは後ほど触れることにします)
  • フィールドと対応するプロパティが 動的プロパティ なためモックの仕方が直感的ではない

などが挙げられます.
できれば,getter を持った Entity に詰め替えたり,DB アクセスは Repository 層でラップしてドメインロジックから切り離したりすることをオススメします.

とは言え,「どうしても Model をモックしなきゃならないんだ!」という方も少なくないと思うので,できる限り,Model のモックで躓かないよう知見を共有します.

前提

Model のモックに限らず,テスト時にモックを利用するには条件があります.それは,適切に **DI(Dependency Injection: 依存性の注入)**がされていることです.

DI については以下の記事が詳しく書かれています.
http://blog.a-way-out.net/blog/2015/08/31/your-dependency-injection-is-wrong-as-I-expected/

簡単に言えば,依存するクラスを中で生成するのではなく外部から渡してあげましょう ということです.
以下に,依存性が注入できていない例と,DI の手法の一つである メソッドインジェクション を使って適切に依存性を注入している例を示します.

依存性を注入できていない
public function sendMail(
    string $address,
    string $message
): void {
    // 依存するクラスのインスタンスを中で生成する
    $mailService = new MailSerivce();

    try {
        $mailService->send($address, $message);
    } catch (MailServerConnectionException $e) {
        // do something...
    }
}
依存性を注入できている
public function sendMail(
    string $address,
    string $message,
    MailService $mailService // 依存するクラスのインスタンスを外で作って渡してあげる
): void {
    try {
        $mailService->send($address, $message);
    } catch (MailServerConnectionException $e) {
        // do something...
    }
}

前項で述べたとおり,モックは本来渡されるインスタンスの代わりに差し込むことで,テストの対象クラスが依存しているクラスの挙動をテスト側でコントロールできるというものでした.

適切に DI をしている場合,依存先のクラスをモックに差し替えることが可能です.
上記の例を使い, sendMail()NotifyAction::class のメソッドであると想定すると,モックへの差し替えは次のように書けます.

public function test_sendMail_method(): void
{
    // MailService として振る舞うモックオブジェクトを作る
    $mailServiceMock = Mockery::mock(MailService::class);

    $action = new NotifyAction();
    $address = 'sample@example.com';
    $text = 'hello world!';

    // テスト対象の sendMail メソッドを実行する際,MailService をモックと差し替える
    $action->sendMail($address, $text, $mailServiceMock);
}

本来は MailService::class のインスタンスが渡され,sendMail() の中では MailService::class のメソッドが呼ばれるはずでしたが,今回これをモックに差し替えたため, sendMail() はモックインスタンスのメソッドを呼ぼうとするわけです.
したがって上記の例には書いていませんが,MailService::classsend() がどんな値を返すかは,テストメソッド側で決めることができるのです.

逆に,適切に DI できていないパターン,つまり,依存しているクラスのインスタンスを内部で生成している場合,モックに差し替えることはできませんので,依存先のロジックをこちらでコントロールすることはできません.

Model とモックの利用

さて,どんな場合にモックを利用したテストができて,どんな場合にそれが不可能なのか,前項である程度分かっていただけたのではないでしょうか.
繰り返しにはなりますが,モックを利用したテストを書く場合,テスト対象のクラスないしメソッドは,依存するクラスが外部から注入されている必要がありました.

ここで,一般的な Model の使用例をいくつか示します.

id を指定してデータを取得
$user = User::find(1);
条件を指定してデータを取得
$users = User::query()
    ->where('born_in', '長崎県')
    ->get();
データを新規登録
$user = new User();
$user->name = 'ふわせぐ';
$user->born_in = '長崎県';
$user->twitter = 'fuwasegu';
$user->save();
データを削除
$user = User::where('twitter', 'fuwasegu')
    ->delete();

2 例目くらいで気づきましたよね?
Laravel の Eloquent Model は普通に使うと明らかにモックの対象にしづらいんです!.
静的メソッドや,new Model() を使う前提で設計されているからですね.

じゃぁ Model を DI すればいいじゃないか!

ごもっともな意見です.最初に考えるのはこれですよね.
もちろんこれも技術的には可能です.でも筆者は推奨しません.

記事の冒頭で述べたように,Model はレコードをオブジェクトに変換したものです.Model インスタンスがデータベース上の1レコードに相当します.
つまり,Model は,(ほぼ空のレコードを表現している場合を除いて)プロパティに値が詰まっていて初めて意味を持つものです.
PHP のクラスである以上,「Model インスタンスを持ち回ってそこに実装されているメソッドを呼び出す」というのも不可能ではないですが,本来の Model の使用方法とはかけ離れてしまいます.

したがって,Model が出現するビジネスロジックをモックを使ってテストしたい場合,以下のようなことに気をつけて実装すると良いです.

  • Model::query() などの静的メソッドをロジック内で使わない
    • DB アクセスを伴う Model の取得は別クラスに切り出す
  • new Model() をロジック内で使わない
    • モデルの新規作成は別クラスに切り出す
  • Model を DI しない
    • ただし,値としてメソッドに引数に Model を渡すのは問題ありません.これは依存性の注入ではなく値渡しです

多くの場合,上記のように DB アクセスを外部クラスに切り出す場合,切り出し先のことをRepository 層と呼ぶことがあります.
Repository に処理を切り出した場合,そのテストは実際に DB と通信を行う想定でテストを書いてください.(Unit テストではなく Feature テスト)

Model をモックしてみよう!

ここからは,実際に Model のモックで躓くポイントを解説します.と言っても,Model もただの PHP のクラスに過ぎないので,基本的には一般的なモックの方法と何ら変わりません.
モックオブジェクトを作り,shouldReceive(), with(), andReturn() などを使ってメソッドの振る舞いを定義していきます.
Model のモックをする中で,一般的なクラスと少し違うのはプロパティアクセスのモックです.DB のフィールドと対応する Model のプロパティは全て動的プロパティだからですね.

本項では,基本的なメソッドのモックに加え動的プロパティからの値の取得や動的プロパティへの値の代入のモックなど少し高度なモックまで幅広く扱います.

一般的な public メソッドのモック

以下のようなメソッドのテストを考えます.

テスト対象
class Sample
{
    public function action(User $user): string
    {
        // テーブル名を取得する
        return $user->getTable();
    }
}

今回テストすべき点は次の通りです.

  • action() メソッド内で User::gatTable() が 1 回呼ばれ,文字列を返す
  • action() メソッドが User::gatTable() で得られた結果を返す

まずはテストの準備として,User::class のモックオブジェクトを作成します.

public function test_action(): void
{
    $userMock = Mockery::mock(User::class);
}

次に,getTable() の振る舞いを定義します.

public function test_action(): void
{
     $userMock = Mockery::mock(User::class);
+    $userMock
+        ->shouldReceive('getTable')
+        ->once()
+        ->andReturn('users');
}

最後に対象クラスを実行しアサートします.

public function test_action(): void
{
     $userMock = Mockery::mock(User::class);
     $userMock
         ->shouldReceive('getTable')
         ->once()
         ->andReturn('users');

+    $result = (new Sample())->action($userMock);
+    $this->assertSame('users', $result);
}

動的プロパティのモック【値の取得】

以下のようなメソッドのテストを考えます.

テスト対象
class Sample
{
    public function action(User $user): string
    {
        assert($user->exists); // バグを防ぐために簡易的にアサーションを書く

        $firstName = $user->first_name;
        $lastName = $user->last_name;

        return "フルネームは $lastName $firstName さんです.";
    }
}

今回テストすべき点は次の通りです.

  • $user->exists が 1 回呼ばれ,true を返す
  • $user->first_name が 1 回呼ばれ,文字列を返す
  • $user->last_name が 1 回呼ばれ,文字列を返す
  • 本名を示す文章が返ってくる

まずは $user->exists をモックします.
Mockery では,public プロパティのモックは期待する値をそのままプロパティの値としてセットすることで実現できます.

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

public function test_action(): void
{
    $userMock = Mockery::mock(User::class);
    $userMock->exists = true;
}

次に,$user->first_name$user->last_name をモックします.
ここで,PHP における動的プロパティについておさらいです.
PHP では,定義されていないプロパティからデータを取得しようとした場合,マジックメソッドの __get() がコールされます.
したがって,先程の $useer->exists のようにプロパティに値を代入するという方法ではモックできません.

https://www.php.net/manual/ja/language.oop5.overloading.php#object.get

これは,Illuminate\Database\Eloquent\Model.php__get() の実装です.
Model の __get() は,HasAttributes トレイトの getAttribute($key) をラップしているだけのシンプルなものになります.
https://github.com/illuminate/database/blob/9.x/Eloquent/Model.php#L2069-L2078

さて,では実際に動的プロパティから値を取得する部分をモックしてみましょう.
今までの流れからすれば,恐らく __get() をモックし,期待する戻り値にプロパティから読み出されるであろう値を設定すれば良さそうです.

public function test_action(): void
{
     $userMock = Mockery::mock(User::class);
     $userMock->exists = true;
+    $userMock
+        ->shouldReceive('__get')
+        ->once()
+        ->with('first_name')
+        ->andReturn('太郎');
+    $userMock
+        ->shouldReceive('__get')
+        ->once()
+        ->with('last_name')
+        ->andReturn('山田');
}

ちょっとここでテストを実行してみます.
すると,このような結果が出力されます.

Mockery\Exception\BadMethodCallException

Received Mockery_0_App_Models_User::getAttribute(), but no expectations were specified

at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:2064
  2060▕      * @return mixed
  2061▕      */
  2062▕     public function __get($key)
  2063▕     {
➜ 2064▕         return $this->getAttribute($key);
  2065▕     }
  2066▕
  2067▕     /**
  2068▕      * Dynamically set attributes on the model.

意訳すると,

User Model の getAttribute() が呼び出されたが,呼び出しが期待されていませんでした

のような感じのメッセージです.
実は,Mockery は __get() のようなマジックメソッドをモックすることができません.今回のように,未定義プロパティへのアクセスを実行した場合,モックしなかったときと同様に __get() が実行されるため,モックすべきは __get() 内で呼ばれている getAttibute() になります.

したがって,プロパティへのアクセス部分のモックを書き換えます

public function test_action(): void
{
     $userMock = Mockery::mock(User::class);
     $userMock->exists = true;
+    $userMock
-        ->shouldReceive('__get')
+        ->shouldReceive('getAttribute')
+        ->once()
+        ->with('first_name')
+        ->andReturn('太郎');
+    $userMock
-        ->shouldReceive('__get')
+        ->shouldReceive('getAttribute')
+        ->once()
+        ->with('last_name')
+        ->andReturn('山田');
}

あとは,テスト対象メソッドの実行と,実行結果のアサーションを書くだけです.

public function test_action(): void
{
    $userMock = Mockery::mock(User::class);
    $userMock->exists = true;
    $userMock
        ->shouldReceive('getAttribute')
        ->once()
        ->with('first_name')
        ->andReturn('太郎');
    $userMock
        ->shouldReceive('getAttribute')
        ->once()
        ->with('last_name')
        ->andReturn('山田');

    $result = (new Sample())->action($userMock);
    $this->assertSame("フルネームは 山田 太郎 さんです.", $result);
}

これで完璧ですね🎉

動的プロパティのモック【値の代入】

以下のようなメソッドのテストを考えます.

テスト対象
class Sample
{
    public function action(User $user, string $newAddress): string
    {
        assert($user->exists); // バグを防ぐために簡易的にアサーションを書く

        $user->mail = $newAddress;
        $user->save();

        return $user;
    }
}

今回テストすべき点は次の通りです.

  • $user->exists が 1 回呼ばれ,true を返す
  • $user->mail に文字列が 1 回代入される
  • User::save() が 1 回呼ばれる
  • 更新後の Model が返ってくる

$user->exists のモックは先程と同じで,値をセットしてあげるだけですね.
今回注目すべきは,$user->mail$newAddress が代入される部分です.
未定義プロパティの値を取得するときに __get() が呼ばれるのと同じように,未定義プロパティに値を代入するときは __set() が呼ばれます.

これは,Illuminate\Database\Eloquent\Model.php__set() の実装です.
Model の __set() は,HasAttributes トレイトの setAttribute($key, $value) をラップしているだけのシンプルなものになります.
https://github.com/illuminate/database/blob/9.x/Eloquent/Model.php#L2080-L2090

モックでやることは __get() のときと同じです.Mockery は __set() をモックできないので,setAttribute() をモックすることになります.
以上を踏まえると,テストは以下のように書けます.

public function test_action(): void
{
    $userMock = Mockery::mock(User::class);
    $userMock->exists = true;
    $userMock
        ->shouldReceive('setAttribute')
        ->once()
        ->with('email', 'sample@example.com');
    $userMock
        ->shouldReceive('save')
        ->once();

    $result = (new Sample())->action($userMock);

    // 値渡ししたものと同じインスタンスが返ってきているかどうかをチェックする
    $this->assertSame(spl_object_id($userMock), spl_object_id($result));
}

【番外編】 Null 合体演算子

PHP7 以降では,isset(expr1) ? expr1 : expr2 のシンタックスシュガーとして Null 合体演算子 ?? が使えます.
https://www.php.net/manual/ja/language.operators.comparison.php#language.operators.comparison.coalesce

例えば,Model から取得した値が null だったとき,デフォルト値を定めたい場合があります.

$nickname = $user->nikckname ?? '名無しさん';

このように書くことで,$user->nikckname に値があればその値を,なければ(null だったら,もしくはカラムさえ存在しなかったら)「名無しさん」という文字列が $nickname に代入されます.
一見やっていることは一般的なプロパティアクセスと対して変わりませんが,モックに関して言えば,先程紹介した 動的プロパティのモック【値の取得】 より少しばかり手間が増えます.

以下のようなメソッドのテストを考えます.

テスト対象
class Sample
{
    public function action(int $id): string
    {
        // UserRepository::find() の返り値は ?User であると想定
        $user = $this->userRepository
            ->retrieveById($id);

        $nickname = $user->nikckname ?? '名無しさん';

        return "ID $id のユーザーのあだ名は $nickname です";
    }
}

今回は Model を値渡しするパターンではなく,試しに UserRepository から取得するパターンのテストにしてみましょう.

今回テストすべき点は次の通りです.

  • UserRepository::retrieveById() が 1 回呼ばれ, User インスタンス(のモック)を返す
  • $user->nikckname ?? '名無しさん' が 1 回呼ばれ,文字列を返す
  • あだ名を示す文章が返ってくる

まずモックすべきは UserRepository::class ですね.

public function test_action(): void
{
    $userRepositoryMock = Mockery::mock(UserRepository::class);
    $userRepositoryMock
        ->shouldReceive('retrieveById')
        ->once()
        ->with(1)
        ->andReturn($userMock = Mockery::mock(User::class));
}

次に,UserRepository が返した User インスタンスのモックから,nickname を取り出す部分のモックを書いていきます.

ここで,expr1 ?? expr2isset(expr1) ? expr1 : expr2 のシンタックスシュガーであったことを思い出してください.
つまり

$nickname = $user->nikckname ?? '名無しさん';

は,実際には

$nickname = isset($user->nikckname) ? $user->nikckname : '名無しさん';

が実行されています.
PHP では,未定義プロパティに対して isset() が実行されると,マジックメソッドの __isset() が呼ばれます.
Model に実装された __isset() は以下の通りです.

https://github.com/illuminate/database/blob/9.x/Eloquent/Model.php#L2137-L2146

__get()__set() と似ていて,Model の __isset()Model::offsetExists() をラップしています.
したがって,今回はまず,getAttribute() をモックする前に offsetExists() をモックする必要があります.

以上を踏まえ,テストを書いてみると以下のようになります.

nickname が Null ではないパターン
public function test_action(): void
{
    $userRepositoryMock = Mockery::mock(UserRepository::class);
    $userRepositoryMock
        ->shouldReceive('retrieveById')
        ->once()
        ->with(1)
        ->andReturn($userMock = Mockery::mock(User::class));

    $userMock
        ->shouldReceive('offsetExists')
        ->once()
        ->with('nickname')
        ->andReturnTrue();
    $userMock
        ->shouldReceive('getAttribute')
        ->once()
        ->with('nickname')
        ->andReturn('ふわせぐ');

    $result = (new Sample(userRepository: $userRepositoryMock))->sample(1);
    $this->assertSame('ID 1 のユーザーのあだ名は ふわせぐ です');
}
nickname が Null のパターン
public function test_action(): void
{
    $userRepositoryMock = Mockery::mock(UserRepository::class);
    $userRepositoryMock
        ->shouldReceive('retrieveById')
        ->once()
        ->with(1)
        ->andReturn($userMock = Mockery::mock(User::class));

    $userMock
        ->shouldReceive('offsetExists')
        ->once()
        ->with('nickname')
        ->andReturnFalse();

    $result = (new Sample(userRepository: $userRepositoryMock))->sample(1);
    $this->assertSame('ID 1 のユーザーのあだ名は 名無しさん です');
}

まとめ

本記事では,Eloquent\Model のモックのツラミを踏まえた上で,初心者が躓きやすい動的プロパティアクセスのモックについて解説しました.
基本的には UseCase などのドメインロジックの中で,DB と密結合な Eloquent\Model は露出させたくないですが,「今更アーキテクチャ変えられないけどせめてテストは書いてみたい」のようなニーズは少なくないと思いますので,ぜひ参考になればいいなと思います.

GitHubで編集を提案
株式会社ゆめみ

Discussion

ukeloopukeloop

ORMなどを使用しない場合、モッククラスを作成せずにFactoryなどでインスタンスを作成すれば良いのではないのでしょうか?

$user = User::factory()->make([
    'name' => 'Abigail Otwell',
]);

また、Eloquent Modelを無理にモックせずにテスト用DBをSQLiteでも良いので用意したり、DB関連の処理をメソッドに切り出したりいた方が良いと思います。

ふわせぐふわせぐ

コメントありがとうございます!

ORMなどを使用しない場合

これは,Eloquent ではなく QueryBuilder を使う場合ということですかね?
それは今回の話ではないかなー,という感じですね.
個人的には,stdClass で返ってくるより Eloquent で返ってきたほうが恩恵が多いです.

Eloquent Modelを無理にモックせずに

もちろん機能テストに分類してしまって,DB を使ってテストをするというのも一つですね!
というか,本来は Eloquent が露出してるクラスのテストはそうあるべきだと思います.
ただし,扱うテーブルのカラム数が多い,リレーションが複雑など,小さなロジックのテストのために Factory でデータを用意するのは少々コストが高いと感じることがこれまでにありました.また,個人的に,Faker があんまり好きではなく,テスト用データであってもある程度値をコントロールしたいので,カラムが多いからできるだけ Faker で楽しよう,という発想には僕はならないです.
Laravel の思想としては,基本的に機能テストで一連の動作を担保するというのが定石ですが,どうしてもコアなロジックは純粋なロジックのユニットテストが書きたくなります.
ユニットテストでは,ロジック以外の部分は全てモックされている必要がありますので,もしロジック内に Eloquent が露出しているのであれば,それもモックしなければいけません.

DB関連の処理をメソッドに切り出したりいた方が良い

これは本記事でも何度も言及していますが,その上で Model をモックしたくなった場合の Tips として記事を書いています.
基本的に,しっかり設計されたアプリケーションであれば今回のようなモックをしなければいけないようなことにはならないはずですが,例えば,もともとテストを書くつもりがなかったり,設計の知見があまりないチームが作ったプロダクトであれば,依存関係の整理などもされていないコードというのは沢山あると思います.そのようなプロダクトが別の人やチームに引き継がれ,全体のリファクタリングをするコストはないが最低限の品質担保のためにテストを書きたい,などというケースは考えられます.
今回の記事は,そのようなエッジケースが割と当てはまるかなと思っています.