Laravel / PHP ·

Http::fakeで外部APIの障害を再現する — タイムアウト・5xx・遅延のテスト

  • Laravel
  • テスト
  • 外部API連携

この記事で扱う問題

外部 API のタイムアウトとリトライの記事では、配送業者の API を呼ぶ HttpShippingGateway が失敗を3つの例外に分類し、RequestShipmentJob がその例外ごとにリトライするか・待つか・あきらめるかを決める形にしました。設計の判断の大部分は、成功したときではなく、失敗したときの振る舞いにあります。

ところが、外部 API を呼ぶコードのテストは、例えば次のような状態になりがちです。

  • Http::fake() で 200 を返させた正常系のテストだけがある。タイムアウトや 503 のときに何が起きるかは、本番で初めて分かる
  • 誰かが Gateway に ->retry(3) を足したり、409 の扱いを「拒否」に変えたりしても、どのテストも落ちない。前の記事で「リトライする層は1つ」と決めた判断が、黙って崩れる
  • 偽の応答を設定し忘れた URL があり、テストから相手のサンドボックス(または本番)に実際にリクエストが飛んでいる
  • 遅延を再現しようとして、偽の応答の中で sleep() している。テストが遅くなるうえ、後で書くとおり、それではタイムアウトは起きない

外部 API の障害は、待っていても都合よく起きてはくれません。だからこそ、テストの中で意図して起こし、そのときの振る舞いを書き残しておく必要があります。

判断

この記事のテストは Laravel 11 以降を前提にしています(ジョブのテストで withFakeQueueInteractions() を使うため)。また、テストの環境では、配送業者の API の URL を https://shipping.example.test にしている前提です(phpunit.xml の環境変数で指定し、サービスプロバイダが config() から HttpShippingGateway に渡します)。

偽の応答のないリクエストは、テスト全体で止める

まず、テストの基底クラスで Http::preventStrayRequests() を呼びます。

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Http;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Http::fake() で偽の応答を決めていない URL に送ろうとしたら、例外でテストを落とす
        Http::preventStrayRequests();
    }
}

これで、偽の応答の URL のパターンを書き間違えたときも、実際の API に届く前にテストが落ちます。逆に、すべての URL に 200 を返す Http::fake()(引数なし)は、基底クラスには置きません。どの外部 API がどう応答したかを、テストごとに書かせるためです。

障害は「相手の応答」として作る

Http::fake() で作る偽の応答は、障害の種類ごとに次のように書き分けます。

再現したい障害 偽の応答の作り方
エラー応答(4xx・5xx) Http::response(null, 503)
混み合っている(429) Http::response(null, 429, ['Retry-After' => '120'])
接続できない・タイムアウト Http::failedConnection()
1回目は失敗し、2回目は成功する Http::sequence()->pushStatus(503)->push([...], 201)
200 なのに本文の形が違う Http::response(['unexpected' => true])(項目がない)、Http::response(['fee' => '800円'])(型が違う)
遅延 再現しない。タイムアウトの指定を確かめ、遅延の結果(タイムアウト)は Http::failedConnection() で作る

最後の行が、この記事で一番伝えたい点です。Http::fake() の偽の応答は、ネットワークを通らずに返ります。タイムアウトを効かせているのは実際の通信の層なので、偽の応答の中で sleep(10) しても、timeout(5) は発動しません。テストが10秒遅くなるだけです。

遅延について確かめたいことは、2つに分けられます。1つは「タイムアウトが指定されているか」で、これは送ったリクエストのオプションを見れば分かります。もう1つは「タイムアウトしたら何が起きるか」で、これは Http::failedConnection() で再現します。偽の応答として、Guzzle の ConnectException(接続の失敗やタイムアウトのときに Guzzle が投げる例外)を返すもので、本番のタイムアウトと同じ経路で Laravel の ConnectionException に変換されます。どちらも待つ必要はありません。

