🎻

[Symfony] 機能テストでGuzzleによるHTTPリクエストをモックする方法

2020/04/15に公開

Symfonyの機能テストでGuzzleをモックする方法を解説します。

プロダクションコードの例

以下のような例で考えてみましょう

サービス定義

services:
    GuzzleHttp\ClientInterface:
        class: GuzzleHttp\Client

コントローラ

public function someAction(GuzzleHttp\ClientInterface $client)
{
    // ...
    
    try {
        $response = $client->request('GET', $someUrl);
        $content = $response->getBody()->getContents();

        if ($content === 'expected') {
            // ...
        }
        
    } catch (GuzzleHttp\Exception\GuzzleException $e) {
        // ...
    }

    // ...

}

このコントローラをテストしたい場合、 GuzzleHttp\ClientInterface サービスの実体をモックしてあげる必要があります。

そうしないと、テストを実行するたびに実際に外部のサイトにリクエストしてしまい、テストの結果がそのサイトの状態に依存してしまいます。(そして単純にそのサイトに対して迷惑です)

テストコードからサービスコンテナの中身を入れ替える

まずはサービスをモックしないシンプルなテストを書いてみます。

public function testSomeAction()
{
    $client = static::createClient();

    // テスト対象のアクション
    $crawler = $client->request('GET', '/some_action');
    
    $this->assertResponseIsSuccessful();
}

これだと、 /some_action にアクセスするたびにGuzzleによるHTTPリクエストの処理が走ってしまいます。

これを防ぐために、 /some_action へのアクセスを行う前に、 Symfonyのサービスコンテナに登録されているサービスを動的に入れ替えてあげます。

具体的には、

public function testSomeAction()
{
    $client = static::createClient();

    // ... $mockGuzzleClient を作る

    $client->getContainer()->set('GuzzleHttp\ClientInterface', $mockGuzzleClient);

    $crawler = $client->request('GET', '/some_action');
    
    $this->assertResponseIsSuccessful();
}

こんな感じです。ただし、サービスがprivateな場合は、コンテナの get()set() で直接触れないので、サービス定義も変更しておく必要があります。

services:
    GuzzleHttp\ClientInterface:
        class: GuzzleHttp\Client
        public: true

サービスがprivateなままで置き換えようとすると、以下のようなエラーになります。

Symfony\Component\DependencyInjection\Exception\InvalidArgumentException: The "GuzzleHttp\ClientInterface" service is private, you cannot replace it.

Symfony3.4以降ではサービスはデフォルトでprivateになる ので、要注意です。

GuzzleのClientをMockHandlerを使ってモックする

さて、あとはGuzzleのClientのモックを作るだけです。

今回のプロダクションコードだと

$response = $client->request('GET', $someUrl);
$content = $response->getBody()->getContents();

if ($content === 'expected') {
    // ...
}

ぐらいしかしていないので、例えば以下のようなコードでもモックできなくはありません。

$client = static::createClient();

$mockBody = $this->prophesize(StreamInterface::class);
$mockBody->getContents(Argument::cetera())->willReturn('expected');

$mockResponse = $this->prophesize(ResponseInterface::class);
$mockResponse->getBody(Argument::cetera())->willReturn($mockBody->reveal());

$mockGuzzleClient = $this->prophesize(Client::class);
$mockGuzzleClient->request(Argument::cetera())->willReturn($mockResponse->reveal());

$client->getContainer()->set('GuzzleHttp\ClientInterface', $mockGuzzleClient->reveal());

Clientからメソッドチェーンで呼ばれるインスタンスを一つひとつ丁寧にモックしています。

一応これでもテストはできますが、ひたすらめんどくさいですね…😓

実はこんなことしなくても、Guzzleにちゃんとイイものが用意されています。

MockHandler という機能で、以下のようにインスタンス生成時に渡してあげると、事前に登録しておいたHTTPレスポンスを受け取ることができます。

$client = static::createClient();

$mock = new MockHandler([
    new Response(200, [], 'expected'),
]);

$handler = HandlerStack::create($mock);

$client->getContainer()->set('GuzzleHttp\ClientInterface', new Client(['handler' => $handler]));

ワンライナー化すると以下のような感じです。とてもスッキリ書けますね!

$client = static::createClient();

$client->getContainer()->set('GuzzleHttp\ClientInterface', new Client(['handler' => HandlerStack::create(new MockHandler([
    new Response(200, [], 'expected'),
]))]));

手軽なだけでなく、任意のレスポンスを簡単に受け取れるので柔軟でもあります。

