> ## 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/zh-TW/http-client.md)，測試環境的準備請參閱[套件的測試](https://kawax.biz/zh-TW/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` Facade 也使用相同的類別名稱從容器解析。因此在一般的 Laravel 應用程式中，**透過從容器取得的 Factory 所發出的通訊，也能以 `Http::fake()` 驗證**。雖然也可以設計成直接使用 Facade，但本頁為了明確表示相依關係，改為注入 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 Trait](https://kawax.biz/zh-TW/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/zh-TW/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/zh-TW/advanced/package-service-lifetimes.md)。

設定的覆寫與部署時的快取更新，請參閱[套件設定的合併與快取](https://kawax.biz/zh-TW/advanced/package-config-merging.md)。基礎 URL 應作為可信任的應用程式設定來管理，不要讓請求的輸入能任意指定連線目的地。

## 以 fake 驗證真正的用戶端

若直接 mock 用戶端本身，就無法確認它是否使用共用 Factory，以及 URL 與標頭是否組裝正確。在套件的測試中，**從容器解析真正的 `CatalogClient`，只將 HTTP 回應設為 fake**。

以下是在 [Orchestra Testbench 測試環境](https://kawax.biz/zh-TW/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 Facade 的解析目標與 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/zh-TW/http-client.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [用 Inertia.js 建構 SPA](https://kawax.biz/zh-TW/blog/inertia-introduction.md)
- [套件的靜態分析（PHPStan / Larastan）](https://kawax.biz/zh-TW/advanced/package-static-analysis.md)
- [部落格](https://kawax.biz/zh-TW/blog/index.md)


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