偽の応答のクロージャから、Laravel の ConnectionException を直接投げる書き方もよく見かけます。Gateway に届く例外は同じですが、本番と経路が違います。送ったリクエストが記録されず、Http::assertSent() や Http::assertSentCount() は0件と判断します。ConnectionFailed のイベントも発火しないので、そのイベントでログや通知を出していても、テストでは動きません。

Http::failedConnection() は Laravel 11.x の途中で追加されたものです。使えないバージョンでは、Guzzle の ConnectException で reject した promise を返せば、同じ経路を通ります。

use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Promise\Create;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

Http::fake([
    'shipping.example.test/*' => fn (Request $request) => Create::rejectionFor(
        new ConnectException('cURL error 28: Operation timed out', $request->toPsrRequest()),
    ),
]);

ジョブ:失敗の種類ごとに、リトライするかを確かめる

出荷の依頼で守りたいのは、「どの失敗ならキューにリトライさせ、どの失敗ならあきらめるか」です。前の記事の分類表を、そのままデータプロバイダにします。

Gateway をモックに差し替えず、実物の HttpShippingGateway をコンテナから取り出し、HTTP の応答だけを偽物にします。HttpShippingGateway はコンテナの Factory をコンストラクタで受け取っているので、Http::fake() が効きます。Gateway の中で new Factory() のように自分で作っていると、偽の応答は効きません。

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\DataProvider;
use Throwable;

final class RequestShipmentJobTest extends TestCase
{
    private const ORDER_ID = '01jaxq3k8w5n2m4p6r7s9t0v1x';

    /** @return iterable<string, array{int}> */
    public static function rejectedStatuses(): iterable
    {
        yield '400 Bad Request' => [400];
        yield '401 Unauthorized' => [401];
        yield '422 Unprocessable Content' => [422];
    }

    /** @return iterable<string, array{int}> */
    public static function temporaryStatuses(): iterable
    {
        yield '408 Request Timeout' => [408];
        yield '409 同じキーを処理中' => [409];
        yield '500 Internal Server Error' => [500];
        yield '503 Service Unavailable' => [503];
    }

    #[DataProvider('rejectedStatuses')]
    public function test_拒否された依頼はリトライを使い切らずに失敗にする(int $status): void
    {
        Http::fake(['shipping.example.test/*' => Http::response(null, $status)]);

        $job = $this->runJob();

        $job->assertFailed();
        Http::assertSentCount(1);
    }

    #[DataProvider('temporaryStatuses')]
    public function test_一時的な失敗は例外のまま投げてキューにリトライさせる(int $status): void
    {
        Http::fake(['shipping.example.test/*' => Http::response(null, $status)]);

        $job = $this->runJob(expect: ShippingTemporarilyUnavailable::class);

        $job->assertNotFailed();
        $job->assertNotReleased();   // 間隔は backoff() に任せ、ジョブの中では release() しない
        Http::assertSentCount(1);    // Gateway の中ではリトライしない
    }

    public function test_タイムアウトは結果が分からない失敗としてリトライさせる(): void
    {
        Http::fake(['shipping.example.test/*' => Http::failedConnection()]);

        $job = $this->runJob(expect: ShippingTemporarilyUnavailable::class);

        $job->assertNotFailed();
        Http::assertSentCount(1);
    }

    public function test_429なら相手が指定した秒数だけ待ってやり直す(): void
    {
        Http::fake(['shipping.example.test/*' => Http::response(null, 429, ['Retry-After' => '120'])]);

        $this->runJob()->assertReleased(delay: 120);
    }

    public function test_一時的な失敗のあとのやり直しでも同じキーで依頼する(): void
    {
        Http::fake([
            'shipping.example.test/*' => Http::sequence()
                ->pushStatus(503)
                ->push(['shipment_id' => 'shp_0001'], 201),
        ]);

        $this->runJob(expect: ShippingTemporarilyUnavailable::class);   // 1回目:キューがリトライする
        $this->runJob()->assertNotFailed();                              // 2回目:成功する

        Http::assertSentCount(2);
        Http::assertNotSent(
            fn (Request $request): bool => $request->header('Idempotency-Key') !== ['shipment-request:' . self::ORDER_ID],
        );
    }

