> ## 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-CN/http-client.md)，测试环境的准备请参阅[包的测试](https://kawax.biz/zh-CN/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-CN/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-CN/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-CN/advanced/package-service-lifetimes.md)。

配置的覆盖以及部署时的缓存更新，请参阅[包配置的合并与缓存](https://kawax.biz/zh-CN/advanced/package-config-merging.md)。基础 URL 应作为可信的应用配置进行管理，不要允许通过请求输入随意指定连接目标。

## 用 fake 验证真实的客户端

如果直接模拟客户端本身，就无法确认它是否使用了共享 Factory，也无法确认 URL 和请求头是否组装正确。在包的测试中，**从容器解析真实的 `CatalogClient`，只把 HTTP 响应设为 fake**。

下面是在[Orchestra Testbench 测试环境](https://kawax.biz/zh-CN/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);
    }
}
```

第二个测试中，**先解析客户端，再设置 fake**。由于客户端持有 Factory，并在实际调用时才创建 PendingRequest，所以即使按这个顺序，fake 也会生效。如果改成在构造函数中创建并持有 PendingRequest 的设计，就能检测出创建之后添加的 fake 不会生效的问题。

`assertSent()` 验证的是至少存在一个匹配的请求。为了不遗漏意外的额外发送，还应配合使用 `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-CN/http-client.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [引擎 API 集成应用开发指南 - VOICEVOX for Laravel](https://kawax.biz/zh-CN/packages/laravel-voicevox/app-guide.md)
- [测试](https://kawax.biz/zh-CN/packages/laravel-bluesky/testing.md)
- [包的静态分析（PHPStan / Larastan）](https://kawax.biz/zh-CN/advanced/package-static-analysis.md)


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