Testen Ihrer Go-App: Starten Sie richtig

Veröffentlicht: 2022-03-11

Wenn Sie etwas Neues lernen, ist es wichtig, einen frischen Geisteszustand zu haben.

Wenn Sie ziemlich neu bei Go sind und von Sprachen wie JavaScript oder Ruby kommen, sind Sie wahrscheinlich daran gewöhnt, vorhandene Frameworks zu verwenden, die Ihnen helfen, sich zu verspotten, zu bestätigen und andere Testzauberei durchzuführen.

Beseitigen Sie jetzt die Idee der Abhängigkeit von externen Abhängigkeiten oder Frameworks! Das Testen war das erste Hindernis, auf das ich beim Erlernen dieser bemerkenswerten Programmiersprache vor ein paar Jahren gestoßen bin, zu einer Zeit, als weitaus weniger Ressourcen verfügbar waren.

Ich weiß jetzt, dass das Testen von Erfolg in Go bedeutet, mit geringen Abhängigkeiten umzugehen (wie bei allen Go-Dingen), sich nur minimal auf externe Bibliotheken zu verlassen und guten wiederverwendbaren Code zu schreiben. Diese Präsentation von Blake Mizeranys Erfahrungen mit Testbibliotheken von Drittanbietern ist ein guter Anfang, um Ihre Denkweise anzupassen. Sie werden einige gute Argumente für die Verwendung externer Bibliotheken und Frameworks sehen, anstatt es „auf die Go-Art“ zu tun.

Willst du Go lernen? Schauen Sie sich unser Golang-Einführungstutorial an.

Es mag kontraintuitiv erscheinen, ein eigenes Test-Framework und Mocking-Konzepte zu erstellen, aber es ist einfacher als man denkt und ein guter Ausgangspunkt für das Erlernen der Sprache. Außerdem haben Sie diesen Artikel im Gegensatz zu meiner Lernzeit, um Sie durch gängige Testszenarien zu führen und Techniken vorzustellen, die ich als Best Practices für effizientes Testen und Sauberhalten von Code betrachte.

Machen Sie die Dinge „the Go Way“, beseitigen Sie Abhängigkeiten von externen Frameworks.
Twittern

Tabellentests in Go

Die grundlegende Testeinheit – bekannt als „Unit Testing“ – kann in ihrer einfachsten Form jede Komponente eines Programms sein, die eine Eingabe entgegennimmt und eine Ausgabe zurückgibt. Werfen wir einen Blick auf eine einfache Funktion, für die wir Tests schreiben möchten. Es ist bei weitem nicht perfekt oder vollständig, aber es ist gut genug für Demonstrationszwecke:

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

Die obige Funktion, func Avg(nos ...int) , gibt entweder Null oder den ganzzahligen Durchschnitt einer Reihe von Zahlen zurück, die ihr übergeben werden. Jetzt schreiben wir einen Test dafür.

In Go gilt es als bewährte Methode, eine Testdatei mit dem gleichen Namen wie die Datei zu benennen, die den zu testenden Code enthält, mit dem hinzugefügten Suffix _test . Der obige Code befindet sich beispielsweise in einer Datei namens avg.go , sodass unsere Testdatei avg_test.go .

Beachten Sie, dass diese Beispiele nur Auszüge aus tatsächlichen Dateien sind, da die Paketdefinition und Importe der Einfachheit halber weggelassen wurden.

Hier ist ein Test für die Avg Funktion:

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

Bei der Funktionsdefinition sind einige Dinge zu beachten:

  • Erstens das Präfix „Test“ im Namen der Testfunktion. Dies ist notwendig, damit das Tool es als gültigen Test aufnimmt.
  • Der letzte Teil des Funktionsnamens ist im Allgemeinen der Name der getesteten Funktion oder Methode, in diesem Fall Avg .
  • Wir müssen auch die Teststruktur namens testing.T , die eine Steuerung des Testablaufs ermöglicht. Weitere Einzelheiten zu dieser API finden Sie auf der Dokumentationsseite.