    /** @param class-string<Throwable>|null $expect ジョブから投げられるはずの例外 */
    private function runJob(?string $expect = null): RequestShipmentJob
    {
        // キューのリトライでは、ペイロードから新しいインスタンスが作られる。それに合わせて毎回作る
        $job = (new RequestShipmentJob(self::ORDER_ID))->withFakeQueueInteractions();

        try {
            $job->handle($this->app->make(ShippingGateway::class));
        } catch (Throwable $e) {
            if ($expect === null || ! $e instanceof $expect) {
                throw $e;
            }

            return $job;
        }

        if ($expect !== null) {
            $this->fail("{$expect} が投げられなかった");
        }

        return $job;
    }
}

データプロバイダには、すべてのステータスコードを並べていません。分類表の行ごとに、振る舞いが変わる代表を置いています。404 や 502 を足しても、確かめていることは増えません。

一番大事なのは、最後のテストです。一時的な失敗の後のやり直しで、Idempotency Key が変わらないこと。キュージョブの冪等性で、キーをジョブの UUID ではなく「この注文の出荷依頼」から決めた判断は、このテストがなければ、誰かがキーの作り方を変えたときに黙って崩れます。二重出荷は、テストの中でしか安全に起こせません。

利用者が待っている処理:画面に何を返すかを確かめる

前の記事では、利用者が画面の前で待っている送料の見積もりは、キューに回さず、失敗したら「いまは表示できない」と返すことにしました。ここで確かめたいのは、Gateway の例外ではなく、利用者に返るレスポンスです。そのため、HTTP のリクエストから通す Feature Test にします。

例として、ShippingGateway に見積もりのメソッド quoteFee() を足します(requestShipment() と例外の定義は既存の記事のまま、Money と Currency は Value Object の記事の定義です)。

interface ShippingGateway
{
    // requestShipment() は既存の記事の定義のまま(省略)

    /**
     * @throws ShippingTemporarilyUnavailable 結果が分からない・一時的な失敗、または応答の形が想定と違う
     * @throws ShippingRateLimited            混み合っている
     * @throws ShippingRequestRejected        拒否された。リトライしても結果は変わらない
     */
    public function quoteFee(string $postalCode): Money;
}

HttpShippingGateway には、quoteFee() と、失敗の分類をまとめた throwIfFailed() を足します。分類を2つのメソッドに写すと、片方だけ 429 の扱いを変えたときに黙ってずれるので、1か所にまとめます。

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;

// HttpShippingGateway に足すメソッド。requestShipment() も、post() の後の分類を
// $this->throwIfFailed($response, temporaryStatuses: [408, 409]) に置き換える
public function quoteFee(string $postalCode): Money
{
    try {
        $response = $this->http
            ->baseUrl($this->baseUrl)
            ->withToken($this->token)
            ->connectTimeout(1)
            ->timeout(2)   // 利用者が画面の前で待つので、出荷の依頼(5秒)より短くする
            ->get('/fees', ['postal_code' => $postalCode]);
    } catch (ConnectionException $e) {
        throw new ShippingTemporarilyUnavailable($e->getMessage(), previous: $e);
    }

    // 409(同じキーを処理中)は Idempotency Key を送る依頼だけの意味なので、見積もりでは一時的な失敗に含めない
    $this->throwIfFailed($response, temporaryStatuses: [408]);

    $fee = $response->json('fee');

    if (! is_int($fee) || $fee < 0) {
        // 相手の仕様が変わった可能性がある。表示はあきらめ、ログでは目立たせる(ログは省略)
        throw new ShippingTemporarilyUnavailable('shipping api returned an unexpected body');
    }

    return new Money($fee, Currency::JPY);
}

