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

# Client HTTP et tests dans un package

> Concevez avec la Factory HTTP partagée de Laravel 13 un client d'API testable avec Http::fake(), avec un état isolé à chaque requête et un enregistrement de service propre.

Lorsqu'un package doit consommer une API externe, il faut concilier deux exigences : **hériter de la configuration du client HTTP gérée par Laravel** et **ne pas conserver la configuration d'un appel à l'autre**. Créer sa propre Factory ou conserver longtemps une requête non envoyée empêche les fakes de l'application consommatrice de s'appliquer.

Cette page a été vérifiée sur l'implémentation de Laravel Framework `v13.39.0`. Pour les opérations de base, consultez [Client HTTP](https://kawax.biz/fr/http-client.md), et pour préparer l'environnement de test, consultez [Tester des packages](https://kawax.biz/fr/advanced/package-testing.md).

## Partager la Factory, créer une PendingRequest à chaque fois

Le client HTTP de Laravel repose sur des objets aux responsabilités distinctes.

| Objet | Rôle | Utilisation dans un package |
| - | - | - |
| `Illuminate\Http\Client\Factory` | Gère les fakes, les options communes, les middlewares communs, etc. | La recevoir depuis le conteneur de Laravel |
| `Illuminate\Http\Client\PendingRequest` | Contient la configuration de la requête à envoyer : destination, en-têtes, timeout, etc. | La créer à chaque appel d'une méthode d'API |
| `Illuminate\Http\Client\Response` | Permet d'inspecter le statut et le corps | La traiter comme le résultat de l'échange et la convertir en exception ou en valeur de retour si nécessaire |

`FoundationServiceProvider` enregistre la Factory en singleton, et la façade `Http` est résolue depuis le conteneur avec le même nom de classe. Ainsi, dans une application Laravel classique, **les échanges effectués via la Factory reçue du conteneur peuvent aussi être vérifiés avec `Http::fake()`**. Il est possible d'utiliser directement la façade, mais cette page injecte la Factory afin de rendre les dépendances explicites.

Les méthodes habituelles de configuration de requête appelées sur la Factory, comme `baseUrl()`, sont transférées par `__call()` vers une nouvelle PendingRequest. `createPendingRequest()` transmet les callbacks de fake et la restriction des stray requests en vigueur au moment de la création, et `newPendingRequest()` applique les middlewares et options communs.

```mermaid theme={null}
flowchart LR
    A["Conteneur de Laravel"] --> B["Factory partagée"]
    C["Réponses définies avec Http::fake()"] --> B
    B --> D["Nouvelle PendingRequest<br>à chaque appel d'API"]
    D --> E["Configuration propre à l'appel"]
    E --> F["Envoi et inspection de la réponse"]
```

> [!WARNING]
>
> Si le package instancie `new Factory()` ou le client d'une autre bibliothèque HTTP, les fakes de la Factory partagée et `Http::preventStrayRequests()` ne sont pas hérités. Ces fonctionnalités ne bloquent pas toutes les communications du processus. Pour un client destiné à Laravel, utilisez la Factory partagée, et si vous fournissez un client indépendant, prévoyez une méthode de test dédiée à ce client.

## Regrouper les opérations d'API dans un petit client

Prenons l'exemple d'un package `Acme\Catalog` qui récupère des produits depuis une API de catalogue fictive. `src/CatalogClient.php` ne conserve que la Factory et l'URL de base, et la requête est créée pendant l'exécution de `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);
    }
}
```

Avec cette conception, si vous appelez `product(42)` après `product(42, 'ja')` sur le même client, l'en-tête `Accept-Language` ajouté lors de l'appel précédent ne subsiste pas. La configuration commune définie sur la Factory par l'application consommatrice continue de s'appliquer à chaque requête.

* Appliquez la configuration propre au package sur la PendingRequest nouvellement créée. Évitez de définir `Http::globalOptions()` lors de l'initialisation du package, car cela affecterait aussi des échanges sans rapport.
* N'envoyez rien dans le constructeur. Concevez le package pour que la simple résolution du service, le démarrage d'Artisan ou la publication du fichier de configuration ne se connectent pas à l'API externe.
* `throw()` convertit les 4xx et 5xx en `RequestException`. Distinguez-les des `ConnectionException` dues à un échec de connexion ou à un timeout, et ne remplacez pas un échec par une valeur de succès comme un tableau vide.
* `json(flags: JSON_THROW_ON_ERROR)` convertit un JSON invalide en `JsonException`. L'exemple vérifie seulement qu'il s'agit d'un tableau ; avec une vraie API, validez aussi les champs obligatoires et les types de données.

> [!TIP]
>
> S'il s'agit seulement de mutualiser l'URL de base ou les en-têtes, les macros HTTP de la documentation officielle sont aussi une option. Si le package fournit les opérations d'API, l'inspection des valeurs de retour et le contrat d'exceptions, le séparer dans une classe dédiée comme celle-ci facilite la maintenance. Pour le fonctionnement des macros, consultez [Le trait Macroable](https://kawax.biz/fr/advanced/macroable.md).

## Enregistrer le client dans un service provider

Préparez la configuration initiale dans `config/catalog.php` du package. N'y stockez ni client HTTP ni closure ; ne mettez que des valeurs pouvant être placées dans le cache de configuration.

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

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

`src/CatalogServiceProvider.php` fusionne la configuration et transmet la Factory partagée lors de la résolution du client. Il ne remplace pas le binding de la Factory elle-même.

```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');
    }
}
```

Le provider est chargé par exemple via la [découverte automatique des packages](https://kawax.biz/fr/advanced/package-discovery.md). L'utilisateur publie la configuration et remplace l'URL de base dans `config/acme-catalog.php` par celle de la vraie API. Le `catalog.example.test` de l'exemple est un hôte fictif destiné aux explications et aux fakes.

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

Côté application, le client est résolu par injection dans le constructeur ou via le conteneur. La requête de récupération du produit n'est envoyée qu'au moment de cet appel.

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

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

L'enregistrement utilise `bind()`, mais si l'objet qui conserve le client est un singleton, cette référence n'est pas recréée à chaque fois. L'essentiel est de ne pas stocker dans des propriétés la langue, les identifiants d'authentification ou l'objet de requête propres à un appel. Pour plus de détails, consultez [Concevoir le cycle de vie des services d'un package](https://kawax.biz/fr/advanced/package-service-lifetimes.md).

La surcharge de la configuration et la mise à jour du cache lors du déploiement sont traitées dans [Fusion et cache de la configuration d'un package](https://kawax.biz/fr/advanced/package-config-merging.md). Gérez l'URL de base comme une configuration d'application de confiance, et ne laissez pas les entrées de requête désigner librement la destination.

## Vérifier le vrai client avec des fakes

Si vous mockez le client lui-même, vous ne pouvez pas vérifier qu'il utilise la Factory partagée ni qu'il construit correctement l'URL et les en-têtes. Dans les tests du package, **résolvez le vrai `CatalogClient` depuis le conteneur et ne simulez que les réponses HTTP**.

Voici un exemple exécuté dans [l'environnement de test Orchestra Testbench](https://kawax.biz/fr/advanced/package-testing.md). `preventStrayRequests()` est activé dans le `setUp()` de chaque test. Dans les tests qui communiquent, définissez des réponses uniquement pour les URL attendues et n'ajoutez pas de repli `'*'`, afin de détecter aussi les erreurs d'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);
    }
}
```

Dans le deuxième test, **le fake est défini après la résolution du client**. Comme le client conserve la Factory et crée la PendingRequest au moment de l'appel réel, le fake est pris en compte même dans cet ordre. Si la conception est modifiée pour créer et conserver la PendingRequest dans le constructeur, ce test détectera que les fakes ajoutés après la création ne sont plus appliqués.

`assertSent()` vérifie qu'au moins une requête correspondante existe. Combinez-le avec `assertSentCount()` pour ne pas laisser passer des envois supplémentaires non souhaités. `assertSentInOrder()` vérifie le nombre et l'ordre des requêtes, ce qui permet aussi de contrôler l'isolation de l'état lorsque le même client est utilisé plusieurs fois de suite.

La protection est configurée après `parent::setUp()`. Ce test seul ne permet pas de vérifier les échanges effectués pendant le démarrage du provider ; conservez donc une conception qui ne communique pas lors de l'enregistrement et du démarrage.

> [!NOTE]
>
> Les fakes permettent de vérifier les requêtes construites par le client et le traitement des réponses préparées. Ils ne garantissent ni l'authentification du vrai service, ni la connexion TLS, ni les durées réelles de timeout, ni les changements de spécification de l'API externe. Les vérifications impliquant des connexions externes doivent être menées séparément, dans un environnement de test explicitement préparé.

## Vérifications à ajouter pour la maintenance à long terme

| Changement ou cas d'erreur | Ce qu'il faut vérifier |
| - | - |
| Surcharge de l'URL de base | La configuration de l'application est prise en compte et la requête part vers le chemin prévu |
| Échec de connexion | Avec `Http::failedConnection()`, la `ConnectionException` remonte jusqu'à l'application |
| JSON invalide ou type inattendu | Un JSON invalide produit une `JsonException`, et une valeur qui n'est pas un tableau produit la `UnexpectedValueException` de l'exemple |
| Ajout de nouvelles tentatives | Vérifier le nombre de tentatives, les statuts concernés et le contenu envoyé, et tenir compte des exécutions en double pour les API à effets de bord |
| Ajout d'authentification ou de langue | Même en utilisant plusieurs fois le même client, les valeurs de l'appel précédent ne sont pas reportées |
| Mise à jour de Laravel | Continuer à vérifier, sur les versions prises en charge, le chemin de résolution de la Factory, l'application des fakes et le contrat d'exceptions |

En séparant les responsabilités de la Factory partagée et de chaque PendingRequest, l'application consommatrice peut utiliser telles quelles les méthodes de test habituelles de Laravel. Plutôt que de commencer par créer votre propre mécanisme de fake, préparez d'abord une implémentation qui passe par la Factory partagée et les tests qui le vérifient.

## Sources primaires consultées

* [Documentation officielle de Laravel : HTTP Client](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Documentation officielle de Laravel : Package Development](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0 : fake, createPendingRequest et \_\_call de Factory](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0 : configuration et traitement des stubs de PendingRequest](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0 : throw et décodage JSON de Response](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0 : cible de résolution et fake de la façade Http](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0 : enregistrement en singleton dans FoundationServiceProvider](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Tests officiels de Laravel : HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [Client HTTP](https://kawax.biz/fr/http-client.md)
- [Sujets avancés](https://kawax.biz/fr/advanced/index.md)
- [Guide de développement d'applications intégrées à l'API du moteur - VOICEVOX for Laravel](https://kawax.biz/fr/packages/laravel-voicevox/app-guide.md)
- [Enregistrer des drivers Storage dans un package et les résoudre à nouveau](https://kawax.biz/fr/advanced/package-filesystem-drivers.md)
- [Canaux de notification personnalisés dans un package et vérification de l'envoi](https://kawax.biz/fr/advanced/package-notification-channels.md)


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