🤔

Reflection を使ったチョット高度なテスト

2023/03/25に公開

はじめに

僕の Zenn 記事ではおなじみ,PHP のモックライブラリ Mockery ですが,皆さんは使っていますでしょうか.
以下のコードは Mockery を使ってとあるクラス MyClass::class のモックを生成するサンプルコードです.

use Mockery;
use MyClass;

$mock = Mockery::mock(MyClass::class);
$mock->shouldReceive('hoge')
    ->once()
    ->with('fuga')
    ->andReturn('piyo');

このモックは

  • 'fuga' という文字列を受け取り
  • MyClass::hoge() というメソッドが
  • 1 回 呼ばれる

ことを期待し,

  • 'piyo' という文字列を返す

ように振る舞うことを定義するものです.

モックは,「期待通りの実行が行われるかの検証」と「振る舞いの定義」の 2 点を担っています.モック自体がテストの役割も果たしているということですね.

上記のコードでは,once() が「1回だけ呼ばれること」,with('fuga') が「'fuga' が引数として渡されること」を検証するよう定義する部分になっています.

したがって,例えばテスト対象を実行した際,'fuga' を引数に取るような MyClass::hoge() が呼ばれなかったり,引数に別の文字列が渡されたりするとテストに失敗します.期待する実行と異なるからですね.

本記事は,そんな Mockery の with() に関するお話です.

with() に渡すもの

先程の例では,MyClass::hoge()fuga が渡されることを期待しました.この検証を pass するような呼び出し側の実装は,例えばこんな感じになっているはずです.

$value = 'fuga';
$this->myClass->hoge($value);

逆に,(当たり前ですが)次のような実装になっていたらこの検証は失敗します.

$value = 'other value';
$this->myClass->hoge($value);

この例では string な値を受け取ることになっていますが,もちろんオブジェクトを引数に取るような場合も with() を使って検証することができます.
ただし,with()厳密な比較によって検証を行うため,内包する値が同じオブジェクトであっても同一インスタンスでなければ検証に失敗します.
ではここで,オブジェクトを引数に取る場合の例をいくつか考えてみます.

with() にオブジェクトを渡す

こんなコードがあったとします.

SendDirectMessageToUserAction.php
class SendDirectMessageToUserAction
{
    public function __construct(
        private UserRepository $users,
        private SlackService $slack,
    ) {
    }

    public function __invoke(UserId $userId): void
    {
        /**
         * @var UserEntity $user
         */
        $user = $this->users->findById($userId);

        // Slack にメッセージを送信
        $this->slack->postDirectMessage(
            userId: $user->id(),
            name: $user->name(),
            message: "Hello World!",
        );
    }
}
UserId.php
/**
 * UserId の ValueObject
 */
interface UserId
{
    /**
     * users.id は UUID
     */
    public function value(): string;
}
UserEntity.php
interface UserEntity
{
    public function id(): UserId;

    public function name(): string;
}
UserRepository.php
interface UserRepository
{
    public function findById(UserId $id): UserEntity;
}
SlackService.php
interface SlackService
{
    /**
     * 指定ユーザーに DM を送信する
     */
    public function postDirectMessage(UserId $userId, string $name, string $message): void;
}

さて,このような UseCase のクラスを Unit テストしたい場合,副作用となる Slack との通信はや DB アクセスはモックしたいですよね.
今回のテスト対象である SendDirectMessageToUserAction::class が依存しているのは

  • UserRepository::class
  • SlackService::class

の2つです.
また,SendDirectMessageToUserAction::__invoke()UserId::class を引数に取ります.

UserId に関しては,VO ですので基本的に副作用にはなり得ません.「こういうのも含めて依存は全部モックしちゃう派」の方もいらっしゃるとは思いますが,今回はこの UserId インタフェースを実装したクラスを定義し,テストでは実体を new して使うことにします.

UserId の実装
// UserIdContract は上に示した UseId インタフェース
class UserId implements UserIdContract
{
    public function __construct(
        private string $value,
    ) {
    }

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

