モックライブラリ「Prophecy」と「PHPUnit_Framework_MockObject」の比較

1人AdventのDay-6です。

adventar.org

前回の記事は @o0hさんでした。本日は、私@o0hがお送りします。

さて、皆さんはPHPUnitを利用する際に、モックを使っていますか?
PHPUnitには、標準で2つのモックオブジェクトが入っています。

1つ目が、MockObject です。これがデフォルト・・・という言い方も、「2種類とも最初から使える状態になっている」以上は微妙な感じもするのですが、まぁそんな感じです。
2つ目が、Prophecy です。PHPUnitの公式Docを見ると、以下のように説明がされています。

クセは強いけれども、強力で柔軟な、PHP のオブジェクトモッキングフレームワークです。 最初は phpspec2 のニーズを満たすために作られましたが、今やそれ以外のテスティングフレームワークでも、 最小限の努力で使えるようになりました」

・・・よくわかんないですね!
ということで、今回は「この2つを実際に使ってみるとどんな感じになるの」を比べて見たいと思います。

本題に入る前に

Prophecyについて

まず、Prophecyについてはこちらの記事が大変参考になるので、ご一読されることをおすすめします。

qiita.com

ダブル、スタブ、モック、スパイ

モックオブジェクトについて語るので、よく混同される用語の整理をまずしておくのが良いかと思います。 ・・・と思ったのですが、シンプルに纏めてくれてい記事があったのでご紹介を。

qiita.com

といっておいてなんですが、本記事中では特に意識せずに「モック」という言葉を利用します。モック方式・スパイ方式という言い方をした場合にだけ、テスト作法に関しての話題にフォーカスしているものとして、使い分けているとご理解いただければと。

モックっていつ使うの?

「テストが難しいもの」を登場させる必要があった場合に利用しますね。よく「インターフェイスに対してテストをかけ」などといいますが。
例えば、「決済APIを叩く処理」についての実装をしているとき、そのテストは「テストを実行するたびに決済を行う」わけには行きません。そこで、決済という外部システム=「依存コンポーネント」をモック化してしまえ、見たいな話です。

「決済して、その結果を返す部分」について「決済に必要なパラメータを受け取って、実行したふりをして値を返す」という振る舞いをする"ハリボテ"。それが、モックです。

PHPUnitでモックを使ってみよう

こんなに雑なサンプルコードを用意してみました!「あ、外部サービスを叩いているのねふむふむ」くらいのイメージをしてください。それ以上でも以下でもありません。

<?php
namespace App;

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

/**
 * Class HogeService
 * @package App
 */
class HogeService
{
    /**
     * HogeService constructor
     */
    public function __construct()
    {
        $this->http = new Client();
    }

    /**
     * Request user object to Hoge
     * 
     * @param int $id user id to request
     * @return ResponseInterface
     */
    public function getUser(int $id)
    {
        return $this->http->get("/user/{$id}");
    }
}

さて、 getUser()の単体テストを書きたいわけですが、ここで「テストの実行のたびに実際に「ほげ」にアクセスさせるわけには行きません。*1

また、HogeServiceはGuzzleHttp\Clientに依存しています。ということで、「実際にテストが困難な依存コンポーネント」として、Clientをモック化してしまえ!!という捉え方をしました。

実際にモックを用いたテストコード

ここからは、「モックを使ってみよう」のコーナーです。

まず簡単に、次のようなテストクラスを作成しておきます。

<?php
namespace App;

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

/**
 * Class HogeService
 * @package App
 */
class HogeService
{
    /**
     * HogeService constructor
     */
    public function __construct($client = null)
    {
        $this->http = $client ?? new Client();
    }

    /**
     * Request user object to Hoge
     *
     * @param int $id user id to requestc
     * @return ResponseInterface
     */
    public function getUser(int $id)
    {
        return $this->http->get("/user/{$id}");
    }
}

ここに、説明用にいろいろとテストケースを追加していきます。

1. PHPUnit MockObjectのpartial mockを用いた例

まずは、以下の内容で検査をできると考えました。

  • HogeService::getUser($userId)の呼び出し実行時に
  • Client::get()に対して
  • /user/:user_id を引数として
  • 呼び出しが1回行われている

これをもって、「ちゃんとHogeをキックできている」と判断することとします

素直に書いてしまえば、この様になりました。

<?php

    /**
     * test to get()
     */
    public function testGet()
    {
        $userId = 100;

        /** @var MockObject|HogeService $mock */
        $httpMock = $this->createPartialMock(Client::class, ['get']);
        $httpMock->expects($this->once())
            ->method('get')
            ->with("/user/{$userId}");
        $this->subject->http = $httpMock;


        $this->subject->getUser($userId);
    }
  1. createPartialMock() で、partial mockを作ります
    • これは、「指定したメソッドだけ無効化=モック化する」という方法です
  2. 「1回だけ」「getメソッドが」「user取得用パス文字列を引数として」呼び出されることを期待する・・・というモックを作成する
  3. そのモックを、subjetクラスのメンバとして注入し
  4. 実際に、getUser()をコールしてみる!

