🥱

【PHPerKaigi2022 後日談】なんちゃって Entity を導入しよう

2022/04/13に公開約11,100字5件のコメント

はじめに

先日,PHPerKaigi 2022 に登壇させていただきました.ご清聴いただいた皆さま,ありがとうございました!

https://fortee.jp/phperkaigi-2022/proposal/7d7503c6-b152-40c5-8d51-e24145c522ef

また,登壇に使用したスライドも公開してありますのでぜひご覧ください.

登壇内容をふりかえる

さて,今回の発表では主に以下のことについてお話しました.

  • Laravel Facade を使いすぎると破綻するのでできるだけ使用を控えよう
  • プロダクトの規模が大きくなるにつれて MVC アーキテクチャではつらみが出てくるので「なんちゃってクリーンアーキテクチャ」を導入しよう
  • Unit テストを書こう.そのためにアプリケーションのロジックからフレームワーク依存を剥がそう

この内,3つ目の「Unit テストを書こう」では,ビジネスロジックが集まる UseCase 層が Laravel (主に Eloquent)に依存してしまうと Unit テストが書けないので, 「Entity + Repository」を導入することによって Eloquent への依存を無くす ,という解決策を示しました.

スライド中に示した Entity のコード例は以下の通りです.

class User
{
    public function __construct(
        private int $id,
        private string $name,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function name(): string
    {
        return $this->name;
    }
}

コンストラクタで各属性を受け取り,ゲッターで値を返す,とてもシンプルなものです.

DB を扱う上で想定される入出力のありがちな処理は以下のようなものが考えられます.

  • 新しくレコードを登録する
  • 条件指定してレコードを取得する
  • 一括でレコードの内容を書き換える(REST の PUT 的な処理)
  • カラムを指定して内容を書き換える(REST の PATCH 的な処理)
  • レコードを削除する

上記のような Entity を用意している場合,対応する Repository は次のように実装できます.
前提として,以下のような User モデルがあるとします

class User extends Model
{
    public function toEntity(): UserEntity
    {
        return new UserEntity(
            id: $this->id,
            name: $this->name,
        );
    }
}

新しくレコードを登録する

public function create(string $name): UserEntity
{
    return UserModel::create([
        'name' => $name,
    ])->toEntity();
}

条件指定してレコードを取得する

public function retrieveById(int $id): ?UserEntity
{
    return UserModel::query() // find() などでも可能
        ->whereKey($id)
        ->first()
        ?->toEntity()
}

一括でレコードの内容を書き換える(REST の PUT 的な処理)

public function update(int $id, string $name): UserEntity
{
    if (! $model = UserModel::find($id)) {
        throw new UserNotFoundException();
    }

    $model->update([
        'name' => $name,
        // 他の値も引数で受け取り列挙
    ]);

    return $model->toEntity();
}

カラムを指定して内容を書き換える(REST の PATCH 的な処理)

public function updateName(int $id, string $name): UserEntity
{
    if (! $model = UserModel::find($id)) {
        throw new UserNotFoundException();
    }

    $model->update([
        'name' => $name,
    ]);

    return $model->toEntity();
}

レコードを削除する

public function delete(int $id): void
{
    if (! $model = UserModel::find($id)) {
        throw new UserNotFoundException();
    }

    $model->delete();
}

カラムが増えたときのことを想像してみよう

updateHoge() を沢山生やすかどうかは,絶対にそのカラムだけを更新してほしい・他のカラムには触れないで欲しいなどの強い意志がある時や,更新する可能性があるカラムが確定で限られている時などになるとは思いますが,シンプルな update() メソッドの需要はそれなりにあると思います.
カラムが増えた時,Repository層 を通すととても大変です.なにか一つ更新したい場合でも以下のように全ての属性を列挙しなければいけません.

// twtter の user name を更新したいだけなのに...
$user = $this->userRepository
    ->update(
        id: $user->id(),
        name: $user->name(),
        nickName: $user->nickName(),
        birthday: $user->birthday(),
        gender: $user->gender(),
        twitterUserName: $newTwitterUserName,
        // ...
    );

PHP8.0 から導入された名前付き引数のおかげで値の渡しミスはかなり防げますが,それでも列挙するのがとても大変です.

あぁ,Eloquent と仲良くしていた頃が懐かしいなぁ.ActiveRecord だったら

$user = User::find($id);
$user->update([
    'twitter_user_name' => $newTwitterUserName,
]);

とか

$user = User::find($id);
$user->twitter_user_name = $newTwitterUserName;
$user->save();

とかで,簡単に更新できたのになぁ....

ん? ActiveRecord

そうか. Entity も ActiveRecord っぽくしてしまえばいいんだ.

Eloquent と完全に縁を切ったわけではありません.禁止したわけじゃないんです.
ここは都合よく ActiveRecord 味の蜜を吸わせてもらいましょう.
Entity に save() を生やせるよう改造しちゃえばいいんです.

Eloquent に依存する 「なんちゃって Entity」

さて,いきなりですが実装例を示します.

class User
{
    public function __construct(
-        private int $id,
-        private string $name,
+        private UserModel $model
    ) {
+        assert($model->exists);
    }