Lassen Sie uns nun über die Form sprechen, in der das Beispiel geschrieben ist. Eine Testsuite (eine Reihe von Tests) wird durch die Funktion Avg() , und jeder Test enthält eine bestimmte Eingabe und die erwartete Ausgabe. In unserem Fall sendet jeder Test ein Stück Ganzzahlen ( Nos ) und erwartet einen bestimmten Rückgabewert ( Result ).

Der Tabellentest hat seinen Namen von seiner Struktur, die einfach durch eine Tabelle mit zwei Spalten dargestellt wird: die Eingabevariable und die erwartete Ausgabevariable.

Golang Interface Spott

Eine der größten und leistungsstärksten Funktionen, die die Go-Sprache zu bieten hat, wird als Schnittstelle bezeichnet. Abgesehen von der Leistungsfähigkeit und Flexibilität, die wir durch Schnittstellen beim Entwerfen unserer Programme erhalten, bietet uns die Schnittstelle auch erstaunliche Möglichkeiten, unsere Komponenten zu entkoppeln und sie an ihrem Treffpunkt gründlich zu testen.

Eine Schnittstelle ist eine benannte Sammlung von Methoden, aber auch ein Variablentyp.

Nehmen wir ein imaginäres Szenario, in dem wir die ersten N Bytes von einem io.Reader lesen und als Zeichenfolge zurückgeben müssen. Es würde in etwa so aussehen:

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

Offensichtlich ist das Wichtigste, was zu testen ist, dass die Funktion readN bei verschiedenen Eingaben die richtige Ausgabe zurückgibt. Dies kann mit Tabellentests durchgeführt werden. Aber es gibt zwei andere nicht triviale Aspekte, die wir behandeln sollten, die das überprüfen:

  • r.Read wird mit einem Puffer der Größe n aufgerufen.
  • r.Read gibt einen Fehler zurück, wenn einer geworfen wird.

Um die Größe des an r.Read übergebenen Puffers zu kennen und die Kontrolle über den zurückgegebenen Fehler zu übernehmen, müssen wir das an readN übergebene r readN . Wenn wir uns die Go-Dokumentation zum Typ Reader ansehen, sehen wir, wie io.Reader aussieht:

 type Reader interface { Read(p []byte) (n int, err error) }

Das scheint ziemlich einfach. Alles, was wir tun müssen, um io.Reader zufrieden zu stellen, ist, dass unser Mock eine Read -Methode besitzt. Unser ReaderMock kann also wie folgt aussehen:

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

Lassen Sie uns den obigen Code ein wenig analysieren. Jede Instanz von ReaderMock erfüllt eindeutig die io.Reader Schnittstelle, da sie die erforderliche Read -Methode implementiert. Unser Mock enthält auch das Feld ReadMock , mit dem wir das genaue Verhalten der mockierten Methode festlegen können, was es für uns super einfach macht, alles, was wir brauchen, dynamisch zu instanziieren.

Ein großartiger speicherfreier Trick, um sicherzustellen, dass die Schnittstelle zur Laufzeit zufrieden ist, besteht darin, Folgendes in unseren Code einzufügen:

 var _ io.Reader = (*MockReader)(nil)

Dadurch wird die Assertion überprüft, aber nichts zugewiesen, wodurch wir sicherstellen können, dass die Schnittstelle zur Kompilierzeit korrekt implementiert ist, bevor das Programm tatsächlich auf eine Funktionalität stößt, die sie verwendet. Ein optionaler Trick, aber hilfreich.

Als nächstes schreiben wir unseren ersten Test, in dem r.Read mit einem Puffer der Größe n aufgerufen wird. Dazu verwenden wir unseren ReaderMock wie folgt:

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