    public static function create(string $value): static
    {
        return new static($value);
    }
}

まずは,UserRepository のモックを作ります.

use Mockery;

// UserId のインスタンス
$userId = UserId::create('xxx');

// UserRepository のモックインスタンスを作る
$userRepository = Mockery::mock(UserRepository::class);

// UserRepository の振る舞いを定義
$userRepository
    ->expects('findById')
    ->with($userId)
    ->andReturn($user = Mockery::mock(UserEntity::class));

// UserEntity の振る舞いを定義
$user
    ->expects('id')
    ->andReturn($userId);
$user
    ->expects('name')
    ->andReturn('ふわせぐ');

なお,expects()shouldReceive()->once() と等価です.
続いて SlackService のモックを作ります.

// SlackService のモックインスタンスを作る
$slackServiceMock = Mockery::mock(SlackService::class);

// SlackService の振る舞いを定義
$slackServiceMock
    ->expects('postDirectMessage')
    ->with(
        $userId,
        'ふわせぐ',
        'Hello World!',
    );

これで,SendDirectMessageToUserAction::class が依存するクラスに関してすべてモックすることができました.
あとは,以下のように実行すればテスト完了です.

// モックを注入して UseCase のインスタンスを作る
$action = new SendDirectMessageToUserAction(
    users: $userRepositoryMock,
    slack: $slackServiceMock,
);

// 実行
($action)($userId);

正直これだけモックで埋め尽くされてしまうと何がテストできてるのか分からなくなってきますね...笑
正直こうなってしまうなら Unit テストは要らなくて,逆に末端の Http 通信だけ Http::fake() などでモックしつつ,DB を使った Feature テストをするほうがよっぽど良いです.

ただし,今回は説明のために,このような例を使っていることをご理解ください m(_ _)m .

さて,このように with() にオブジェクトが渡せることも確認できました.
ここまでは,割とありがちな例で,公式のドキュメントにも例として書いてあるレベルなので何も難しいことは無いと思います.

では,ここから更に深堀りしてみます.

中で new しているオブジェクトを受け取るメソッドの with()

先程の例では,SlackService::postDirectMessage() はシンプルなメッセージを string で受け取るようになっていました.
ここで,Slack に送信するメッセージをもう少しリッチな見た目にしたいという話になったとしましょう.Slack には,構造化されたメッセージデータを Slack に送信することで以下のようなリッチなメッセージを送信できるような仕組みがあります.