    public function id(): int
    {
-        return $this->id;
+        return $this->model->id;
    }

    public function name(): string
    {
-        return $this->name;
+        return $this->model->name;
    }
+
+    public function setName(string $name): static
+    {
+        $this->model->name = $name;
+
+        return $this;
+    }
+
+    public function save(): static
+    {
+        $this->model->save();
+
+        return $this;
+    }
}

差分を簡単にまとめるとこうです.

  • コンストラクタで直接 Eloquent Model を受け取るようにしました
  • 各パラメータに対してセッターを実装しました
  • save() メソッドを実装しました

このようにリファクタリングすることで先の twitter_user_name の例は次のように書き換えることができます.

- $user = $this->userRepository
-    ->update(
-        id: $user->id(),
-        name: $user->name(),
-        nickName: $user->nickName(),
-        birthday: $user->birthday(),
-        gender: $user->gender(),
-        twitterUserName: $newTwitterUserName,
-        // ...
-    );
+$user->setTwitterUserName($newTwitterUserName);
+$user->save();

とてもスッキリしましたね!
ただ,これだけではイマイチイメージがつかない方や「テストどうするの?」と思われる方もいらっしゃると思うので,実際にこの「なんちゃって Entity」を使った UseCase の実装例とそのテストを示します.

Models/Employee.php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Entities\Employee as EmployeeEntitiy;

/**
 * @property int $id ID
 * @property string $name 氏名
 * @property int $employee_number 社員番号
 * @property string $emergency_contact_number 緊急連絡先
 */
class Employee extends Model
{
    public function toEntity(): EmployeeEntity
    {
        return new EmployeeEntity($this);
    }
}
Entities/Employee.php
<?php

declare(strict_types=1);

namespace App\Entities;

use App\Models\Employee as EmployeerModel;

class Employeer
{
    public function __construct(
        private EmployeeModel $model
    ) {
        assert($this->model->exists);
    }

    public function id(): int
    {
        return $this->model->id;
    }

    public function name(): string
    {
        return $this->model->name;
    }

    public function employeeNumber(): int
    {
        return $this->model->employee_number;
    }

    public function emergencyContactNumber(): string
    {
        return $this->model->emergency_contact_number;
    }

    public function setName(string $value): static
    {
        $this->model->name = $value;
        return $this;
    }

    public function setEmployeeNumber(string $value): static
    {
        $this->model->employee_number = $value;
        return $this;
    }

    public function setEmergencyContactNumber(string $value): static
    {
        $this->model->emergency_contact_number = $value;
        return $this;
    }

