🤡

Laravel7.x 以前で Http ファサードを用いた複数のリクエストを順序を含めて検証する

2022/05/14に公開約12,600字

TL;DR

  • Http ファサードを使った通信は,Http::fake() でモックできる
  • 通信が1回の場合は,Http::assertSent() を使ってリクエストの検証ができるが,通信が複数回発生する場合に対応できない
    • Laravel8 以降は,Http::assertSentInOrder() が使える
  • Http::recorded(fn() => true)$this->recorded のゲッターとして使うことで通信の記録を全て取得できるので,テスト側で個別に検証ができる
  • 困ったら Laravel の実装を読もう!

はじめに

Laravel で API を実装するとき,外部の API からデータを取得したり,サードパーティ製のチャットサービス(Slack など)にデータを POST したりするときに,HTTP クライアントライブラリを用いることが多いと思います.
その代表例が Guzzle ですが,Laravel にはそれをラップする Http ファサード があります.

https://readouble.com/laravel/8.x/ja/http-client.html

Http ファサードの一番シンプルな例を示します.

use Illuminate\Support\Facades\Http;

$response = Http::get('http://example.com');

このように,Http::get()Http::post() を使うことで,とても簡単に任意の API と通信することができ,Illuminate\Http\Client\Response インスタンスとして結果が得られるお手軽なものです.

テストを書く際,メソッドを個々にモックするのではなく,特定エンドポイントへのリクエストに対するレスポンスをモックし,実際にどのようなリクエストを送ろうとしているのかを検証したい場合があります.
Http ファサードには fake() メソッドがあり,Http ファサードを使った通信をテスト時にモックすることができます.

また,fake() メソッドの使用とは関係なく,Http ファサードを通して行われた通信は Http::assertSent() を使ってリクエストやレスポンスの検証を行うことができます.
ただし,Http::assertSent() は一つのテストケースの中で実行される通信が複数ある場合,通信結果の検証が少々難しいです.
本記事では,通信が複数回行われるケースのテスト方法を Laravel 本体のソースコードリーディングを織り交ぜながら 紹介します.

通信が一回のとき

このような API を考えます

App/Http/Controllers/DogController.php
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\JsonResponse;

class DogController extends Controller
{
    public function fetchRandomImage(): JsonResponse
    {
        // ランダムに犬の画像を返してくれる API
        $dogResponse = Http::get('https://dog.ceo/api/breeds/image/random');

        // 成功した場合以下のような結果が返ってくる
        // {
        //     "message": "https://images.dog.ceo/breeds/clumber/n02101556_4213.jpg",
        //     "status": "success"
        // }

        if ($dogResponse->failed()) {
            return new JsonResponse([
                'message' => '犬の画像を取得できませんでした',
            ], 404);
        }

        return new JsonResponse([
            'image_url' => $dogResponse['message'],
        ], 200);
    }
}

では,このエンドポイントの機能テストを書いてみます.

今回テストしたいケースは次の通りです,とりあえず正常系だけ.

  • Dog API に正しくリクエストを送っている
  • リクエストが成功した場合,画像 URL とステータスコード 200 を返す
tests/Feature/DogControllerTest.php
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class DogControllerTest extends TestCase
{
    /**
     * @test
     */
    public function 正常系_犬の画像URLを取得(): void
    {
        // Dog API に対してのリクエストを全て fake する
        Http::fake([
            'https://dog.ceo/api/breeds/image/random' => Http::response([
                'message' => 'https://example.com/images/dog/1.png',
                'status' => 'success',
            ], 200),
        ]);

        // テスト対象のエンドポイントに対してリクエストを送る(実行)
        $response = $this->get('api/dog/random_image');

        // Dog API へのリクエストの検証
        Http::assertSent(function (Request $request) {
            $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url());
            return true;
        });

        // テスト対象のエンドポイントからのレスポンスの検証
        $response->assertStatus(200)
            ->assertJsonFragment([
                'image_url' => 'https://example.com/images/dog/1.png',
            ]);
    }
}

今回のように,Http ファサードを使った外部 API との通信が1回のときは,Http::assertSent() を使ってリクエストの検証を行うことができます.
これは,冒頭で示したマニュアルにも書いてありますので,簡単に実装できるかと思います.

通信が複数回のとき

では,次に Controller 内で複数回 Http 通信が発生するパターンについて考えてみます.

App/Http/Controllers/AnimalController.php
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\JsonResponse;