/** @param list<int> $temporaryStatuses 5xx のほかに、一時的な失敗として扱うステータス */
private function throwIfFailed(Response $response, array $temporaryStatuses): void
{
    if ($response->successful()) {
        return;
    }

    if ($response->status() === 429) {
        throw new ShippingRateLimited($this->retryAfterSeconds($response));
    }

    if (in_array($response->status(), $temporaryStatuses, true) || $response->serverError()) {
        throw new ShippingTemporarilyUnavailable("shipping api returned {$response->status()}");
    }

    throw new ShippingRequestRejected("shipping api rejected: {$response->status()}");
}

Controller は、一時的な失敗と混み合っているときだけ「いまは表示できない」に変えます。拒否(4xx)は、郵便番号の形式を FormRequest で確かめた後なので、認証の設定の誤りなど、こちら側の問題として 500 のまま記録します。

ここでは UseCase を挟まず、Controller から ShippingGateway を呼んでいます。見積もりは相手の値を読んで表示するだけで、業務のルールも状態の変更もないためです(読み取り専用の一覧を専用のクエリクラスから返すのと同じ考え方です)。Controller が知っているのは Interface と業務の意味の例外だけで、HTTP の実装は知りません。見積もりの結果で注文の内容を変えるなど、ルールが絡んできたら UseCase に移します。

use Illuminate\Http\JsonResponse;

final class ShowShippingFeeController
{
    public function __construct(private ShippingGateway $shipping)
    {
    }

    public function __invoke(ShowShippingFeeRequest $request): JsonResponse
    {
        try {
            $fee = $this->shipping->quoteFee($request->validated('postal_code'));
        } catch (ShippingTemporarilyUnavailable | ShippingRateLimited) {
            // 待てない処理なのでリトライしない。送料は注文の確定時に確かめ直す
            return response()->json(['fee' => null]);
        }

        return response()->json(['fee' => $fee->amount]);
    }
}

ShowShippingFeeRequest は、postal_code を ['required', 'regex:/\A\d{7}\z/'] で確かめるだけの FormRequest です(省略)。テストは次のとおりです。

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\DataProvider;

final class ShowShippingFeeTest extends TestCase
{
    /** @return iterable<string, array{array<string, mixed>}> */
    public static function unexpectedBodies(): iterable
    {
        yield '項目がない' => [['unexpected' => true]];
        yield '型が違う' => [['fee' => '800円']];
    }

    public function test_配送業者のAPIがタイムアウトしたら送料を空にして200を返す(): void
    {
        Http::fake(['shipping.example.test/*' => Http::failedConnection()]);

        $this->getJson('/api/shipping-fee?postal_code=1000001')
            ->assertOk()
            ->assertExactJson(['fee' => null]);

        Http::assertSentCount(1);   // 利用者を待たせている間に、やり直さない
    }

    public function test_配送業者のAPIが混み合っていたら送料を空にして200を返す(): void
    {
        Http::fake(['shipping.example.test/*' => Http::response(null, 429, ['Retry-After' => '30'])]);

        $this->getJson('/api/shipping-fee?postal_code=1000001')
            ->assertOk()
            ->assertExactJson(['fee' => null]);
    }

    public function test_配送業者のAPIに拒否されたらこちらの問題として500を返す(): void
    {
        Http::fake(['shipping.example.test/*' => Http::response(null, 401)]);

        $this->getJson('/api/shipping-fee?postal_code=1000001')
            ->assertInternalServerError();
    }

    /** @param array<string, mixed> $body */
    #[DataProvider('unexpectedBodies')]
    public function test_配送業者のAPIが形の違う応答を返したら送料を空にして200を返す(array $body): void
    {
        Http::fake(['shipping.example.test/*' => Http::response($body)]);

        $this->getJson('/api/shipping-fee?postal_code=1000001')
            ->assertOk()
            ->assertExactJson(['fee' => null]);
    }

    public function test_送料の見積もりは接続1秒・全体2秒で打ち切る(): void
    {
        $sentOptions = [];

        Http::fake(function (Request $request, array $options) use (&$sentOptions) {
            $sentOptions = $options;

            return Http::response(['fee' => 800]);
        });

        $this->getJson('/api/shipping-fee?postal_code=1000001')
            ->assertOk()
            ->assertExactJson(['fee' => 800]);

        $this->assertSame(1, $sentOptions['connect_timeout']);
        $this->assertSame(2, $sentOptions['timeout']);
    }
}