    public function save(): static
    {
        $this->model->save();
        return $this;
    }
}
Repositories/EmployeeRepository.php
<?php

declare(strict_types=1);

namespace App\UseCases\Repositories;

use App\Entities\Employee as EmployeeEntitiy;
use App\Models\Employee as EmployeeModel;

class EmployeeRepository
{
    public function retrieveByEmplpyeeNumber(int $employeeNumber): ?EmployeeEntity
    {
        return EmployeeModel::query()
            ->where('employee_number', $employeeNumber)
            ->first()
            ?->toEntity();
    }
}
<?php

declare(strict_types=1);

namespace App\UseCases\Employee;

use App\Exceptions\Employee\EmployeeNotFoundException;
use App\Exceptions\Employee\OverwiteSameValueException;
use App\Entities\Employee;
use App\Repositories\EmployeeRepository;

class UpdateEmergencyContactNumberAction
{
    public function __construct(
        private EmployeeRepository $repository,
    ) {
    }

    public function __invoke(int $employeeNumber, string $emergencyContactNumber): Employee
    {
        // 社員番号から社員を検索
        $employee = $this->repository
            ->retrieveByEmplpyeeNumber($employeeNumber);

        // 存在チェック
        if (!$employee) {
            throw new EmployeeNotFoundException('該当する社員が見つかりませでした。');
        }

        // 同じ番号での上書きを禁止する
        if ($employee->emergencyContactNumber() === $emergencyContactNumber) {
            throw new OverwiteSameValueException('変更前と同じ番号です。');
        }

        // 緊急連絡先の更新
        $employee->setEmergencyContactNumber($emergencyContactNumber);
        $employee-save();

        return $employee;
    }
}

<?php

declare(strict_types=1);

namespace Tests\Unit\UseCases;

use App\Entities\Employee as EmployeeEntity;
use App\Exceptions\Employee\EmployeeNotFoundException;
use App\Repositories\EmployeeRepository;
use App\UseCases\Employee\UpdateEmergencyContactNumberAction;
use Mockery;
use Mockery\MockInterface;

class UpdateEmergencyContactNumberActionTest
{
    /**
     * @var EmployeeRepository&MockInterface&mixed
     */
    private EmployeeRepository $repository;

    private UpdateEmergencyContactNumberAction $action;

    public function setUp(): void
    {
        parent::setUp();

        $this->repository = Mockery::mock(EmployeeRepository::class);
        $this->action = new UpdateEmergencyContactNumberAction($this->repository);
    }

    /**
     * @test
     */
    public function 緊急連絡先の更新(): void
    {
        // リポジトリの振る舞い
        $this->repository
            ->shouldReceive('retrieveByEmplpyeeNumber')
            ->once()
            ->with('123')
            ->andReturn($employee = Mockery::mock(EmployeeEntity::class));

        // エンティティの振る舞い
        $employee
            ->shouldReceive('emergencyContactNumber')
            ->once()
            ->andReturn('080-1234-5678');
        $employee
            ->shouldReceive('setEmergencyContactNumber')
            ->once()
            ->with('080-9876-5432');
        $employee
            ->shouldReceive('save')
            ->once()

        // 実行
        $result = $this->action(
            employeeNumber: '123',
            emergencyContactNumber: '080-9876-5432',
        );

        // 戻り値の検証
        $this->assertInstanceOf(EmployeeEntity::class, $result);
    }

    /**
     * @test
     */
    public function 緊急連絡先が存在しない(): void
    {
        // リポジトリの振る舞い
        $this->repository
            ->shouldReceive('retrieveByEmplpyeeNumber')
            ->once()
            ->with('123')
            ->andReturnNull();

        // 例外が投げられることを期待
        $this->expectException(EmployeeNotFoundException::class);

        // 実行
        $this->action(
            employeeNumber: '123',
            emergencyContactNumber: '080-9876-5432',
        );
    }
}

このように,Entity 自身は Eloquent Model に依存していますが,UseCase 自身は引き続き Eloquent Model に依存しないため,モックを使って UseCase の Unit テストを書くことができました!

まとめ

