【Laravel】AI呼び出しをテストでモックする(Http::fakeパターン)

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

LLM連携機能を実装したら、当然テストも書きたくなります。

ただし、テストのたびに実際のAI APIを叩いていると、実行するたびに課金が発生してしまいます。

今回はHttp::fake()を使って、AI API呼び出しを安全にモックする方法を解説します。

Http::fake()とは?

Http::fake()は、Laravelの標準的なテストパターンの1つです。

HTTP呼び出しをモックし、実際に外部APIへリクエストを送らずに固定レスポンスを返せます。

AI SDK呼び出し(OpenAIやAnthropicのSDK、あるいは自作のHTTPクライアント)も、内部的にはHTTPリクエストです。

そのため、通常の外部API連携のテストと同じアプローチで、AI呼び出しもモックできます。

実装手順

実際に一次情報として経験した課金トラブル

実は私がこのテーマを扱うきっかけになったのが、実際に起きた課金トラブルです。

LLM連携機能のテストを書いた際、Http::fake()を使わずに実装をそのままテストしてしまい、CIを回すたびに本物のOpenAI APIを叩いていました。

ある週、CIのpushが立て続けに走った結果、月末の請求で見慣れない金額のAPI利用料が発生していることに気づきました。

原因を調べると、フィーチャーブランチのpushごとにCIがテストを実行し、そのたびにチャットAPIへの本物のリクエストが飛んでいたことが判明しました。

  • ❌Before:AIクライアントのテストでHttp::fake()を使わず、CI実行のたびに本物のAPIへ課金リクエストが発生
  • ✅After:Http::fake()で固定レスポンスを返すようにし、CIでの実API呼び出しをゼロにした

この経験から、AI連携機能を実装したら「テストを書く前に必ずモックする」のを自分のルールにしています。

Http::fake()での基本的なモック実装

まず、実際にAI APIを呼び出すサービスクラスを用意します。

<?php

namespace App\Services\Ai;

use Illuminate\Support\Facades\Http;

class ChatCompletionClient
{
    public function complete(string $prompt): string
    {
        $response = Http::withToken(config('services.openai.key'))
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o-mini',
                'messages' => [
                    ['role' => 'user', 'content' => $prompt],
                ],
            ])
            ->throw();

        return $response->json('choices.0.message.content');
    }
}

このクラスをテストする際、Http::fake()で固定レスポンスを返すように設定します。

<?php

namespace Tests\Feature\Services\Ai;

use App\Services\Ai\ChatCompletionClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class ChatCompletionClientTest extends TestCase
{
    public function test_prompt_ni_taishite_kotae_wo_kaesu(): void
    {
        Http::fake([
            'api.openai.com/*' => Http::response([
                'choices' => [
                    ['message' => ['content' => 'これはテスト用の固定レスポンスです。']],
                ],
            ], 200),
        ]);

        $client = new ChatCompletionClient();
        $result = $client->complete('こんにちは');

        $this->assertSame('これはテスト用の固定レスポンスです。', $result);

        // 実際に正しいエンドポイント・パラメータで送信されたかも検証する
        Http::assertSent(function ($request) {
            return $request->url() === 'https://api.openai.com/v1/chat/completions'
                && $request['model'] === 'gpt-4o-mini';
        });
    }
}

Http::fake()を呼んだ時点で、テスト内のすべてのHTTPリクエストがインターセプトされ、実際の通信は発生しません。

エラーレスポンスのテスト

正常系だけでなく、APIエラー時の挙動もテストしておくと安心です。

public function test_api_ga_error_wo_kaeshita_baai_ni_reigai_ga_hassei_suru(): void
{
    Http::fake([
        'api.openai.com/*' => Http::response([
            'error' => ['message' => 'Rate limit exceeded'],
        ], 429),
    ]);

    $client = new ChatCompletionClient();

    $this->expectException(\Illuminate\Http\Client\RequestException::class);

    $client->complete('こんにちは');
}

->throw()をサービス側で呼んでいるため、429エラー時に例外が送出されることを確認できます。

よくあるつまずきポイント・エラー対処

Http::fake()を導入した直後、次のようなエラーに遭遇しました。

Illuminate\Http\Client\RequestException: cURL error 6:
Could not resolve host: api.openai.com

これは、Http::fake()をテストメソッド内で呼ぶ前に、サービスクラスのインスタンスを先に生成していたことが原因でした。

Http::fake()はグローバルなHTTPマクロを差し替える仕組みなので、呼び出し順序自体は本来問題にならないのですが、実際にはsetUp()で別のHTTPクライアントをキャッシュしてしまっていたことが根本原因でした。

  • ❌Before:setUp()内で事前にAPIクライアントをインスタンス化し、内部で保持したGuzzleクライアントがHttp::fake()の差し替え前の状態のままだった
  • ✅After:クライアントの生成をテストメソッド内、Http::fake()呼び出しの後に移動した

また、URLパターンのマッチが甘く、意図しないリクエストまでモックされてしまうケースもあります。

api.openai.com/*のようにワイルドカードを使う際は、他のテストで別のAPIを呼んでいないか確認しておくと安全です。

応用・一歩先の使い方

複数のAPIエンドポイントを同時にモックしたい場合は、配列のキーごとにパターンを分けます。

Http::fake([
    'api.openai.com/v1/chat/completions' => Http::response([...], 200),
    'api.openai.com/v1/embeddings' => Http::sequence()
        ->push(['data' => [['embedding' => array_fill(0, 1536, 0.1)]]])
        ->push(['data' => [['embedding' => array_fill(0, 1536, 0.2)]]]),
]);

Http::sequence()を使うと、同じエンドポイントへの連続呼び出しに対して異なるレスポンスを順番に返せます。

RAGパイプラインのように、embeddingを複数回呼び出す処理のテストで重宝します。

まとめ

この記事のポイント

  • AI SDK呼び出しも内部的にはHTTPリクエストなので、Http::fake()でモック可能
  • モックしないままCIを回すと、実行のたびに課金が発生する危険がある
  • Http::assertSent()でリクエスト内容そのものも検証できる
  • Http::sequence()で連続呼び出しに異なるレスポンスを返せる

次に読むべき記事

  • 次回:コスト管理とレート制限:トークン消費を抑える実装

コメント

タイトルとURLをコピーしました