ハマりポイント1:デフォルトだと $client->request() を実行するたびにコンテナが初期化される

例えば以下のようなテストを考えてみましょう。

プロダクションコード

public function someAction(GuzzleHttp\ClientInterface $client)
{
    try {
        $client->request('GET', '存在しないURL');
    } catch (GuzzleHttp\Exception\GuzzleException $e) {
        return new Response('NG');
    }

    return new Response('OK');
}

テストコード

public function testSomeAction()
{
    $client = static::createClient();

    $client->getContainer()->set('GuzzleHttp\ClientInterface', new Client(['handler' => HandlerStack::create(new MockHandler([
        new Response(200),
        new Response(200),
    ]))]));

    $crawler = $client->request('GET', '/some_action');
    $this->assertEquals('OK', $crawler->text());

    $crawler = $client->request('GET', '/some_action');
    $this->assertEquals('OK', $crawler->text()); // こっちだけが NG でエラーになる
}

MockHandlerで同じ200のレスポンスを2回分登録してあるにもかかわらず、2回目のほうだけがエラーになります。

テストコードを以下のように修正して、1回目の $client->request() をやめてみると、これはパスします。

public function testSomeAction()
{
    $client = static::createClient();

    $client->getContainer()->set('GuzzleHttp\ClientInterface', new Client(['handler' => HandlerStack::create(new MockHandler([
        new Response(200),
        new Response(200),
    ]))]));

//    $crawler = $client->request('GET', '/some_action');
//    $this->assertEquals('OK', $crawler->text());

    $crawler = $client->request('GET', '/some_action');
    $this->assertEquals('OK', $crawler->text()); // これならOKになる
}

つまり、 $client->request() を実行するたびにせっかくモックを登録したはずのコンテナが初期化されてしまって、 存在しないURL に実際にリクエストしようとしてGuzzleが例外を投げているのです。

こういう場合は、 $client->disableReboot(); を1度呼んでおくことが必要です。

public function testSomeAction()
{
    $client = static::createClient();
    $client->disableReboot(); // これを追加

    $client->getContainer()->set('GuzzleHttp\ClientInterface', new Client(['handler' => HandlerStack::create(new MockHandler([
        new Response(200),
        new Response(200),
    ]))]));

    $crawler = $client->request('GET', '/some_action');
    $this->assertEquals('OK', $crawler->text());

    $crawler = $client->request('GET', '/some_action');
    $this->assertEquals('OK', $crawler->text()); // 無事にパスする
}

なぜこんなことになるのかという原因は @polidog 先生が過去にまとめてくれているのでこちらを参照してください。

SymfonyのWebTestCaseでServiceContainerが再生成されてモックが使えなくなった
https://polidog.jp/2016/07/15/symfony_container_test/

ハマりポイント2:同じサービスを複数回入れ替えることはできない

コンテナ内の同じキーに対して複数回に渡って set() することはできません。

例えばこういうのはダメ

public function testSomeAction()
{
    $client = static::createClient();

    $client->getContainer()->set('some_service', new SomeService1());
    $client->request('GET', '/some_action');

    // ..
    
    $client->getContainer()->set('some_service', new SomeService2()); // ダメ
    $client->request('GET', '/some_action');

    // ...
}

このテストを走らせると以下のエラーが出ます。

Symfony\Component\DependencyInjection\Exception\InvalidArgumentException: The "some_service" service is already initialized, you cannot replace it.

このような場合は $client 自体を再生成する必要があります。

public function testSomeAction()
{
    $client = static::createClient();

    $client->getContainer()->set('some_service', new SomeService1());
    $client->request('GET', '/some_action');

    // ..

    $client = static::createClient(); // 再生成
    
    $client->getContainer()->set('some_service', new SomeService2()); // これなら大丈夫
    $client->request('GET', '/some_action');

    // ...
}

まとめ

  • Symfonyの機能テストでサービスをモックに入れ替えたい場合は、サービスをpublicで定義しておいた上で、テストコードから $client->getContainer()->set('サービス名', $mock); で出来る
  • ただしデフォルトでは $client->request() のたびにコンテナが初期化されてしまうので、モックを登録したコンテナを使い回したい場合は $client->disableReboot(); を呼んでおく必要がある
  • 同じコンテナに対して同じサービスを複数回入れ替えることはできないので、それがやりたい場合は $client 自体を作り直す必要がある
  • モックしたいサービスがGuzzleの場合は、Prophecyで頑張るのではなく MockHandler を使うと便利
GitHubで編集を提案

Discussion