  • Entity + Repository を導入することで UseCase から Eloquent を剥がし Unit テストが書けるようになる(PHPerKaigi の登壇内容)
  • Entity がコンストラクタで各属性を受け取る形だと,カラムが増えてきたときに更新機能の実装が大変になる
  • Entity が Eloquent Model を受け取る形にすることで,ActiveRecord のように Entity に save() メソッドなどを生やせるようになるので便利
GitHubで編集を提案

Discussion

素人質問で恐縮ですが, Eloquent Modelのモックを使用したテストとEntity(Eloquent Modelのwapper)のモックを使用した場合とではどういった点で違いや利点があるのでしょうか?

とても興味深い質問ありがとうございます!!
僕が普段モックライブラリに Mockery を使っているので,その前提で話させてください.

結論からいうと,Eloquent Model のモックを選ばないのは「いまいちしっくり来ないから」に尽きます.
この記事自体もかなり賛否両論ある話題ですし,「絶対この方法がいい!」というわけでは無いですので,こういうやり方もあるんだ,くらいに見ていただけたらと思います.
とはいえ,しっくり来ないだけで片付けてもモヤモヤすると思うので,いくらか例など上げながら僕の考えを説明させていただきますね.
もしかしたらほとんどご存知かもしれませんが,他の方のためにもちょっと詳しく書きます.

前提

Eloquent Model は レコードの各値をプロパティとして提供しますよね.$user->name のような感じで.でもこのプロパティは,最初から定義してあるものではなく,__get() を使った擬似的なプロパティになります.Eloquent Model の __get() の実装はこんな感じです

public function __get($key)
{
    return $this->getAttribute($key);
}

同じように,プロパティに値をセットするときも __set() を用いています.

public function __set($key, $value)
{
    $this->setAttribute($key, $value);
}

Eloquent Model をモックしてみる

では,Entity への詰替えを行わない Repository を作ってみます.

<?php

namespace App\Repositories;

use App\Models\User;

class TestRepository
{
    public function getUserById(int $id): ?User
    {
        return User::query()
            ->whereKey($id)
            ->first();
    }
}

これを使った UseCase を作ってみます.

<?php

namespace App\UseCases;

use App\Models\User;
use App\Repositories\TestRepository;
use Exception;

class TestAction
{
    public function __construct(
        private TestRepository $repository,
    ) {
    }

    public function __invoke(int $id, string $email): User
    {
        // id からユーザーを検索
        $user = $this->repository->getUserById($id);

        if (!$user) {
            throw new Exception('ユーザーが見つかりませんでした');
        }

        // 2 通りの書き方ができるので,例としてどちらも書いているだけ
        if ($user->email === $email) {
            throw new Exception('変更前と同じアドレスです。');
        }

        $user->email = $email;

        // 2 通りの書き方ができるので,例としてどちらも書いているだけ
        if (!$user->isDirty('email')) {
            throw new Exception('変更前と同じアドレスです。');
        }

        $user->save();

        return $user;
    }
}

本記事とほぼ同じような例ですね.
ここで Entity 版と決定的に違うのは モック対象のオブジェクトにプロパティアクセスが発生している という点です.

では,Repository および Eloquent Model をモックして UseCase のロジックの Unit テストをしてみようと思います.が,ここで問題が生じます.

Mockery は存在しないプロパティやマジックメソッドのモックができないんですね.なので,Illuminate\Database\Eloquent\Model.php の実装をみにいき,__get()__set() の中身を全てモックする必要があります.具体的には,getAttribute()setAttribute() です.
したがって,上記の UseCase のテストを書くなら,このようになります.

<?php

namespace Tests\Unit\UseCases;

use App\Models\User;
use App\Repositories\TestRepository;
use App\UseCases\TestAction;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;

class TestActionTest extends TestCase
{
    /**
     * @var TestRepository&MockeryIntefcace&mixed
     */
    private TestRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $this->repository = Mockery::mock(TestRepository::class);
    }