Wie Sie oben sehen können, haben wir das Verhalten für die Read -Funktion unseres „falschen“ io.Reader mit einer Scope-Variablen definiert, die später verwendet werden kann, um die Gültigkeit unseres Tests zu bestätigen. Leicht genug.

Schauen wir uns das zweite Szenario an, das wir testen müssen, was erfordert, dass wir Read verspotten, um einen Fehler zurückzugeben:

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

In den obigen Tests gibt jeder Aufruf von mr.Read (unser verspottetem Reader) den definierten Fehler zurück, sodass davon ausgegangen werden kann, dass die korrekte Funktion von readN dasselbe bewirkt.

Funktion Mocking with Go

Es kommt nicht oft vor, dass wir eine Funktion mocken müssen, weil wir dazu neigen, stattdessen Strukturen und Schnittstellen zu verwenden. Diese sind leichter zu kontrollieren, aber gelegentlich können wir auf diese Notwendigkeit stoßen, und ich sehe häufig Verwirrung um das Thema. Einige Leute haben sogar gefragt, wie man Dinge wie log.Println verspotten kann. Obwohl es selten der Fall ist, dass wir Eingaben an log.Println testen müssen, werden wir diese Gelegenheit nutzen, um dies zu demonstrieren.

Betrachten Sie diese einfache if -Anweisung unten, die die Ausgabe abhängig vom Wert von n protokolliert:

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

Im obigen Beispiel nehmen wir das lächerliche Szenario an, in dem wir speziell testen, dass log.Println mit den richtigen Werten aufgerufen wird. Damit wir diese Funktion verspotten können, müssen wir sie zuerst in unsere eigene packen:

 var show = func(v ...interface{}) { log.Println(v...) }

Indem wir die Funktion auf diese Weise – als Variable – deklarieren, können wir sie in unseren Tests überschreiben und ihr jedes gewünschte Verhalten zuweisen. Implizit werden Zeilen, die sich auf log.Println beziehen, durch show ersetzt, sodass unser Programm zu:

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

Jetzt können wir testen:

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

Unser Mitbringsel sollte nicht 'mock log.Println ' sein, aber in diesen sehr gelegentlichen Szenarien, in denen wir aus legitimen Gründen eine Funktion auf Paketebene verspotten müssen, ist dies (soweit mir bekannt ist) die einzige Möglichkeit indem wir es als Variable auf Paketebene deklarieren, damit wir die Kontrolle über seinen Wert übernehmen können.

Wenn wir jedoch jemals Dinge wie log.Println verspotten müssen, kann eine viel elegantere Lösung geschrieben werden, wenn wir einen benutzerdefinierten Logger verwenden würden.

Gehen Sie zu Template-Rendering-Tests

Ein weiteres ziemlich häufiges Szenario besteht darin, zu testen, ob die Ausgabe einer gerenderten Vorlage den Erwartungen entspricht. Betrachten wir eine GET-Anforderung an http://localhost:3999/welcome?name=Frank , die den folgenden Text zurückgibt:

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

Falls es noch nicht offensichtlich genug war, es ist kein Zufall, dass der name des Abfrageparameters mit dem Inhalt der als „Name“ klassifizierten span übereinstimmt. In diesem Fall wäre der offensichtliche Test, zu überprüfen, ob dies jedes Mal korrekt über mehrere Ausgänge hinweg geschieht. Ich fand die GoQuery-Bibliothek hier sehr hilfreich.

GoQuery verwendet eine jQuery-ähnliche API, um eine HTML-Struktur abzufragen, was für das Testen der Gültigkeit der Markup-Ausgabe Ihrer Programme unerlässlich ist.

Jetzt können wir unseren Test folgendermaßen schreiben:

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

Zuerst überprüfen wir, ob der Antwortcode 200/OK war, bevor wir fortfahren.

