Die richtige Wahl Ihres Test-Doubles in Go
Min-jun Kim
Dev Intern · Leapcell

Tests sind ein unverzichtbarer Bestandteil der Softwareentwicklung und gewährleisten die Zuverlässigkeit und Korrektheit unserer Anwendungen. In Go ergibt sich häufig ein Dilemma, wenn Dienste kommunizieren, die über HTTP kommunizieren: Wie lassen sich Interaktionen mit externen HTTP-Abhängigkeiten am besten testen? Sollen wir für Integrationstests einen echten, wenn auch ephemeren, HTTP-Server hochfahren oder sorgfältig Mock-Objekte erstellen, um diese Interaktionen während Unit-Tests zu simulieren? Diese Frage ist nicht rein akademisch; sie hat erhebliche Auswirkungen auf die Wartbarkeit von Tests, die Ausführungsgeschwindigkeit und die Abdeckung. Dieser Artikel befasst sich mit den Kompromissen zwischen httptest.NewServer für Integrationstests und Mock-Service-Schnittstellen für Unit-Tests und führt Sie zu einer effektiven Teststrategie in Go.
Bevor wir uns den Details widmen, lassen Sie uns einige Kernkonzepte klären, die für unsere Diskussion von zentraler Bedeutung sind:
- Unit Testing: Konzentriert sich auf das Testen einzelner Einheiten oder Komponenten einer Software in Isolation. Das Ziel ist es, zu überprüfen, ob jede Codeeinheit wie erwartet funktioniert. Externe Abhängigkeiten werden typischerweise "gemockt" oder "gestubbt", um die Einheit isoliert zu halten.
- Integration Testing: Zielt darauf ab, zu testen, wie verschiedene Einheiten oder Komponenten eines Softwaresystems miteinander interagieren. Dies beinhaltet oft das Testen der Interaktion des Systems mit externen Diensten, Datenbanken oder APIs.
httptest.NewServer: Ein Go-Paket, das Dienstprogramme für HTTP-Tests bereitstellt.httptest.NewServererstellt einen neuen HTTP-Server, der an einer zufälligen lokalen Netzwerkadresse lauscht, was ihn ideal für Integrationstests macht, bei denen Sie einen Live-HTTP-Endpunkt benötigen, aber seine Antworten und sein Verhalten steuern möchten.- Mock Service Interface: Im Kontext von Unit-Tests bezieht sich dies auf die Erstellung eines Ersatzobjekts, das das Verhalten einer realen Abhängigkeit nachahmt. Anstatt den tatsächlichen externen Dienst aufzurufen, interagiert Ihr Code mit diesem Mock, der vordefinierte Antworten liefert, so dass Sie Ihre Logik isoliert ohne tatsächliche Netzwerkanrufe testen können.
Nun wollen wir die beiden Hauptansätze untersuchen.
httptest.NewServer für Integrationstests
httptest.NewServer ist ein leistungsstarkes Werkzeug für Integrationstests. Es ermöglicht Ihnen, eine voll funktionsfähige HTTP-Server-Instanz innerhalb Ihrer Testsuite zu erstellen, an die Ihr zu testender Code dann Anfragen senden kann. Dies simuliert einen echten externen Dienst und ermöglicht es Ihnen, den vollständigen Anfrage-Antwort-Zyklus zu testen, einschließlich HTTP-Headern, Statuscodes und Body-Inhalten.
Wie es funktioniert:
Sie übergeben einen http.Handler an httptest.NewServer, der dann eingehende Anfragen behandelt. Der Server wird an einem zufälligen verfügbaren Port gestartet, und seine URL ist über ts.URL erreichbar.
Beispiel:
Betrachten Sie einen Client, der Benutzerdaten von einer externen API abruft:
package main import ( "encoding/json" "fmt" "io" "net/http" ) type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } type UserClient struct { baseURL string client *http.Client } func NewUserClient(baseURL string) *UserClient { return &UserClient{ baseURL: baseURL, client: &http.Client{}, } } func (uc *UserClient) GetUser(id string) (*User, error) { resp, err := uc.client.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
Schreiben wir nun einen Integrationstest mit httptest.NewServer:
package main import ( "encoding/json" "net/http" "net/http/httptest" "testing" ) func TestUserClient_GetUser_Integration(t *testing.T) { // Erstellen Sie einen Mock-Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/users/123" { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(User{ID: "123", Name: "John Doe", Email: "john@example.com"}) } else if r.URL.Path == "/users/404" { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } })) defer ts.Close() // Schließen Sie den Server, wenn der Test abgeschlossen ist client := NewUserClient(ts.URL) // Testfall 1: Erfolg bei der Benutzerabfrage user, err := client.GetUser("123") if err != nil { t.Fatalf("Erwartete keinen Fehler, erhielt %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Erwarteter Benutzer John Doe mit ID 123, erhielt %v", user) } // Testfall 2: Benutzer nicht gefunden _, err = client.GetUser("404") if err == nil { t.Fatalf("Erwartete einen Fehler für nicht gefundenen Benutzer, erhielt nil") } expectedErrMsg := "unexpected status code: 404" if err.Error() != expectedErrMsg { t.Errorf("Erwarteter Fehler '%s', erhielt '%s'", expectedErrMsg, err.Error()) } }
Vorteile:
- Hohe Wiedergabetreue: Testet eine realistische Interaktion mit einem HTTP-Dienst, einschließlich Netzwerk-Serialisierung/Deserialisierung, HTTP-Statuscodes und Headern.
- Umfassende Fehlerbehandlung: Ermöglicht das genaue Testen verschiedener HTTP-Fehlerszenarien (4xx, 5xx).
- Weniger Mocking-Boilerplate: Sie definieren das Verhalten für den
http.Handler, was oft einfacher ist als die Definition einer gesamten Mock-Schnittstelle mit mehreren Methoden.
Nachteile:
- Langsamere Ausführung: Das Hochfahren und Herunterfahren eines echten HTTP-Servers dauert länger als rein speicherinterne Unit-Tests.
- Netzwerkabhängigkeit (lokal): Obwohl es sich um einen lokalen Server handelt, sind Interaktionen mit dem Netzwerkstapel beteiligt, die eine Quelle für subtile Probleme oder Langsamkeit sein können.
- Komplexität beim Debugging: Das Debuggen von Problemen innerhalb des
http.Handlerkann manchmal kniffliger sein als das direkte Debuggen eines Mock-Objekts.
Mock Service Interfaces für Unit-Tests
Für Unit-Tests ist die Isolierung unseres Codes von externen Abhängigkeiten von größter Bedeutung. Hier glänzen Mock-Service-Schnittstellen. Anstatt einen echten HTTP-Endpunkt anzusprechen, definieren wir eine Schnittstelle für unseren externen Dienst und erstellen dann eine Mock-Implementierung dieser Schnittstelle. Unser zu testender Code interagiert dann mit diesem Mock.
Wie es funktioniert:
Zuerst definieren wir eine Schnittstelle, die unser UserClient erfüllen muss (oder, häufiger, wir definieren eine Schnittstelle für die Abhängigkeit, die UserClient verwendet, wenn UserClient eine Schnittstelle als Abhängigkeit erhalten würde). Dann erstellen wir eine Mock-Struktur, die diese Schnittstelle implementiert, wodurch wir die Rückgabewerte und Nebeneffekte ihrer Methoden steuern können.
Beispiel:
Lassen Sie uns unseren UserClient refaktorieren, um auf eine Schnittstelle für HTTP-Anfragen zu setzen:
package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // HTTPClient definiert die Schnittstelle für HTTP-Anfragen. // Wir benötigen nur die Get-Methode für unseren aktuellen UserClient. type HTTPClient interface { Get(url string) (*http.Response, error) } // ConcreteHttpClient ist ein Wrapper um den Standard-http.Client type ConcreteHttpClient struct { client *http.Client } func NewConcreteHttpClient() *ConcreteHttpClient { return &ConcreteHttpClient{client: &http.Client{}} } func (c *ConcreteHttpClient) Get(url string) (*http.Response, error) { return c.client.Get(url) } type UserClientWithInterface struct { baseURL string httpClient HTTPClient // Eingespritzter HTTPClient } func NewUserClientWithInterface(baseURL string, client HTTPClient) *UserClientWithInterface { return &UserClientWithInterface{ baseURL: baseURL, httpClient: client, } } func (uc *UserClientWithInterface) GetUser(id string) (*User, error) { resp, err := uc.httpClient.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
Nun schaffen wir einen Mock HTTPClient für Unit-Tests:
package main import ( "bytes" "io" "net/http" "testing" ) // MockHTTPClient ist eine Mock-Implementierung der HTTPClient-Schnittstelle type MockHTTPClient struct { GetResponse *http.Response GetError error } func (m *MockHTTPClient) Get(url string) (*http.Response, error) { return m.GetResponse, m.GetError } func TestUserClientWithInterface_GetUser_Unit(t *testing.T) { // Testfall 1: Erfolg bei der Benutzerabfrage mockClientSuccess := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"id":"123","name":"John Doe","email":"john@example.com"}`)), }, GetError: nil, } clientSuccess := NewUserClientWithInterface("http://api.example.com", mockClientSuccess) user, err := clientSuccess.GetUser("123") if err != nil { t.Fatalf("Erwartete keinen Fehler, erhielt %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Erwarteter Benutzer John Doe mit ID 123, erhielt %v", user) } // Testfall 2: Benutzer nicht gefunden (Status 404) mockClientNotFound := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewBufferString("")), // Leerer Body für 404 }, GetError: nil, } clientNotFound := NewUserClientWithInterface("http://api.example.com", mockClientNotFound) _, err = clientNotFound.GetUser("404") if err == nil { t.Fatalf("Erwartete einen Fehler für nicht gefundenen Benutzer, erhielt nil") } expectedErrMsgNotFound := "unexpected status code: 404" if err.Error() != expectedErrMsgNotFound { t.Errorf("Erwarteter Fehler '%s', erhielt '%s'", expectedErrMsgNotFound, err.Error()) } // Testfall 3: Netzwerkfehler mockClientNetworkError := &MockHTTPClient{ GetResponse: nil, GetError: fmt.Errorf("network connection refused"), } clientNetworkError := NewUserClientWithInterface("http://api.example.com", mockClientNetworkError) _, err = clientNetworkError.GetUser("123") if err == nil { t.Fatalf("Erwartete einen Netzwerkfehler, erhielt nil") } expectedErrMsgNetwork := "failed to make request: network connection refused" if err.Error() != expectedErrMsgNetwork { t.Errorf("Erwarteter Fehler '%s', erhielt '%s'", expectedErrMsgNetwork, err.Error()) } }
Vorteile:
- Schnelle Ausführung: Tests laufen vollständig im Speicher und sind daher extrem schnell und für Continuous Integration geeignet.
- Isolation: Der zu testende Code ist vollständig von externen Faktoren isoliert, sodass Testfehler auf Probleme innerhalb der Einheit selbst hinweisen.
- Präzise Kontrolle: Sie haben detaillierte Kontrolle über die vom Mock zurückgegebenen Antworten und Fehler, was das Testen von Randfällen erleichtert.
Nachteile:
- Geringere Wiedergabetreue: Testet nicht die eigentliche HTTP-Transportschicht. Mögliche Probleme mit Serialisierung/Deserialisierung oder Header-Handling können übersehen werden.
- Boilerplate: Erfordert die Definition von Schnittstellen und die Erstellung von Mock-Implementierungen, was zu mehr Boilerplate-Code führen kann, insbesondere wenn keine externe Mocking-Bibliothek verwendet wird.
- Risiko fehlerhafter Mocks: Wenn Ihr Mock das Verhalten des echten Dienstes nicht genau nachahmt, können Ihre Tests erfolgreich sein, während die tatsächliche Integration fehlschlägt.
Die richtige Wahl treffen
Die Wahl zwischen httptest.NewServer und Mock-Service-Schnittstellen hängt weitgehend von der Art des Tests ab, den Sie schreiben, und dem spezifischen Aspekt, den Sie überprüfen möchten.
- Verwenden Sie Mock-Service-Schnittstellen für Unit-Tests, wenn Sie die interne Logik Ihrer Komponente isoliert testen möchten, um sicherzustellen, dass sie verschiedene Antworten (Erfolg, unterschiedliche Fehlercodes) und Netzwerkausfälle korrekt verarbeitet. Hierbei geht es darum, wie Ihr Code auf verschiedene
HTTPClient-Ergebnisse reagiert. Diese Tests sollten schnell und fokussiert sein. - Verwenden Sie
httptest.NewServerfür Integrationstests, wenn Sie sicherstellen möchten, dass Ihre Komponente korrekt mit einem HTTP-Dienst interagiert, einschließlich der Nuancen des HTTP-Protokolls. Hierbei geht es um die Überprüfung der End-to-End-Kommunikation und des Datenaustauschs über HTTP. Diese Tests sind langsamer, bieten aber in realen Szenarien eine höhere Zuverlässigkeit.
Eine robuste Teststrategie beinhaltet oft beide Ansätze. Beginnen Sie mit umfassenden Unit-Tests mit Mock-Schnittstellen für Ihre Kern-Geschäftslogik und Ihre clientseitige HTTP-Interaktionslogik. Ergänzen Sie diese durch Integrationstests mit httptest.NewServer, um den tatsächlichen HTTP-Kommunikationspfad zu überprüfen und sicherzustellen, dass Ihr Client Antworten von einem Live- (wenn auch lokalen) Server korrekt interpretiert.
Zusammenfassend lässt sich sagen, dass httptest.NewServer hochgradig getreue Integrationstests für HTTP-Interaktionen bietet und in realen Szenarien Vertrauen schafft, allerdings auf Kosten der Geschwindigkeit. Mock-Service-Schnittstellen hingegen ermöglichen blitzschnelle, isolierte Unit-Tests, die sich perfekt zur Überprüfung der internen Logik und Fehlerbehandlung eignen. Die optimale Strategie harmonisiert diese beiden leistungsstarken Techniken und stellt sowohl die modulare Korrektheit Ihrer Komponenten als auch ihre nahtlose Integration in das Gesamtsystem sicher.