class AnimalController extends Controller
{
    public function fetchRandomImages(): JsonResponse
    {
        // ランダムに犬の画像を返してくれる API
        $dogResponse = Http::get('https://dog.ceo/api/breeds/image/random');

        // 成功した場合以下のような結果が返ってくる
        // {
        //     "message": "https://images.dog.ceo/breeds/clumber/n02101556_4213.jpg",
        //     "status": "success"
        // }

        if ($dogResponse->failed()) {
            return new JsonResponse([
                'message' => '犬の画像を取得できませんでした',
            ], 404);
        }

        // ランダムに猫の画像を返してくれる API
        $catResponse = Http::get('https://aws.random.cat/meow');

        // 成功した場合以下のような結果が返ってくる
        // {
        //     "file": "https://purr.objects-us-east-1.dream.io/i/GU8Gc.jpg",
        // }

        if ($catResponse->failed()) {
            return new JsonResponse([
                'message' => '猫の画像を取得できませんでした',
            ], 404);
        }

        // ランダムに狐の画像を返してくれる API
        $foxResponse = Http::get('https://randomfox.ca/floof/');

        // 成功した場合以下のような結果が返ってくる
        // {
        //    "image": "https://randomfox.ca/images/98.jpg",
        //    "link": "https://randomfox.ca/?i=98"
        // }

        if ($foxResponse->failed()) {
            return new JsonResponse([
                'message' => '狐の画像を取得できませんでした',
            ], 404);
        }

        return new JsonResponse([
            'dog_image_url' => $dogResponse['message'],
            'cat_image_url' => $catResponse['file'],
            'fox_image_url' => $foxResponse['image'],
        ], 200);
    }
}

今回は 3 回,それぞれ違う API に対してリクエストを送っています.
この場合のテストについて考えていきます.
まずは準備として,Http::fake() を使って通信のモックを作り,該当エンドポイントにリクエストを送るところまで書いてみます.

tests/Feature/AnimalControllerTest.php
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class AnimalControllerTest extends TestCase
{
    /**
     * @test
     */
    public function 正常系_動物の画像URLを取得(): void
    {
        // それぞれの API に対してのリクエストを全て fake する
        Http::fake([
            'https://dog.ceo/api/breeds/image/random' => Http::response([
                'message' => 'https://example.com/images/dog/1.png',
                'status' => 'success',
            ], 200),
            'https://aws.random.cat/meow' => Http::response([
                'file' => 'https://example.com/images/cat/1.png',
            ], 200),
            'https://randomfox.ca/floof/' => Http::response([
                'image' => 'https://example.com/images/fox/1.png',
                'link' => 'https://example.com/images/fox?id=1',
            ], 200),
        ]);

        // テスト対象のエンドポイントに対してリクエストを送る(実行)
        $response = $this->get('api/animal/random_images');
    }
}

さて,困るのはここからです.
テスト対象の Controller では,犬の API -> 猫の API -> 狐の API という順序で通信を行っているので,この順序の保証も含めてテストしたいですよね.
外部 API との通信が1回のときは,Http::assertSent() を使ってリクエストの検証を行うことができましたが,今回はどうでしょうか?

// Dog API へのリクエストの検証
Http::assertSent(function (Request $request) {
    $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url());
    return true;
});

// Cat API へのリクエストの検証
Http::assertSent(function (Request $request) {
    $this->assertSame('https://aws.random.cat/meow', $request->url());
    return true;
});

// Fox API へのリクエストの検証
Http::assertSent(function (Request $request) {
    $this->assertSame('https://randomfox.ca/floof/', $request->url());
    return true;
});

こんな風に,各通信毎に Http::assertSent() が書けたらいいですよね...
しかし,残念ながらこのように書くことはできません.

これを実行すると以下のような結果になります

at tests/Feature/AnimalControllerTest.php:38
     34▕         $response = $this->get('api/animal/random_images');
     35▕
     36▕         // Dog API へのリクエストの検証
     37▕         Http::assertSent(function (Request $request) {
  ➜  38▕             $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url());
     39▕             return true;
     40▕         });
     41▕
     42▕         // Cat API へのリクエストの検証

      +1 vendor frames
  2   [internal]:0
      Illuminate\Http\Client\Factory::Illuminate\Http\Client\{closure}()

      +5 vendor frames
  8   tests/Feature/SampleControllerTest.php:40
      Illuminate\Support\Facades\Facade::__callStatic()
  --- Expected
  +++ Actual
  @@ @@
  -'https://dog.ceo/api/breeds/image/random'
  +'https://aws.random.cat/meow'

Laravel8 以降では

じつは,Laravel8 以降,Http::assertSentInOrder() というメソッドが追加され,複数回の通信に対応しました.

https://github.com/laravel/framework/commit/af6ba0adc9316531b5d2db1a610a6db3f27405e4
しかしながら,中には Laravel7.x 以前のバージョンを使っている場合もあると思いますので,その場合のテスト方法について紹介します.

Http::assertSent() の実装を紐解く

なぜ上記の書き方だと複数回の通信をそれぞれ検証することができないのでしょうか?
ここでは実際に Http ファサードの中身を覗いてみます.
Http ファサードの基底クラスは Illuminate\Http\Client\Factory ですので,コチラのソースコードを読んでいきます.

https://github.com/laravel/framework/blob/7.x/src/Illuminate/Http/Client/Factory.php

Http::assertSent()

/**
 * Assert that a request / response pair was recorded matching a given truth test.
 *
 * @param  callable  $callback
 * @return void
 */
