> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# パッケージのHTTPクライアントとテスト

> Laravel 13の共有HTTP Factoryを使い、リクエストごとの状態分離、サービス登録、Http::fake()で検証できるAPIクライアントを設計します。

パッケージから外部APIを利用したいときは、**Laravelが管理するHTTPクライアントの設定を引き継ぐこと**と、**呼び出しごとの設定を持ち越さないこと**を両立させます。独自にFactoryを生成したり、送信前のリクエストを長期間保持したりすると、利用側のfakeが適用されない原因になります。

このページはLaravel Framework `v13.39.0` の実装を確認しています。基本操作は[HTTPクライアント](https://kawax.biz/jp/http-client.md)、検証環境の準備は[パッケージのテスト](https://kawax.biz/jp/advanced/package-testing.md)を参照してください。

## Factoryは共有し、PendingRequestは毎回作る

LaravelのHTTPクライアントには、異なる責任を持つオブジェクトがあります。

| オブジェクト | 役割 | パッケージでの扱い |
| - | - | - |
| `Illuminate\Http\Client\Factory` | fake、共通オプション、共通ミドルウェアなどを管理する | Laravelのコンテナから受け取る |
| `Illuminate\Http\Client\PendingRequest` | 送信先、ヘッダー、タイムアウトなど、送信するリクエストの設定を持つ | APIメソッドを呼ぶたびに生成する |
| `Illuminate\Http\Client\Response` | ステータスや本文を検査する | 通信結果として扱い、必要なら例外や戻り値へ変換する |

`FoundationServiceProvider` はFactoryをsingletonとして登録し、`Http` ファサードも同じクラス名を使ってコンテナから解決します。そのため、通常のLaravelアプリケーションでは、**コンテナから受け取ったFactoryに対する通信も `Http::fake()` で検証できます**。ファサードを直接使う設計も可能ですが、このページでは依存関係を明示するためFactoryを注入します。

Factoryに対する `baseUrl()` などの通常のリクエスト設定メソッドは、`__call()` から新しいPendingRequestへ転送されます。`createPendingRequest()` は生成時点のfake用コールバックとstray requestの制限を渡し、`newPendingRequest()` は共通ミドルウェアと共通オプションを適用します。

```mermaid theme={null}
flowchart LR
    A["Laravelのコンテナ"] --> B["共有Factory"]
    C["Http::fake()で応答を設定"] --> B
    B --> D["API呼び出しごとに<br>新しいPendingRequest"]
    D --> E["呼び出し固有の設定"]
    E --> F["送信とレスポンス検査"]
```

> [!WARNING]
>
> パッケージ内で `new Factory()` や別のHTTPライブラリのクライアントを生成しても、共有Factoryのfakeや `Http::preventStrayRequests()` は引き継がれません。これらはプロセス全体の通信を遮断する機能ではありません。Laravel向けクライアントでは共有Factoryを使い、独立したクライアントを提供する場合は、そのクライアント専用のテスト方法を用意してください。

## API操作を小さなクライアントにまとめる

架空のカタログAPIから商品を取得する `Acme\Catalog` パッケージを例にします。`src/CatalogClient.php` はFactoryとベースURLだけを保持し、リクエストは `product()` の実行中に作ります。

```php theme={null}
<?php

namespace Acme\Catalog;

use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use UnexpectedValueException;

final class CatalogClient
{
    public function __construct(
        private readonly Factory $http,
        private readonly string $baseUrl,
    ) {
    }

    public function product(int $id, ?string $language = null): array
    {
        $request = $this->request();

        if ($language !== null) {
            $request->withHeaders(['Accept-Language' => $language]);
        }

        $data = $request->get('/products/'.$id)
            ->throw()
            ->json(flags: JSON_THROW_ON_ERROR);

        if (! is_array($data)) {
            throw new UnexpectedValueException('Catalog response must be an array.');
        }

        return $data;
    }

    private function request(): PendingRequest
    {
        return $this->http->baseUrl($this->baseUrl)
            ->acceptJson()
            ->connectTimeout(3)
            ->timeout(10);
    }
}
```

この設計では、同じクライアントで `product(42, 'ja')` の後に `product(42)` を呼んでも、前の呼び出しで追加した `Accept-Language` は残りません。利用側アプリケーションがFactoryに設定した共通設定は、引き続き各リクエストへ適用されます。

* パッケージ固有の設定は、新しく作ったPendingRequestへ適用します。パッケージの初期化で `Http::globalOptions()` を設定すると、無関係な通信にも影響するため避けます。
* コンストラクターでは送信しません。サービスの解決、Artisanの起動、設定ファイルの公開だけで外部APIへ接続しない設計にします。
* `throw()` は4xx・5xxを `RequestException` にします。接続失敗やタイムアウトの `ConnectionException` と区別し、失敗を空配列などの成功値に置き換えません。
* `json(flags: JSON_THROW_ON_ERROR)` は不正なJSONを `JsonException` にします。例では配列かどうかだけを検査しているため、実際のAPIでは必須フィールドやデータ型も検証します。

> [!TIP]
>
> ベースURLやヘッダーの共通化だけなら、公式ドキュメントのHTTPマクロも選択肢です。API操作、戻り値の検査、例外の契約までパッケージとして提供する場合は、このような専用クラスに分けると保守しやすくなります。マクロの仕組みは[Macroableトレイト](https://kawax.biz/jp/advanced/macroable.md)を参照してください。

## サービスプロバイダーで登録する

パッケージの `config/catalog.php` に初期設定を用意します。ここにHTTPクライアントやクロージャを保存せず、設定キャッシュへ格納できる値だけを置きます。

```php theme={null}
<?php

return [
    'base_url' => 'https://catalog.example.test/api',
];
```

`src/CatalogServiceProvider.php` は設定をマージし、クライアントを解決するときに共有Factoryを渡します。Factoryそのもののバインディングは上書きしません。

```php theme={null}
<?php

namespace Acme\Catalog;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\ServiceProvider;

class CatalogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/catalog.php', 'acme-catalog');

        $this->app->bind(CatalogClient::class, function (Application $app): CatalogClient {
            return new CatalogClient(
                $app->make(Factory::class),
                $app['config']->get('acme-catalog.base_url'),
            );
        });
    }

    public function boot(): void
    {
        $this->publishes([
            __DIR__.'/../config/catalog.php' => config_path('acme-catalog.php'),
        ], 'acme-catalog-config');
    }
}
```

プロバイダーは[パッケージの自動検出](https://kawax.biz/jp/advanced/package-discovery.md)などで読み込みます。利用者は設定を公開し、`config/acme-catalog.php` のベースURLを実際のAPIへ変更します。例の `catalog.example.test` は説明とfake用の架空のホストです。

```shell theme={null}
php artisan vendor:publish --tag=acme-catalog-config
```

利用側ではコンストラクター注入やコンテナでクライアントを解決します。この呼び出しで初めて商品取得のリクエストが送信されます。

```php theme={null}
use Acme\Catalog\CatalogClient;

$product = app(CatalogClient::class)->product(42, 'ja');
```

登録には `bind()` を使っていますが、クライアントを保持する側がsingletonなら、その参照まで毎回作り直されるわけではありません。呼び出し固有の言語、認証情報、リクエストオブジェクトをプロパティに保存しないことが重要です。詳細は[パッケージサービスのライフサイクル設計](https://kawax.biz/jp/advanced/package-service-lifetimes.md)を参照してください。

設定の上書きとデプロイ時のキャッシュ更新は[パッケージ設定のマージとキャッシュ](https://kawax.biz/jp/advanced/package-config-merging.md)で扱っています。ベースURLは信頼できるアプリケーション設定として管理し、リクエストの入力から接続先を自由に指定させないでください。

## 本物のクライアントをfakeで検証する

クライアント自体をモックすると、共有Factoryを利用しているか、URLやヘッダーを正しく組み立てているかは確認できません。パッケージのテストでは、**本物の `CatalogClient` をコンテナから解決し、HTTP応答だけをfakeにします**。

次は[Orchestra Testbenchのテスト環境](https://kawax.biz/jp/advanced/package-testing.md)で実行する例です。各テストの `setUp()` で `preventStrayRequests()` を有効にします。通信するテストでは想定URLにだけ応答を設定し、`'*'` のフォールバックを置かないことで、URLの誤りも検出できます。

```php theme={null}
<?php

namespace Acme\Catalog\Tests;

use Acme\Catalog\CatalogClient;
use Acme\Catalog\CatalogServiceProvider;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\StrayRequestException;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;

class CatalogClientTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [CatalogServiceProvider::class];
    }

    protected function setUp(): void
    {
        parent::setUp();

        Http::preventStrayRequests();
    }

    public function test_resolving_the_client_does_not_send_a_request(): void
    {
        Http::fake();

        $this->assertInstanceOf(CatalogClient::class, $this->app->make(CatalogClient::class));

        Http::assertNothingSent();
    }

    public function test_product_request_uses_the_shared_factory(): void
    {
        $client = $this->app->make(CatalogClient::class);

        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response([
                'id' => 42,
                'name' => 'Notebook',
            ]),
        ]);

        $this->assertSame(['id' => 42, 'name' => 'Notebook'], $client->product(42, 'ja'));

        Http::assertSent(static fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request->url() === 'https://catalog.example.test/api/products/42'
            && $request->hasHeader('Accept', 'application/json')
            && $request->hasHeader('Accept-Language', 'ja')
        );
        Http::assertSentCount(1);
    }

    public function test_language_is_not_carried_over_to_the_next_request(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['id' => 42]),
        ]);

        $client = $this->app->make(CatalogClient::class);
        $client->product(42, 'ja');
        $client->product(42);

        Http::assertSentInOrder([
            static fn (Request $request): bool => $request->hasHeader('Accept-Language', 'ja'),
            static fn (Request $request): bool => ! $request->hasHeader('Accept-Language'),
        ]);
    }

    public function test_server_errors_are_not_returned_as_product_data(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['error' => 'unavailable'], 503),
        ]);

        $this->expectException(RequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }

    public function test_a_request_without_a_matching_fake_is_rejected(): void
    {
        Http::fake([]);

        $this->expectException(StrayRequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }
}
```

2番目のテストでは、**クライアントを解決してからfakeを設定しています**。Factoryを保持し、実際の呼び出し時にPendingRequestを作るため、この順番でもfakeが反映されます。コンストラクターでPendingRequestを生成・保持する設計へ変更すると、生成後に追加したfakeが反映されないことを検出できます。

`assertSent()` は一致するリクエストが1件以上あることの検証です。意図しない追加送信を見逃さないよう `assertSentCount()` も組み合わせます。`assertSentInOrder()` は件数と順序を検証するため、同じクライアントを続けて使ったときの状態分離にも利用できます。

ガードの設定は `parent::setUp()` の後です。このテストだけでプロバイダー起動中の通信まで検証できるわけではないため、登録と起動では通信しない設計を維持します。

> [!NOTE]
>
> fakeで確認できるのは、クライアントが組み立てたリクエストと、用意した応答に対する処理です。実サービスの認証、TLS接続、実際のタイムアウト時間、外部APIの仕様変更まで保証するものではありません。外部接続を伴う検証は、明示的に準備した検証環境で別に実施します。

## 長期保守で追加する検証

| 変更や異常系 | 確認すること |
| - | - |
| ベースURLの上書き | アプリケーション設定が反映され、意図したパスへ送信される |
| 接続失敗 | `Http::failedConnection()` で `ConnectionException` が利用側へ伝わる |
| 不正なJSON・想定外の型 | 不正なJSONは `JsonException`、配列でない値は例の `UnexpectedValueException` になる |
| 再試行の追加 | 試行回数、対象ステータス、送信内容を検証し、副作用のあるAPIでは重複実行を考慮する |
| 認証や言語の追加 | 同じクライアントを複数回使っても、前の呼び出しの値が持ち越されない |
| Laravelの更新 | 対応するバージョンでFactoryの解決経路、fakeの適用、例外の契約を継続確認する |

共有Factoryと各PendingRequestの責任を分けておくと、利用側が通常のLaravelのテスト方法をそのまま使えます。独自のfake機構を先に作るよりも、まず共有Factoryを通る実装と、それを確認するテストを用意してください。

## 参照した一次情報

* [Laravel公式ドキュメント: HTTPクライアント](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Laravel公式ドキュメント: パッケージ開発](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0: Factoryのfake・createPendingRequest・\_\_call](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0: PendingRequestの設定とstub処理](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0: ResponseのthrowとJSONデコード](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0: Httpファサードの解決先とfake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0: FoundationServiceProviderのsingleton登録](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Laravel公式テスト: HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [HTTPクライアント](https://kawax.biz/jp/http-client.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [エンジンAPI連携アプリ開発ガイド - VOICEVOX for Laravel](https://kawax.biz/jp/packages/laravel-voicevox/app-guide.md)
- [テスト](https://kawax.biz/jp/packages/laravel-bluesky/testing.md)
- [クライアントモード：トーク（テキスト音声合成） - VOICEVOX for Laravel](https://kawax.biz/jp/packages/laravel-voicevox/client-talk.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.