Ich glaube, es ist nicht zu weit hergeholt anzunehmen, dass der Rest des obigen Codeschnipsels selbsterklärend ist: Wir rufen die URL mit dem http -Paket ab und erstellen aus der Antwort ein neues goquery-kompatibles Dokument, das wir dann zur Abfrage verwenden das zurückgegebene DOM. Wir überprüfen, span.name in h1.header-name den Text „Frank“ kapselt.

Testen von JSON-APIs

Go wird häufig zum Schreiben irgendeiner Art von APIs verwendet, also schauen wir uns zu guter Letzt einige High-Level-Methoden zum Testen von JSON-APIs an.

Überlegen Sie, ob der Endpunkt zuvor JSON statt HTML zurückgegeben hat, also würden wir von http://localhost:3999/welcome.json?name=Frank erwarten, dass der Antworttext in etwa so aussieht:

 {"Salutation": "Hello Frank!"}

Das Assertieren von JSON-Antworten unterscheidet sich, wie man vielleicht schon erraten hat, nicht wesentlich vom Assertieren von Vorlagenantworten, mit der Ausnahme, dass wir keine externen Bibliotheken oder Abhängigkeiten benötigen. Die Standardbibliotheken von Go sind ausreichend. Hier ist unser Test, der bestätigt, dass das richtige JSON für die angegebenen Parameter zurückgegeben wird:

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

Wenn etwas anderes als die Struktur zurückgegeben wird, für die wir dekodieren, gibt json.NewDecoder stattdessen einen Fehler zurück und der Test schlägt fehl. In Anbetracht der Tatsache, dass die Antwort erfolgreich gegen die Struktur dekodiert wurde, überprüfen wir, ob der Inhalt des Felds wie erwartet ist – in unserem Fall „Hallo Frank!“.

Einrichtung & Abbau

Das Testen mit Go ist einfach, aber es gibt ein Problem sowohl mit dem obigen JSON-Test als auch mit dem Template-Rendering-Test davor. Beide gehen davon aus, dass der Server ausgeführt wird, und dadurch entsteht eine unzuverlässige Abhängigkeit. Außerdem ist es keine gute Idee, gegen einen „Live“-Server vorzugehen.

Es ist nie eine gute Idee, mit „Live“-Daten auf einem „Live“-Produktionsserver zu testen; Erstellen Sie lokale Kopien oder Entwicklungskopien, damit kein Schaden entsteht, wenn Dinge schrecklich schief gehen.

Glücklicherweise bietet Go das Paket httptest zum Erstellen von Testservern an. Tests starten ihren eigenen separaten Server, unabhängig von unserem Hauptserver, und so wird das Testen die Produktion nicht beeinträchtigen.

In diesen Fällen ist es ideal, generische setup und teardown Funktionen zu erstellen, die von allen Tests aufgerufen werden, die einen laufenden Server erfordern. Nach diesem neuen, sichereren Muster würden unsere Tests in etwa so aussehen:

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

Beachten Sie die app.Handler() Referenz. Dies ist eine Best-Practice-Funktion, die den http.Handler der Anwendung zurückgibt, der entweder Ihren Produktionsserver oder einen Testserver instanziieren kann.

Fazit

Das Testen in Go ist eine großartige Gelegenheit, die äußere Perspektive Ihres Programms einzunehmen und in die Fußstapfen Ihrer Besucher oder in den meisten Fällen der Benutzer Ihrer API zu treten. Es bietet die großartige Gelegenheit, sicherzustellen, dass Sie sowohl guten Code als auch ein qualitativ hochwertiges Erlebnis liefern.

Wann immer Sie sich über die komplexeren Funktionalitäten in Ihrem Code nicht sicher sind, ist das Testen als Beruhigung praktisch und garantiert auch, dass die Teile weiterhin gut zusammenarbeiten, wenn Sie Teile größerer Systeme modifizieren.

Ich hoffe, dieser Artikel war hilfreich für Sie, und Sie können gerne kommentieren, wenn Sie weitere Testtricks kennen.