という内容です。これで、もし「期待されたとおりの内容で呼び出されていない」となると、テストケースにfailureが記録されます。

2. Prophecyのモック方式を用いた例

さて、同じ発想で今度はProphecyを利用して書いてみます。

<?php
    /**
     * test to get()
     */
    public function testGetByProphecy()
    {
        $userId = 100;

        /** @var ObjectProphecy|Client $clientP */
        $clientP = $this->prophesize(Client::class);
        $clientP->get("/user/{$userId}")->shouldBeCalledOnce();
        $this->subject->http = $clientP->reveal();


        $this->subject->getUser($userId);
    }

事前に「何が、何回、どうやって呼ばれるか」を宣言しておく持っk方式です。

  1. prophesizeに対象クラスを渡して、ObjectProphecyインスタンスを取得します
  2. get()が実際に呼び出されるような形式でmethodを書き・・・ 「1度だけ呼び出されるべき」という指定を最後にくっつけます
  3. reveal()により、モック*2を取得します

あとは実際に、subject->getUser()をコールしてみて、どうなるかな?といった具合です。

3. Prophecyのスパイ方式を用いた場合

今度は、事後に「呼ばれたかな?」を検査するスパイ方式です。

<?php
    /**
     * test to get()
     */
    public function testGetByProphecySpy()
    {
        $userId = 100;

        /** @var ObjectProphecy|Client $clientP */
        $clientP = $this->prophesize(Client::class);
        $this->subject->http = $clientP->reveal();

        $this->subject->getUser($userId);

        $clientP->get("/user/{$userId}")->shouldHaveBeenCalledOnce();
    }
  1. いきなりrevealして
  2. subject側の実行が終わってから、 「have been called: shouldHaveBeenCalled()」と検査する

という違いです。

さて、これで「MockObject」「Prophecyのモック方式」「スパイ方式」の3種類の書き方を抑えました。

Prophecyは「モックしたクラス」として型の制約をパスできる

例えば、HogeService::setClient() という利用するクライアントオブジェクトを注入できるメソッドを用意したとします。

<?php
    /**
     * HogeService constructor
     *
     * @param ?Client Client to use..
     */
    public function __construct(?$client = null)
    {
        $this->setClient($client ?? new Client());
    }

    /**
     * Inject Client object.
     *
     * @param Client $client
     */
    public function setClient(Client $client)
    {
        $this->http = $client;
    }

これに関連して、先程用意したテストケースを書き換えます

<?php
     /**
     * test to get()
     */
    public function testGetByProphecySpy()
    {
        $userId = 100;

        /** @var ObjectProphecy|Client $clientP */
        $clientP = $this->prophesize(Client::class);
        $this->subject->setClient($clientP->reveal());

        $this->subject->getUser($userId);

        $clientP->get("/user/{$userId}")->shouldHaveBeenCalledOnce();
    }

reveal()で得たオブジェクトは、これでも受け入れられることができます。

感想

Prophecyは、思想として「絶対に部分的モックなんてやるものか!」というのがある、と先程のHirakuさんの記事で紹介sれていました。テストの美しさ、そもそもあるべき思想として「モックが実際の動作を部分的に実現している状態」への気持ち悪さ・不完全さというのは、何となく分かる部分もあります。
それゆえに、PHPUnitもMockObjectは「部分的モックが使える」という点は、ある意味では強みでもあるのかな〜と感じました。実際、「挙動をぶっ壊してモックとする部分は最小限に抑えたほうが安心」という場合も、ないとも言い切れないとも思うからです。

対して、記述の簡潔さはProphecyの方が優位だな・・?と感じています。どう考えても、標準のMockObjectのほうが「癖が強い」のでは・・・・・・? ;;
expectsの指定もそうですし、スパイ方式での検査の提供によって、実際に検査する箇所を物理的に「actualの実行のあと」に持ってこれる、というのはテストコードの「見た目の気持ちよさ」において魅力を感じるのです。
最もベーシックな単体テストは、「実行結果をメモしてそれについてassertionを行う」というものでしょう。つまり、実行→結果検査という順序で記述がなされます。それを、「事前設定式」のモック方式の場合、期待する内容 -> 実行となっているわけです。冷静に考えると、これは気持ち悪いかもしれない・・・

そんなところで、隙があらばProphecyの実戦投入も狙ってみたいな!と思った次第でした😁

*1:なお、このコードはそもそもアクセス可能なURIを生成できないので、外部アクセスを生じないのですが

*2:スパイ方式で利用することもあるので、doubleという方が正しい