[Laravel] 機能テストで Guzzle インスタンスの通信をモックする

**【2021/10/15 追記】 現在の Laravel には組み込みの [HTTP クライアント](https://github.com/illuminate/http/tree/1ab63308617d5131eed84e3ace3b5fccc7c0317c/Client)が存在するので，それを使っておくのが一番テストしやすいとは思います。しかしフレームワークロックインは発生してしまうので，依然として疎結合に保ちたい部分では Guzzle を選択する意味はあると思います。**

[Guzzle]: https://github.com/guzzle/guzzle
[Mockery]: https://github.com/mockery/mockery

# はじめに

みなさん，テストにおいて [Guzzle] の通信が絡む部分ってどうしていますか？
ユニットテストと機能テストで手法が異なるので，自分が採用している方法をそれぞれ紹介します。

# 例

通信を Guzzle に委ね， JSON をパースしてオブジェクトを生成する処理は `JsonParser` というクラスに委ねているクライアントのテストを考えます。

```php:app/Services/Twitter/Client.php
<?php

namespace App\Services\Twitter;

use GuzzleHttp\ClientInterface as Guzzle;

class Client
{
    protected Guzzle $guzzle;
    protected JsonParser $parser;

    public function __construct(Guzzle $guzzle, JsonParser $parser)
    {
        $this->guzzle = $guzzle;
        $this->parser = $parser;
    }

    public function getHomeTimeline(): StatusCollection
    {
        $contents = $this->guzzle
            ->request('GET', 'statuses/home_timeline.json')
            ->getBody()
            ->getContents();

        return $this->parser
            ->asStatusCollection(json_decode($contents));
    }
}
```

```php:app/Providers/TwitterServiceProvider.php
<?php

namespace App\Providers;

use App\Services\Twitter\Client;
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\ClientInterface as GuzzleInterface;
use Illuminate\Support\ServiceProvider;

class TwitterServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app
            ->when(Client::class)
            ->needs(GuzzleInterface::class)
            ->give(Closure::fromCallable([$this, 'newGuzzleClient']));
    }

    protected function newGuzzleClient(): GuzzleInterface
    {
        // OAuth 認証は省略
        return new Guzzle([
            'base_uri' => $this->app['config']['services.twitter.base_uri'],
        ]);
    }
}
```

## ユニットテストにおけるモック

ユニットテストの場合は， [Mockery] などの汎用モッキングライブラリにまかせてしまうのが簡単です。

```php:tests/Unit/Services/Twitter/ClientTest.php
<?php

namespace Tests\Unit\Services\Twitter;

use App\Services\Twitter\Client;
use App\Services\Twitter\JsonParser;
use App\Services\Twitter\Status;
use App\Services\Twitter\StatusCollection;
use GuzzleHttp\ClientInterface as Guzzle;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class ClientTest extends TestCase
{
    /**
     * @var \Mockery\LegacyMockInterface|\Mockery\MockInterface|RequestInterface
     */
    protected MockInterface $psrRequest;

    /**
     * @var \Mockery\LegacyMockInterface|\Mockery\MockInterface|ResponseInterface
     */
    protected MockInterface $psrResponse;

    /**
     * @var Guzzle|\Mockery\LegacyMockInterface|\Mockery\MockInterface
     */
    protected MockInterface $guzzle;

    /**
     * @var JsonParser|\Mockery\LegacyMockInterface|\Mockery\MockInterface
     */
    protected MockInterface $parser;

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

        $this->psrRequest = Mockery::mock(RequestInterface::class);
        $this->psrResponse = Mockery::mock(ResponseInterface::class);
        $this->guzzle = Mockery::mock(Guzzle::class);
        $this->parser = Mockery::mock(JsonParser::class);
    }

    public function testGetHomeTimelineSuccess(): void
    {
        $client = new Client($this->guzzle, $this->parser);

        $this->guzzle
            ->shouldReceive('request')
            ->once()
            ->with('GET', 'statuses/home_timeline.json')
            ->andReturn($this->psrResponse);

        $this->psrResponse
            ->shouldReceive('getBody')
            ->once()
            ->andReturn('[{"text":"foo"},{"text":"bar"}]');

        $this->parser
            ->shouldReceive('asStatusCollection')
            ->once()
            ->with([['text' => 'foo'], ['text' => 'bar']])
            ->andReturn(new StatusCollection([
                new Status(['text' => 'foo']),
                new Status(['text' => 'bar']),
            ]));

        $statuses = $client->getHomeTimeline();

        $this->assertCount(2, $statuses);
        $this->assertSame('foo', $statuses[0]->text);
        $this->assertSame('bar', $statuses[1]->text);
    }
}
```

## 機能テストにおけるモック

問題はこっちですね。

大前提として，大部分のテストはユニットテストでカバーしておく必要があります。それでもサービスプロバイダにおける，**コンフィグから設定値を読み取って適切にサービスを初期化する**という部分はユニットテストではカバーできないので，それ以外の方法でカバーする必要があります。

基本的には実際にデプロイして人力でチェックするのが正しいです。しかし，可能な限り自動テストできる範囲はテストしたく感じる人もいるでしょう。ちょうど自分も最近 **「サービスプロバイダさえカバーできればテストカバレッジ100%なのに…」** と感じたところでした。

但し，外部サービスとの通信が絡む部分はどうしようもありません。 **「可能な限り実際に近く，でも Guzzle の通信だけはモックする」** を意識して対応する方法を考えてみました。

以下のようなヘルパートレイトを用意します。

```php:tests/Feature/MocksGuzzleClients.php
<?php

namespace Tests\Feature;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use ReflectionProperty;

trait MocksGuzzleClients
{
    /** @noinspection PhpDocMissingThrowsInspection */

    /**
     * @param  object      $container
     * @param  string      $property
     * @param  array       $queue
     * @param  null|array  &$transactions
     * @return MockHandler
     */
    protected function mockGuzzleOf(object $container, string $property, array $queue = [], &$transactions = null): MockHandler
    {
        /* @noinspection PhpUnhandledExceptionInspection */
        $reflection = new ReflectionProperty($container, $property);
        $reflection->setAccessible(true);

        return $this->mockGuzzle($reflection->getValue($container), $queue, $transactions);
    }

    /** @noinspection PhpDocMissingThrowsInspection */

    /**
     * @param  ClientInterface $client
     * @param  array           $queue
     * @param  null|array      &$transactions
     * @return MockHandler
     */
    protected function mockGuzzle(ClientInterface $client, array $queue = [], &$transactions = null): MockHandler
    {
        /* @noinspection PhpUnhandledExceptionInspection */
        $reflection = new ReflectionProperty($client, 'config');
        $reflection->setAccessible(true);

        $transactions = [];

        $handler = HandlerStack::create($mock = new MockHandler($queue));
        $handler->push(Middleware::history($transactions));
        $client->__construct(compact('handler') + $reflection->getValue($client));

        return $mock;
    }
}
```

リフレクションでスコープぶっ壊したり `__construct()` を後から呼び出したりトリッキーなことをやっていますが，これは Guzzle がイミュータブルを強く意識した設計になっているからです。インスタンスを作り直すとサービスプロバイダで作ったものをテストできないので，そのままモック用の **`handler`** オプションだけを後から無理矢理ねじ込む手法として考えたのがこれです。

これを使うと，以下のような機能テストが書けるでしょう。

```php
<?php

namespace Tests\Feature\Services\Twitter;

use App\Services\Twitter\Client;
use Illuminate\Foundation\Testing\TestCase;
use Tests\Feature\MocksGuzzleClients;

class ClientTest extends TestCase
{
    use MocksGuzzleClients;

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

        $this->app['config']['services.twitter.base_uri'] = 'https://api.twitter.com/v1.1/';
    }

    public function testGetHomeTimeline(): void
    {
        $client = $this->app->make(Client::class);

        $this->mockGuzzleOf($client, 'guzzle', [
            new Response(200, ['Content-Type' => 'application/json'], json_encode([
                ['text' => 'foo'],
                ['text' => 'bar'],
            ])),
        ], $transactions);

        $statuses = $client->getHomeTimeline();

        $this->assertCount(2, $statuses);
        $this->assertSame('foo', $statuses[0]->text);
        $this->assertSame('bar', $statuses[1]->text);

        // より丁寧にやるならここまで↓

        $this->assertCount(1, $transactions);

        $this->assertSame('GET', $transactions[0]['request']->getMethod());
        $this->assertSame('https://api.twitter.com/v1.1/statuses/home_timeline.json', (string)$transactions[0]['request']->getUri());
    }
}
```

# 注意点

- 最初にも示しましたとおり，こういったテストはやるにしても最後の仕上げに行うべきであって，大部分はユニットテストでカバーすることを意識しましょう。
- **「環境変数の設定ミス」「環境変数を読み取って `config` に落とすコードの設定ミス」** まではカバーできません。こればかりは人力チェック以外の確認手段がありませんので気をつけましょう。逆に言えば，そういう部分に変更がある場合は，ほとんどそこだけに人力チェックのリソースを避けば高い信頼性が得られそうです。