Guzzle6専用のモックAPIを自作する

  • 16
    Like
  • 0
    Comment

PHPのHTTPクライアントライブラリであるところのGuzzleは、実際にHTTPリクエストを行う部分をハンドラと称して切り離せるようにしており、ここを差し替えることでモックを作ることができます。

テストのときに実際のAPIサーバーへリクエストを投げなくても、思った通りのレスポンスを受け取ったことにして、コードを実行することができます。

Guzzle公式のMockHandler

Guzzleには標準でMockHandlerなるテスト用のハンドラが付属します。
http://docs.guzzlephp.org/en/latest/testing.html#mock-handler

公式からコピーしてきた内容
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Exception\RequestException;

// Create a mock and queue two responses.
$mock = new MockHandler([
    new Response(200, ['X-Foo' => 'Bar']),
    new Response(202, ['Content-Length' => 0]),
    new RequestException("Error Communicating with Server", new Request('GET', 'test'))
]);

$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);

// The first request is intercepted with the first response.
echo $client->request('GET', '/')->getStatusCode();
//> 200
// The second request is intercepted with the second response.
echo $client->request('GET', '/')->getStatusCode();
//> 202

こんな風にレスポンスオブジェクトのリストを渡すと、上から順に設定済みのレスポンスが返ってくるようになります。
あとはこの$clientに差し替えて(DIとかあるよね当然)、テストを行えばよいだけ。

リアルにモックAPIを作るのと何が違うの?

適当な品質でよければ、リアルなモックAPIを作るのは難しいことではありません。今ならphp -Sやnodejsやgolangがありますよね。じゃあGuzzleの機構を使ってモックすることに何のメリットがあるのでしょうか?

pros

  • 実行がとにかく速い(同一プロセス中で完結してるから当たり前)
  • モックサーバーの起動方法とか、ポート番号とか考えなくていい
  • 開発コストは低い
  • GuzzleのMiddlewareを全部有効にした状態でテストできる

cons

  • Guzzle専用
    • 直でcurl使ってる箇所があるとか、file_get_contents使ってる箇所があってもそこには使えない
  • 所詮PHPなので、ストリームの扱いは結構大変(じわーっと流れてくる様を再現するのとか)

MockHandlerを自作する

ところで、Guzzle公式のMockHandlerは使いにくいと思いませんか?
リストから順にレスポンスを返すことしかできないので、同じAPIを叩く部分が何回もあれば、その分何回もリストに積んでおかないといけません。

そこで、このハンドラ部分を自作してみようと思いました。ドキュメントは乏しいですが、書くべきコード量は少ないです。

Guzzleハンドラの概要

  • Guzzleのハンドラとは、callableなものであれば何でもよい。
    • しかし複雑になっていくので、__invokeメソッドを実装したクラスにするのがオススメ
  • 引数を2個とる。リクエストとその他のオプションの2つ。
  • GuzzleHttp\Promise\PromiseInterface を返す。
  • Promiseはfulfilledであればレスポンスオブジェクトを渡す。
  • 例外は投げず、何かエラーを伝えたい場合はRejectedPromiseに例外オブジェクトを包んでreturnする。
<?php

use Psr\Http\Message\RequestInterface;
use GuzzleHttp\Promise;
use GuzzleHttp\Psr7;

class MyMock
{
    public function __invoke(RequestInterface $req, array $options)
    {
        return new Promise\FulfilledPromise(
            new Psr7\Response(200, ['X-Header'=>'hoge'], 'body string')
        );
    }
}

例えば上記実装だと、どんなリクエストが来ようが常に200でbody stringを返す、という設定です。

使用する

\GuzzleHttp\HandlerStack::createの引数に渡せばいいだけ。

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;

$mock = new MyMock;

$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);

これで$clientはどんなリクエストをしようが200が返ってくる状態になりました。

作り込んでゆく

ハンドラはPSR-7のRequestInterfaceを受け取っています。これを解析して動的にレスポンスを出し分けるような物を作れば、どんどん本家のAPIの挙動に近づけることができます。

たとえば、pathを見て動的に作り分けるとか。

public function __invoke(RequestInterface $req, array $options)
{
    $path = $req->getUri()->getPath();
    switch ($path) {
        case '/foo':
            $body = 'foo!';
            break;
        case '/baa':
            $body = 'baa!';
            break;
        default:
            $body = '?!?!?!';
    }
    return new Promise\FulfilledPromise(
        new Psr7\Response(200, ['X-Header'=>'hoge'], $body)
    );
}

bodyをparseしてパラメータを取ってみたりとか。

public function __invoke(RequestInterface $req, array $options)
{
    //streamのままだと面倒なのでstringにキャスト(手抜き)
    $body = (string)$req->getBody();
    parse_str($body, $params);
    return new Promise\FulfilledPromise(
        new Psr7\Response(200, ['Content-Type'=>'application/json'], json_encode($params))
    );
}

さくさくと作り込んでいけます。
RequestInterfaceで何ができるかは、PSR-7で調べると良いでしょう。

PSR-7: HTTP message interfaces - PHP-FIG

他にも、MyMockクラスに適当なsetter + プロパティを用意して、外からレスポンスを設定できるようにする、みたいなこともできます。

エラーを発生させる

モックの便利なところは、リアルAPIだと若干再現が面倒な状況も簡単に作れることです。
500系の通信エラーだけでなく、コネクションエラーやSSL証明書エラーなど全てのエラーを返すことができます。

Guzzleは内部構造が完全にPromises/A+なので、エラーの通知は例外機構ではなくPromiseを使います。とは言っても、例外を投げてはいけないだけで、例外を作ることに問題はありません。

throwの代わりにreturn new RejectedPromise($e)とするだけ。

public function __invoke(RequestInterface $req, array $options)
{
    return new Promise\RejectedPromise(
        new \GuzzleHttp\Exception\ConnectException(
            'サーバーが落ちてるっぽい',
            $req
        )
    );
}

これなら常にConnectExceptionが発生します。状況に応じて作り分けるとかも自由です。

感想

GuzzleはHttpクライアントというより、ミドルウェアとハンドラからできたフレームワークのようです。
内部がPromiseなので、お作法に慣れるのにちょっと苦労しますが、慣れればよくできてるなと思います。