0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Http::fake()のテストはGreenなのに、本番で500になったLaravel HTTPクライアントの話

0
Posted at

背景

Laravelで「外部APIを呼び出し、成功/失敗に応じてDBのステータスカラムを更新する」という、よくある実装をしていました。

class TaskService
{
    public function create(array $data): Task
    {
        $task = Task::create($data);

        try {
            $this->routineTriggerService->trigger($task);
            $task->update(['status' => 'pending']);
        } catch (RequestException $e) {
            $task->update(['status' => 'error', 'error_message' => $e->getMessage()]);
        }

        return $task;
    }
}

RoutineTriggerService::trigger()の中身はシンプルです。

class RoutineTriggerService
{
    public function trigger(Task $task): void
    {
        Http::withToken(config('services.routine.token'))
            ->post(config('services.routine.url'), [
                'text' => "タスク#{$task->id}: {$task->title}",
            ])
            ->throw();
    }
}

テストも書いて、成功パターン・失敗パターンともにGreenでした。

public function test_失敗レスポンス時はステータスがerrorになる(): void
{
    Http::fake(['*' => Http::response([], 500)]);
    // ...
    $this->assertSame('error', $task->status);
}

「テストも通ったし、成功でも失敗でもちゃんと動く実装ができた」と思っていました。

何が問題だったか

このコードをレビューに出したところ、「RequestExceptionだけでは足りません」という指摘を受けました。

LaravelのHttpファサードは、外部APIの呼び出し失敗を大きく2種類の例外で表現します。

例外 いつ発生するか
Illuminate\Http\Client\RequestException レスポンスは返ってきたが4xx/5xxだった場合(->throw()が投げる)
Illuminate\Http\Client\ConnectionException そもそも接続できなかった場合(URL不正・タイムアウト・ホスト未起動など)

一見すると「レスポンスが返るケース」と「そもそも繋がらないケース」で全く別系統の失敗に見えます。実際、RequestExceptionとConnectionExceptionの間に継承関係はありません。

ですが、両者は共通の親クラスを継承しています。

namespace Illuminate\Http\Client;

class ConnectionException extends HttpClientException {}
namespace Illuminate\Http\Client;

class RequestException extends HttpClientException {}

catch (RequestException $e)だけを書いていた私のコードは、ConnectionExceptionを全く捕捉できていませんでした。今回のケースでは外部APIのURLが未設定(空文字)の状態がまさにこれに該当し、タスク作成のたびに接続エラーが発生 → キャッチされずコントローラまで伝播 → タスク作成のAPI自体が500エラーになるという実害のあるバグでした。

テストがGreenだったのは、Http::fake(['*' => Http::response([], 500)])という「レスポンスは返るが失敗する」パターンしか用意していなかったからです。接続そのものが失敗するケースを一度もテストしていなかったので、catch節が狭すぎることに気づけませんでした。

直し方

catchする型を、共通の親クラスに変えるだけです。

use Illuminate\Http\Client\HttpClientException;

try {
    $this->routineTriggerService->trigger($task);
    $task->update(['status' => 'pending']);
} catch (HttpClientException $e) {
    $task->update(['status' => 'error', 'error_message' => $e->getMessage()]);
}

そして、接続失敗のパターンもテストに追加します。Laravelには接続例外をシミュレートする書き方が用意されています。

public function test_接続失敗時もステータスがerrorになる(): void
{
    Http::fake(function () {
        throw new ConnectionException('Could not connect to the given host.');
    });

    // ... タスクを作成する

    $this->assertSame('error', $task->status);
}

このテストを追加してから実装を直すと、修正前は当然このテストだけRedになります。「レスポンスエラー系のテストはGreenなのに、接続エラー系だけRed」という状態を実際に見て、初めてcatch節の狭さが可視化できました。

まとめ

  • LaravelのHttpファサードの失敗はRequestException(レスポンスあり)とConnectionException(接続不可)の2系統あり、見た目は兄弟クラスのようで実は共通の親HttpClientExceptionを継承している
  • 「成功でも失敗でも後続処理を続けたい」設計では、RequestExceptionだけでなくHttpClientExceptionをcatchする
  • Http::fake(['*' => Http::response([], 500)])だけでは接続失敗系の分岐はテストできない。Http::fake(fn() => throw new ConnectionException(...))で接続失敗も再現し、両方の分岐をテストで担保する
  • 今回は実装した本人ではなく、コンテキストを持たない第三者視点のレビューで見つかったバグだった。「自分で書いたコードは自分では見落としに気づきにくい」という当たり前のことを、改めて実感した事例でもある

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?