    public function test_hoge():void
    {
        // リポジトリの振る舞い
        $this->repository
            ->shouldReceive('getUserById')
            ->once()
            ->with(123)
            ->andReturn($user = Mockery::mock(User::class));

        $user
            ->shouldReceive('getAttribute')
            ->once()
            ->with('email')
            ->andReturn('foo@example.com');

        // エンティティの振る舞い
        $user
            ->shouldReceive('setAttribute')
            ->once()
            ->with('email', 'bar@example.com');

        $user
            ->shouldReceive('isDirty')
            ->once()
            ->with('email')
            ->andReturn(true);

        $user
            ->shouldReceive('save')
            ->once();

        // 実行
        $result = (new TestAction($this->repository))(
            id: 123,
            email: 'bar@example.com',
        );

        // 戻り値の検証
        $this->assertInstanceOf(User::class, $result);
    }
}

所感

プロパティの呼び出しがモックできないなら,App\Models\User.php にアクセサやミューテタのノリでゲッタやセッタを生やし,それらのモックを行えばいいのではないかと思われるかもしれません.しかし,Model にゲッタやセッタを作れば,それは Entity を作っているのとさほど変わりません.
逆に,現バージョンで300以上のメソッドをもつ Model クラスを継承したクラスにさらにゲッタやセッタを生やすのは,あまりいい方法だとは思いません.
それよりかは,新しく Entity という形で詰め直してあげた方が,Laravel に依存しないコードが書けたり(PHPerKaigi の僕の発表のテーマの一つでもありました),しっかりと型でしばれたりと,メリットが多いと考えています.
そもそも,本来はプロパティの呼び出しをモックしたいのに,getAttribute()setAttribute() をモックしなければならないのも,なんだか本質的ではなくて僕の好みではありません.
また,もし Laravel のバージョンが上がって __get()__set() 内で private なメソッドが呼び出されるような変更があったらどうでしょうか?同じテストが動かなくなるだけではなく,private メソッドはモックすらできないわけですから,Model のモックは諦めなくてはいけません.
Entity を実装した場合,当たり前ですが Entity の実装の主導権はこちらにあります.対して,Model は自分が意図しない形で変更される可能性があります.実装の主導権がこちらに無いためですね.

このように,テストのモックに関して,テストの実装者が完全に主導権を握れないコードに依存してしまうようなテストコードやモックは,あまり良いとは言えないと思っています.

これらのことを考えると, Entity に詰めるというのは色々考えないとならない問題が減るため総合的に良い方法なのかなと思っています!!!

ただ,最初にも申し上げましたがこれが絶対に正解というわけではないですので,ぜひもっと良い方法などありましたらぜひ教えて下さい!

長々と失礼しました〜

ご丁寧なご返信ありがとうございます。

Modelのモックについて改めて考えてみましたが、attributesの状態遷移、ミューテタやObserverなどによる変更などを考慮すると、Model/Entityのモックを使用したテストでは、テストは不十分だと考えるようになりました。

ミューテタやObserverなどを使用しないとコーディング規約で決まっていれば心配する必要はないかもしれませんが、Modelに依存している以上は、Modelの魔法による副作用がないかの確認を行うのがテストの役目の一つではないのかなと思います。

面白い記事をありがとうございます!(phperkaigiでも発表拝見させていただきました!)
些細なことなのですが、自分の勘違いでなければ一点だけ気になったのでコメントさせてください!

UpdateEmergencyContactNumberActionの

use App\Models\Employee;

public function __invoke(int $employeeNumber, string $emergencyContactNumber): Employee

use App\Entities\Employee;

public function __invoke(int $employeeNumber, string $emergencyContactNumber): Employee

の間違いかなと思ったりしたのですが、どうでしょうか?
戻り値の型がEntityではなくEloquentモデルになってしまっているのでは?という意味です!

あー,間違ってますね!!
ありがとうございます!!

ログインするとコメントできます