public function assertSent($callback)
{
    PHPUnit::assertTrue(
        $this->recorded($callback)->count() > 0,
        'An expected request was not recorded.'
    );
}

これだけ見てもよく分かりませんが,$this->recorded() にヒントがありそうですね.
ちなみに,今回のテストでこの $callback に渡されているのは

function (Request $request) {
    $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url());
    return true;
}

などです.

Http::recorded()

/**
 * Get a collection of the request / response pairs matching the given truth test.
 *
 * @param  callable  $callback
 * @return \Illuminate\Support\Collection
 */
public function recorded($callback = null)
{
    if (empty($this->recorded)) {
        return collect();
    }

    $callback = $callback ?: function () {
        return true;
    };

    return collect($this->recorded)->filter(function ($pair) use ($callback) {
        return $callback($pair[0], $pair[1]);
    });
}

ここに登場する $this->recordedIlluminate\Http\Client\Factory のプロパティで,レスポンスを記録している配列のようです.
Http::get() などで送ったリクエストに対するレスポンスは,この配列に順番に格納されていくのでしょう.

今回は,3回分の通信が記録されているはずなので最初の if 文は素通り,そしてクロージャも渡しているのでエルビス演算子も素通りします.
結果,このメソッド内で考えるべきは

collect($this->recorded)->filter(function ($pair) use ($callback) {
    return $callback($pair[0], $pair[1]);
});

だけということになります.

collect($this->recorded) でレスポンスを記録している配列を Collection に変換したあと,filter() を使ってフィルタリングしていますね.
filter() に渡されているクロージャの引数 $pair は,$this->recorded の各要素ですが,$callback($pair[0], $pair[1]) としてるあたり,これもまた配列のようですね.

Get a collection of the request / response pairs matching the given truth test.

とあるように,$this->recorded にはリクエストとレスポンスがペアで記録されているようです.
$callback は前述の通り url のアサーションを行っているメソッドですので,それらのアサーションが通るような リクエスト/レスポンス のペアのみを要素に持つコレクションを返す というのが Http::recorded() の動作になります.

したがって,Http::assertSent() に戻ると,クロージャで渡したアサーションが通るような リクエスト/レスポンス の組が 1 つ以上あるかどうか をチェックしているわけですね.
ここまで分かれば簡単です.Http::assertSent() を何度書いても,記録された全てのリクエスト/レスポンス のペアが毎回検証されるわけですので,意味がありません.

また,

  --- Expected
  +++ Actual
  @@ @@
  -'https://dog.ceo/api/breeds/image/random'
  +'https://aws.random.cat/meow'

のような落ち方をしたのは,$callback($pair[0], $pair[1]) でペアを全て検証する中で,必ず $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url()) に引っかかるためです.
filter() のループの1周目では Dog API に対する通信が記録が検証されるためこのアサーションはクリアしますが,2周目で Cat API に対する通信が検証されるため,そのタイミングでテストが落ちています.

どのようにして各レスポンスを検証するか?

今回 Laravel のソースコードリーディングを記事に含めたのは,ソースコードリーディングで見つけたメソッドを活用するためです.
マニュアルには恐らく載っていないメソッドですが,public メソッドなら外から使えますので,この問題が解決できます.

先にコードを示します.

Http::recorded(fn() => true)->each(function (array $record, int $index) {
    list($request, $response) = $record; // リクエストとレスポンスを取り出す

    // 型を確定させるためにアサーション
    assert($request instanceof Request);
    assert($response instanceof Response);

    // 1 回目のリクエスト
    if ($index === 0) {
        $this->assertSame('https://dog.ceo/api/breeds/image/random', $request->url());
    }
    // 2 回目のリクエスト
    elseif ($index === 1) {
        $this->assertSame('https://aws.random.cat/meow', $request->url());
    }
    // 3 回目のリクエスト
    elseif ($index === 2) {
        $this->assertSame('https://randomfox.ca/floof/', $request->url());
    }
});

Laravel のソースコードリーディングで見つけた,Http::recorded($callback) は,$callback が常に true を返せば全ての $this->recorded が Collection で取得できます.
したがってテスト側では,Http::recorded()$this->recorded のゲッターの変わりとして用いて全ての通信の記録を取得した上で,順番どおりに通信を検証していけば良いです.

あんまり綺麗な実装とは言えませんが,こうすることで,通信の順番まで保証されたテスト を書くことができます.

まとめ

  • Http ファサードを使った通信は,Http::fake() でモックできる
  • 通信が1回の場合は,Http::assertSent() を使ってリクエストの検証ができるが,通信が複数回発生する場合に対応できない
    • Laravel8 以降は,Http::assertSentInOrder() が使える
  • Http::recorded(fn() => true)$this->recorded のゲッターとして使うことで通信の記録を全て取得できるので,テスト側で個別に検証ができる
  • 困ったら Laravel の実装を読もう!
GitHubで編集を提案

Discussion

ログインするとコメントできます