Http::fake() に渡したクロージャは、2つ目の引数で、送ろうとしたリクエストのオプションを受け取ります。最後のテストは、そこからタイムアウトの指定を読んでいます。

なぜそう判断したか

Gateway をモックに差し替えなかった理由

ジョブのテストで ShippingGateway をモックにし、「ShippingRateLimited を投げたら release() する」と確かめる書き方もあります。速く、HTTP のことを知らずに済みます。

ただ、それで確かめられるのは「例外 A なら振る舞い B」という、ジョブの中の対応だけです。守りたいのは「相手が 429 と Retry-After: 120 を返したら、120 秒待つ」という、外から見た約束です。分類が Gateway からジョブに移ったり、例外の名前が変わったりしても、この約束は変わりません。HTTP の応答だけを偽物にしておけば、内側を作り直してもテストは通り続けます。テストは将来壊してはいけないものを書き残すもので書いた、Feature Test を中心にする理由と同じです。

相手のサンドボックスを CI で呼ばなかった理由

配送業者がサンドボックスを用意していても、CI のテストからは呼びません。相手の都合で遅くなったり止まったりすると、自分の変更と関係なく CI が落ちます。何より、サンドボックスに「いま 503 を返して」とは頼めません。障害の再現は、偽の応答のほうが確実です。

その代わり、偽の応答は「相手がこう返すはずだ」という自分の想定でしかない、という限界があります。相手の仕様書と食い違っていれば、テストは通っても本番では違う振る舞いになります。409 の意味のように API ごとに違うものは、偽の応答の横に仕様書のどこを根拠にしたかを書き、本番のログで実際に返ってきた応答を見つけたら、それを偽の応答に足します。

sync のキューで、ディスパッチから通さなかった理由

phpunit.xml でキューを sync にしていれば、ジョブをディスパッチするだけで実行されます。ただ、sync はその場で1回実行するだけなので、リトライや待ち時間の振る舞いは確かめられません。ジョブのインスタンスを作って handle() を呼び、release() や fail() が呼ばれたかを確かめるほうが、確かめたいことに直接届きます。ディスパッチされるかどうかは、ディスパッチする側のテストで Queue::fake() を使って確かめます。

どこから設計過剰になるか

  • ステータスコードを網羅する:分類表の行ごとに代表が1つあれば足ります。データプロバイダを数十行にしても、確かめていることは増えず、読む人が「どれが大事か」を見失います
  • まれに呼ぶ社内向けの参照 API:前の記事のとおり、タイムアウトを指定して失敗をそのまま表示するだけなら、タイムアウトしたときの表示を確かめる1本で十分です
  • 偽の API サーバーを立てる:WireMock のような偽のサーバーをコンテナで立てる方法は、複数のサービスや言語で同じ偽物を共有したいときに検討します。1つの Laravel アプリのテストなら、Http::fake() で足ります
  • 送ったリクエストの中身をすべて確かめる:本文の項目を1つずつ照合すると、相手に影響しない並び替えでもテストが落ちます。確かめるのは、二重実行や課金に関わるもの(Idempotency Key、金額、宛先)に絞ります
  • すべての外部 API に、タイムアウトの指定のテストを書く:利用者を待たせる経路や、ワーカーを埋めると困る経路にだけ書きます。値を変える理由があるのに、テストが変更を邪魔するだけになっているなら、そのテストは消してかまいません

まとめ

  • Http::preventStrayRequests() で実際の通信を止め、障害は偽の応答として、エラー応答・Http::failedConnection()・順番に変わる応答で作る
  • 遅延は sleep() では再現できない。タイムアウトの指定はオプションで、タイムアウトの結果は Http::failedConnection() で確かめる
  • Gateway はモックにせず、HTTP の応答だけを偽物にして、「どの失敗をリトライするか」「利用者に何を返すか」という外から見た約束をテストに残す

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。