> ## 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-Client und Tests in Paketen

> Entwirft mit der gemeinsamen HTTP-Factory von Laravel 13 einen API-Client, der den Zustand pro Anfrage trennt, als Service registriert wird und sich mit Http::fake() überprüfen lässt.

Wenn ein Paket eine externe API nutzen soll, müssen zwei Dinge gleichzeitig erfüllt sein: **die von Laravel verwaltete Konfiguration des HTTP-Clients übernehmen** und **keine Einstellungen eines Aufrufs in den nächsten mitnehmen**. Wenn Sie eine eigene Factory erzeugen oder Anfragen vor dem Senden lange vorhalten, kann das dazu führen, dass Fakes der nutzenden Anwendung nicht greifen.

Diese Seite basiert auf der Implementierung von Laravel Framework `v13.39.0`. Die Grundlagen finden Sie unter [HTTP-Client](https://kawax.biz/de/http-client.md), die Vorbereitung der Testumgebung unter [Pakete testen](https://kawax.biz/de/advanced/package-testing.md).

## Die Factory teilen, den PendingRequest jedes Mal neu erstellen

Der HTTP-Client von Laravel besteht aus Objekten mit unterschiedlichen Verantwortlichkeiten.

| Objekt | Rolle | Umgang im Paket |
| - | - | - |
| `Illuminate\Http\Client\Factory` | Verwaltet Fakes, gemeinsame Optionen, gemeinsame Middleware usw. | Aus dem Container von Laravel beziehen |
| `Illuminate\Http\Client\PendingRequest` | Hält die Einstellungen der zu sendenden Anfrage wie Ziel, Header und Timeout | Bei jedem Aufruf einer API-Methode neu erzeugen |
| `Illuminate\Http\Client\Response` | Prüft Status und Body | Als Ergebnis der Kommunikation behandeln und bei Bedarf in Exceptions oder Rückgabewerte umwandeln |

`FoundationServiceProvider` registriert die Factory als Singleton, und auch die Fassade `Http` löst über denselben Klassennamen aus dem Container auf. Deshalb lässt sich in einer gewöhnlichen Laravel-Anwendung **auch die Kommunikation über eine aus dem Container bezogene Factory mit `Http::fake()` überprüfen**. Ein Entwurf, der die Fassade direkt nutzt, ist ebenfalls möglich, doch auf dieser Seite wird die Factory injiziert, um die Abhängigkeit explizit zu machen.

Gewöhnliche Methoden zur Anfragekonfiguration wie `baseUrl()` auf der Factory werden über `__call()` an einen neuen PendingRequest weitergeleitet. `createPendingRequest()` übergibt die zum Zeitpunkt der Erzeugung gültigen Fake-Callbacks und die Einschränkung für Stray Requests, und `newPendingRequest()` wendet die gemeinsame Middleware und die gemeinsamen Optionen an.

```mermaid theme={null}
flowchart LR
    A["Container von Laravel"] --> B["Gemeinsame Factory"]
    C["Antworten mit Http::fake() festlegen"] --> B
    B --> D["Pro API-Aufruf<br>neuer PendingRequest"]
    D --> E["Aufrufspezifische Einstellungen"]
    E --> F["Senden und Antwort prüfen"]
```

> [!WARNING]
>
> Wenn Sie im Paket `new Factory()` oder einen Client einer anderen HTTP-Bibliothek erzeugen, werden die Fakes der gemeinsamen Factory und `Http::preventStrayRequests()` nicht übernommen. Diese Funktionen blockieren nicht die gesamte Kommunikation des Prozesses. Verwenden Sie für Clients, die für Laravel gedacht sind, die gemeinsame Factory, und stellen Sie bei einem unabhängigen Client eine eigene Testmethode für diesen Client bereit.

## API-Operationen in einem kleinen Client bündeln

Als Beispiel dient das Paket `Acme\Catalog`, das Produkte aus einer fiktiven Katalog-API abruft. `src/CatalogClient.php` hält nur die Factory und die Basis-URL, und die Anfrage wird erst während der Ausführung von `product()` erstellt.

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

Bei diesem Entwurf bleibt der im vorherigen Aufruf hinzugefügte `Accept-Language` nicht erhalten, wenn Sie mit demselben Client nach `product(42, 'ja')` noch `product(42)` aufrufen. Gemeinsame Einstellungen, die die nutzende Anwendung an der Factory vorgenommen hat, werden weiterhin auf jede Anfrage angewendet.

* Paketspezifische Einstellungen wenden Sie auf den neu erstellten PendingRequest an. Vermeiden Sie es, bei der Initialisierung des Pakets `Http::globalOptions()` zu setzen, da dies auch unbeteiligte Kommunikation beeinflusst.
* Im Konstruktor wird nichts gesendet. Entwerfen Sie das Paket so, dass das bloße Auflösen des Service, das Starten von Artisan oder das Veröffentlichen der Konfigurationsdatei keine Verbindung zur externen API herstellt.
* `throw()` macht aus 4xx und 5xx eine `RequestException`. Unterscheiden Sie diese von der `ConnectionException` bei Verbindungsfehlern oder Timeouts, und ersetzen Sie Fehler nicht durch Erfolgswerte wie ein leeres Array.
* `json(flags: JSON_THROW_ON_ERROR)` macht aus ungültigem JSON eine `JsonException`. Das Beispiel prüft nur, ob ein Array vorliegt, daher sollten Sie bei einer echten API auch Pflichtfelder und Datentypen validieren.

> [!TIP]
>
> Wenn es nur darum geht, Basis-URL oder Header zu vereinheitlichen, sind auch die HTTP-Makros aus der offiziellen Dokumentation eine Option. Wenn das Paket API-Operationen, die Prüfung von Rückgabewerten und den Exception-Vertrag bereitstellt, ist es wartungsfreundlicher, dies wie hier in eine eigene Klasse auszulagern. Wie Makros funktionieren, erfahren Sie unter [Macroable-Trait](https://kawax.biz/de/advanced/macroable.md).

## Im Service-Provider registrieren

Legen Sie in `config/catalog.php` des Pakets die Standardkonfiguration an. Speichern Sie hier keine HTTP-Clients oder Closures, sondern nur Werte, die im Konfigurations-Cache abgelegt werden können.

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

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

`src/CatalogServiceProvider.php` führt die Konfiguration zusammen und übergibt beim Auflösen des Clients die gemeinsame Factory. Das Binding der Factory selbst wird nicht überschrieben.

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

Der Provider wird zum Beispiel über die [Paket-Erkennung](https://kawax.biz/de/advanced/package-discovery.md) geladen. Nutzer veröffentlichen die Konfiguration und ändern die Basis-URL in `config/acme-catalog.php` auf die tatsächliche API. `catalog.example.test` im Beispiel ist ein fiktiver Host für Erklärungen und Fakes.

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

Auf der nutzenden Seite wird der Client per Konstruktor-Injektion oder über den Container aufgelöst. Erst mit diesem Aufruf wird die Anfrage zum Abrufen des Produkts gesendet.

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

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

Für die Registrierung wird `bind()` verwendet, doch wenn das Objekt, das den Client hält, ein Singleton ist, wird auch diese Referenz nicht jedes Mal neu erzeugt. Entscheidend ist, aufrufspezifische Sprache, Zugangsdaten und Anfrageobjekte nicht in Eigenschaften zu speichern. Details finden Sie unter [Lebenszyklus von Paket-Services entwerfen](https://kawax.biz/de/advanced/package-service-lifetimes.md).

Das Überschreiben der Konfiguration und das Aktualisieren des Caches beim Deployment werden unter [Zusammenführen und Cachen der Paketkonfiguration](https://kawax.biz/de/advanced/package-config-merging.md) behandelt. Verwalten Sie die Basis-URL als vertrauenswürdige Anwendungskonfiguration, und lassen Sie das Verbindungsziel nicht frei über Eingaben der Anfrage festlegen.

## Den echten Client mit Fakes überprüfen

Wenn Sie den Client selbst mocken, können Sie nicht prüfen, ob er die gemeinsame Factory nutzt und URL und Header korrekt zusammensetzt. In den Tests des Pakets **lösen Sie den echten `CatalogClient` aus dem Container auf und ersetzen nur die HTTP-Antworten durch Fakes**.

Das folgende Beispiel läuft in der [Testumgebung von Orchestra Testbench](https://kawax.biz/de/advanced/package-testing.md). In `setUp()` jedes Tests wird `preventStrayRequests()` aktiviert. In Tests mit Kommunikation legen Sie nur für die erwartete URL eine Antwort fest und verzichten auf einen `'*'`-Fallback, sodass auch fehlerhafte URLs erkannt werden.

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

Im zweiten Test wird **der Fake erst nach dem Auflösen des Clients festgelegt**. Da der Client die Factory hält und den PendingRequest erst beim tatsächlichen Aufruf erzeugt, greift der Fake auch in dieser Reihenfolge. Würde der Entwurf so geändert, dass der PendingRequest im Konstruktor erzeugt und gehalten wird, erkennt dieser Test, dass ein danach hinzugefügter Fake nicht greift.

`assertSent()` prüft, dass mindestens eine passende Anfrage existiert. Kombinieren Sie es mit `assertSentCount()`, damit unbeabsichtigte zusätzliche Anfragen nicht übersehen werden. `assertSentInOrder()` prüft Anzahl und Reihenfolge und eignet sich daher auch, um die Zustandstrennung bei aufeinanderfolgender Nutzung desselben Clients zu überprüfen.

Der Schutz wird nach `parent::setUp()` eingerichtet. Mit diesem Test allein lässt sich nicht auch die Kommunikation während des Starts der Provider überprüfen, halten Sie daher an einem Entwurf fest, der bei Registrierung und Start nicht kommuniziert.

> [!NOTE]
>
> Mit Fakes lassen sich die vom Client zusammengesetzte Anfrage und die Verarbeitung der vorbereiteten Antworten prüfen. Authentifizierung beim echten Dienst, TLS-Verbindungen, tatsächliche Timeout-Dauern und Spezifikationsänderungen der externen API werden dadurch nicht garantiert. Überprüfungen mit externen Verbindungen führen Sie separat in einer ausdrücklich dafür vorbereiteten Testumgebung durch.

## Zusätzliche Tests für die langfristige Wartung

| Änderung oder Fehlerfall | Was zu prüfen ist |
| - | - |
| Überschreiben der Basis-URL | Die Anwendungskonfiguration wird übernommen und die Anfrage an den beabsichtigten Pfad gesendet |
| Verbindungsfehler | Mit `Http::failedConnection()` wird die `ConnectionException` an die nutzende Seite weitergegeben |
| Ungültiges JSON und unerwartete Typen | Ungültiges JSON führt zu `JsonException`, Werte, die kein Array sind, zur `UnexpectedValueException` aus dem Beispiel |
| Hinzufügen von Wiederholungsversuchen | Anzahl der Versuche, betroffene Statuscodes und gesendete Inhalte prüfen, bei APIs mit Seiteneffekten doppelte Ausführung berücksichtigen |
| Hinzufügen von Authentifizierung oder Sprache | Auch bei mehrfacher Nutzung desselben Clients werden Werte des vorherigen Aufrufs nicht übernommen |
| Laravel-Updates | In den unterstützten Versionen den Auflösungsweg der Factory, die Anwendung von Fakes und den Exception-Vertrag fortlaufend prüfen |

Wenn Sie die Verantwortlichkeiten der gemeinsamen Factory und der einzelnen PendingRequests trennen, können Nutzer die gewohnten Testmethoden von Laravel unverändert verwenden. Statt zuerst einen eigenen Fake-Mechanismus zu bauen, sollten Sie eine Implementierung bereitstellen, die über die gemeinsame Factory läuft, sowie Tests, die dies bestätigen.

## Verwendete Primärquellen

* [Offizielle Laravel-Dokumentation: HTTP-Client](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Offizielle Laravel-Dokumentation: Paketentwicklung](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0: fake, createPendingRequest und \_\_call der Factory](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0: Konfiguration und Stub-Verarbeitung von PendingRequest](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0: throw und JSON-Dekodierung von Response](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0: Auflösungsziel und fake der Http-Fassade](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0: Singleton-Registrierung im FoundationServiceProvider](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Offizielle Laravel-Tests: HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [HTTP-Client](https://kawax.biz/de/http-client.md)
- [Weiterführende Themen](https://kawax.biz/de/advanced/index.md)
- [Entwicklungsleitfaden für Engine-API-Integrationsanwendungen - VOICEVOX für Laravel](https://kawax.biz/de/packages/laravel-voicevox/app-guide.md)
- [Tests](https://kawax.biz/de/packages/laravel-bluesky/testing.md)
- [Middleware-Registrierung und Geltungsbereich in Paketen](https://kawax.biz/de/advanced/package-middleware.md)


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