Reference: Secondary message attachments (https://api.slack.com/reference/messaging/attachments)

この構造化されたメッセージを,MessageBlock::class という型で受け取ることにします.
簡単にメッセージが作れるような仕組みにするために,addText()addImage() メソッドを実装させるように定義します.

MessageBlock.php
interface MessageBlock
{
    /**
     * メッセージの定義を配列として返す
     */
    public function toArray(): array;

    public function addText(string $text): static;

    public function addImage(string $url): static;

    public function addDivider(): static;
}
MessageBlock.php(実装側)
class MessageBlock implements MessageBlockContract
{
    private array $elements = [];

    public function toArray(): array
    {
        return array_map(fn (Element $elem) => $elem->toArray(), $this->elements);
    }

    public function addText(string $text): static
    {
        $this->elements[] = new TextElement($text);
        return $this;
    }

    public function addImage(string $url): static
    {
        $this->elements[] = new ImageElement($text);
        return $this;
    }

    public function addDivider(): static
    {
        $this->elements[] = new DividerElement($text);
        return $this;
    }
}

ここで,TextElement::class, ImageElement::class, DividerElement::class は,Element インタフェースを実装している,パーツごとのオブジェクトとします.
最低限このような設計にしておいたほうがリアリティが出るかなと思って作りましたが,本記事の本質からは離れるためこれ以上の言及はしないことにします.

そして,SlackService のインタフェースを以下のように修正します.

SlackService.php
+ public function postDirectMessage(UserId $userId, string $name, MessageBlock $block): void;
- public function postDirectMessage(UserId $userId, string $name, string $message): void;

SlackService を修正したのでそれに依存する UseCase 側も修正します.

SendDirectMessageToUserAction.php
+ $block = new MessageBlock();
+ $block
+     ->addText('にゃーん')
+     ->addDivider()
+     ->addImage('https://example.com/images/cats/1.png');

  // Slack にメッセージを送信
  $this->slack->postDirectMessage(
      userId: $user->id(),
      name: $user->name(),
+     block: $block,
-     message: "Hello World!",
  );
修正後の SendDirectMessageToUserAction 全体
SendDirectMessageToUserAction.php
class SendDirectMessageToUserAction
{
    public function __construct(
        private UserRepository $users,
        private SlackService $slack,
    ) {
    }

    public function __invoke(UserId $userId): void
    {
        /**
         * @var UserEntity $user
         */
        $user = $this->users->findById($userId);

        $block = new MessageBlock();
        $block
            ->addText('にゃーん')
            ->addDivider()
            ->addImage('https://example.com/images/cats/1.png');

        // Slack にメッセージを送信
        $this->slack->postDirectMessage(
            userId: $user->id(),
            name: $user->name(),
            block: $block,
        );
    }
}

さて,ではテストも修正しましょう.

困ったぞ...?

  // SlackService のモックインスタンスを作る
  $slackServiceMock = Mockery::mock(SlackService::class);

  // SlackService の振る舞いを定義
  $slackServiceMock
      ->expects('postDirectMessage')
      ->with(
          $userId,
          'ふわせぐ',
-          'Hello World!',
      );

message の部分が block に変わっただけ.ただそれだけです.でも,block として渡される MessageBlock::class インスタンスは UseCase の中で new されている.
例えばこんな風にテストを書くと失敗します.

$block = new MessageBlock();
$block
    ->addText('にゃーん')
    ->addDivider()
    ->addImage('https://example.com/images/cats/1.png');

$slackServiceMock
    ->expects('postDirectMessage')
    ->with(
        $userId,
        'ふわせぐ',
        $block,
    );

なぜなら,ここで作った MessageBlock::class インスタンスと,実際に UseCase 内で作って渡されるインスタンスは別物だからです.
先程言及した通り,with() に渡された引数の検証は === で比較される厳密な評価です.内包している値が同じでも,spl_object_id() 的な意味では別物扱いです.

あるぞ!逃げ道

安心してください.そんなピンチを切り抜ける手段があります.
Mockery に同梱されているライブラリに hamcrest/hamcrest-php というライブラリがあります.

https://github.com/hamcrest/hamcrest-php

この中に,IsEqual::equalTo() というメソッドがあります.このメソッドは,オブジェクトを == を使った弱い比較で評価してくれるというものです.これを使って上記のテストを

  $slackServiceMock
      ->expects('postDirectMessage')
      ->with(
          $userId,
          'ふわせぐ',
+          IsEqual::equalTo($block)
-          $block,
      );

このように修正することで,オブジェクト ID が違っていても値として同じであれば 等しい と評価されます.

でももっと厳密なアサーションがしたい

IsEqual::equalTo() も十分に便利ですが,これだけでは物足りない場合があります.
いままで扱っていた DM を送信する UseCase を,無理やり更に複雑に修正してみます.

SendDirectMessageToUserAction.php
use Illuminate\Contracts\Bus\QueueingDispatcher;

class SendDirectMessageToUserAction
{
    public function __construct(
        private UserRepository $users,
        private MessageRepository $messages,
        private QueueingDispatcher $dispatcher,
    ) {
    }

    public function __invoke(UserId $senderId, UserId $receiverId, string $message): void
    {
        // 送信者
        $sender = $this->users->findById($userId);

        // 受信者
        $receiver = $this->users->findById($receiverId);

        // リッチメッセージを作成
        $block = new MessageBlock();
        $block
            ->addText("{$sender->name()}さんからメッセージです!")
            ->addDivider()
            ->addText($message);

        // メッセージを DB に保存
        $message = $this->messages->create(
            senderId: $senderId,
            receiverId: $receiverId,
            message: $message
        );

        // DM 送信 Job を作成
        $job = new SendDirectMessageToUserJob(
            receiverId: $receiver->id(),
            receiverName: $receiver->name(),
            block: $block,
            messageId: $message->id(), // 送信完了したら送信日時を更新するために id を渡す
        );

        // Job をディスパッチ
        $this->dispatcher->dispatch($job);
    }
}

突然めちゃくちゃ複雑になりました...
要件をまとめると以下のようになります.

  • 送信者と受信者がいる
  • 送信者は任意のメッセージを入力できる
  • DM はリッチメッセージとして送信する
  • メッセージ内容は DB に保存する
  • DM 送信は Job にして Queuing する
  • 送信が完了したら,送信日時を更新する(Job 内)

このような UseCase を,今度は Feature テストすることを考えます.
Feature テストは可能な限り実際の動き通りに実行させたいため,Repository はモックしません.ただし,Queuing に関しては副作用になるため今回はモックすることにします.そうすると,モックすべきは QueueingDispatcher::class です.

$dispatcherMock = Mockery::mock(QueueingDispatcher::class);
$dispatcherMock
    ->expects(dispatch)
    ->with(
        // Job を入れたい
    );

さて,with() に何を渡すかを考えましょう.
今回,実は IsEqual::equalTo() が使いづらいです.なぜかと言うと,SendDirectMessageToUserJob::class がコンストラクタで $message->id() を受け取っているからです.
この messageId も,MessageId という VO である想定ですが.この値はメッセージデータのインサート時に勝手に決まる UUID を内包する VO であるため,テスト側から任意の値に決め打ちすることが出来ないからです.

一つ.代替手段として Hamcrest の IsInstanceOf::anInstanceOf() を使う方法があります.これは,「このクラスのインスタンスであれば OK」というアサーションを wit() の引数に渡せるというものです.
こrを使って次のように書くことが出来ます.

$dispatcherMock = Mockery::mock(QueueingDispatcher::class);
$dispatcherMock
    ->expects(dispatch)
    ->with(
        IsInstanceOf::anInstanceOf(SendDirectMessageToUserJob::class)
    );

ただし,これは QueueingDispatcher::dispatch() に渡される引数が SendDirectMessageToUserJob::class であることしかチェックしていないため,実際に想定通りの receiverId や block を受け取っているかまでは検証できていません.

さて,どうしたものか.

Mockery::on() + ReflectionClass + ReflectionProperty で大いなる力を得る

百聞は一見にしかずです.まずは以下のコードを御覧ください.

$dispatcherMock = Mockery::mock(QueueingDispatcher::class);
$dispatcherMock
    ->expects(dispatch)
    ->with(
        Mockery::on(function (mixed $actual) {
            // まずは $actual が SendDirectMessageToUserJob であることを確認する
            $this->assertInstanceOf(SendDirectMessageToUserJob::class, $actual);

            // チェックしたいプロパティとその値を検証するクロージャのマップ
            $propertyAssertingMap = [
                'receiverId' => function (mixed $value) {
                    // オブジェクトの比較なので Equals
                    $this->assertEquals($this->testUser1->id(), $value);
                },
                'receiverName' => function (mixed $value) {
                    $this->assertSame($this->testUser1->name(), $value);
                },
                'block' => function (mixed $value) {
                    $block = new MessageBlock();
                    $block
                        ->addText("送信者さんからメッセージです!")
                        ->addDivider()
                        ->addText("にゃ~ん");

                    $this->assertEquals($block, $value);
                },
                'messageId' => function (mixed $value) {
                    // この messageId は UseCase の実行途中で自動生成される値(DB の key)なので型の検証のみ
                    $this->assertInstanceOf(MessageId::class, $value);
                },
            ];

            $reflection = new ReflectionClass($actual);
            Collection::make($reflection->getProperties())
                ->each(function (ReflectionProperty $property) use ($actual, $propertyAssertingMap): void {
                    $propertyAssertingMap[$property->getName()]($property->getValue($actual));
                });

            //  プロパティのチェックが全部終わっているなら dispatch の引数アサーションは通して良い
            return true;
        }),
    );

何が起こっているのか

まず,Mockery::on() についてです.
Mockery::on() は,with() での引数の検証時に,より詳細なカスタムチェックを行うためのメソッドです.
通常の with() での検証は,期待した引数と実際に渡ってきた引数が 等しいか のチェックしかできませんが,on() を使えば,たとえば

  • 特定の文字列を含むか
  • 100 より小さい数字か

など,より複雑な検証ができます.
on() の引数はクロージャで,次のような使い方をしますになっています

// $actual には,実際に実行時に引数として渡される値が入る
Mockery::on(function ($actual) {

    // 何らかの検証
    // 例:
    //     ・ $result = $actual < 100;
    //     ・ $result = str_contains($actual, 'hoge');

    return $result // 検証に通れば true, そうでなければ false
});

今回は,$actual には Job のインスタンスが入ってくる想定で値の検証をしています.

詳しくは以下のドキュメントを参照してください.

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

次に実際に on() のクロージャの中で何をやっているかについてです.

型のチェック

$this->assertInstanceOf() を使って $actualSendDirectMessageToUserJob::class のインスタンスであるかを検証しています.
そもそもこれが通らないと,内包する値の検証には進めません.

プロパティ毎の検証ロジックを定義

$propertyAssertingMap には,key にプロパティ名,value にそのプロパティのアサーションロジックを持つ連想配列を定義しています.プロパティによって,検証したいことやその精度が異なります.
例えば,receiverId や receiverName などは値が等しいことをチェックしたいですが,messageId に関しては前述の通り厳密な値まではチェックできないため,MessageId:class のインスタンスであるかだけチェックするようにしています.

Reflection で $actual を解析

ReflectionClass は,渡されたオブジェクトの「クラスについての情報」を取得する PHP ビルトインの機能です.
実際のプロダクションロジックでの利用はオーバーヘッドになるため推奨されませんが,テストでの利用は大変便利です.今回 ReflectionClass を使った理由は,SendDirectMessageToUserJob::class の private なプロパティの値を検証したいからです.
通常,private なプロパティへ外からアクセスすることはできませんが,Reflection を使えばそれが可能です.

https://www.php.net/manual/ja/class.reflectionclass.php

以下のようにして,$actual が持つ全てのプロパティの情報を配列で取得することができます.

$reflection = new ReflectionClass($actual);
$properties = $reflection->getProperties();

なお,ReflectionClass::getProperties() が返す配列は,ReflectionProperty[] となっています.

ReflectionProperty は,オブジェクトが持つ「プロパティについての情報」を持つクラスです.例えば,ReflectionProperty::getName()ReflectionProperty::getValue() を使えば,たとえそれが private なプロパティであっても,プロパティ名や実際の値を取得することができます.

https://www.php.net/manual/ja/class.reflectionproperty.php

今回はこれらを組み合わせて,SendDirectMessageToUserJob::class が持つ全てのプロパティに関してそれぞれ定義したアサーションロジックを適用し,値のチェックを行っているというわけです.

まとめ

  • 今回は Mockery の with() の使い方について解説しました
  • with() はモックしたメソッドが引数として受け取る値を期待するものです
  • with() にはプリミティブな値だけではなく,任意のインスタンスを渡すことができます
  • 厳密な比較がされるため,オブジェクトを渡す場合は適宜 Hamcrest の IsEqual::equalTo() を使うことができます
  • 型のチェックだけであれば,Hamcrest の IsInstanceOf::anInstanceOf() を使うこともできます
  • 更に詳細なアサーションは Mockery::on() を使うことで実現できます
  • ReflectionClassReflectionProperty を組み合わせることで,private なプロパティの値も検証することが可能です
GitHubで編集を提案
株式会社ゆめみ

Discussion