Code nicht laufend zu testen ist längst keine Option mehr, wer nicht testet, weiß weder, ob neue Features tatsächlich funktionieren, noch ob eine Änderung neue Fehler einbaut oder gar alte Wunden wieder aufreißt. Zwar mault der Go-Compiler schneller bei Typfehlern als Skriptsprachen dies üblicherweise tun, und strenge Typprüfung schließt ganze Batterien von Leichtsinnsfehlern von vorneherein aus. Doch statisches Abklopfen kann nie den reibungslosen Ablauf eines Programms garantieren, dazu muss eine Testsuite den Code realen Bedingungen unterwerfen und sehen, ob er sich zur Laufzeit erwartungsgemäß verhält.
Damit Entwickler nicht müde werden, die Testsuite immer wieder anzuwerfen, muss sie blitzschnell ablaufen. Und fällt im Werksbus zur Arbeit mal das Internet aus, sollte auch das für Unit-Tests kein Hindernis sein. Bauen Tests eine Verbindung zu einem Webserver auf oder brauchen eine angeschlossene Datenbank, läuft das dem Gedanken schneller unabhängiger Tests zuwider. Da aber kaum ein ernstzunehmendes Projekt nur allein vor sich hinrödelt, gilt es Abhängigkeiten zu externen Systemen abzufedern und diese durch potempkische Dörfer zu ersetzen. Diese "Mocks" genannten Simulanten spielen für die Test-Suite die Rolle echter Kommunikationspartner, nehmen Anfragen entgegen, und liefern vorprogrammierte Antworten zurück.
Listing 1 zeigt eine kleine Library mit der Funktion Webfetch()
, die zu Testzwecken einen URL entgegegennimmt und den Inhalt der sich dahinter verbergenden Seite zurückliefert. Was kann beim Einholen einer Webseite alles falsch laufen? Zunächst könnte der angegebene URL nicht dem standartisierten Format entsprechen. Dann könnte es Probleme bei der Kontaktaufnahme mit dem Server geben: Fehler bei der DNS-Auflösung, Netzwerk-Timeouts, oder der Server nimmt sich gerade eine Auszeit. Vielleicht verweist die angegebene URL auch auf kein gültiges Dokument des Servers, und dieser antwortet mit 404, oder er fordert zum Beispiel mit 304 einen Redirect an. All diese möglichen Fehler prüft der Code in Listing 1 und gibt im Fehlerfall jeweils einen Fehler des Typs error
zurück. Zapft der Client ab Zeile 23 endlich den Strom der eintrudelnden Bytes an, kommt es vor, dass dieser plötzlich aussetzt, weil die Netzverbindung zusammenbricht. All diese Fälle sollte ein guter Client abfangen und eine gute Testsuite sollte verifizieren, dass der Client dies auch in allen Situationen tut.
01 package webfetcher 02 03 import ( 04 "fmt" 05 "io/ioutil" 06 "net/http" 07 ) 08 09 func Webfetch(url string) (string, error) { 10 resp, err := http.Get(url) 11 12 if err != nil { 13 return "", err 14 } 15 16 if resp.StatusCode != 200 { 17 return "", fmt.Errorf( 18 "Status: %d", resp.StatusCode) 19 } 20 21 defer resp.Body.Close() 22 23 body, err := ioutil.ReadAll(resp.Body) 24 if err != nil { 25 return "", fmt.Errorf( 26 "I/O Error: %s\n", err) 27 } 28 return string(body), nil 29 }
Nun läuft die Testsuite aber wie gesagt eventuell auch auf Systemen, die über keinen zuverlässigen Internetanschluss verfügen, und nichts ist nerviger als eine Testsuite, die mal ordnungsgemäß Dienst tut und mal nicht. Um solche Abhängigkeiten zu entfernen, ersetzen Testsuiten externe Systeme oft durch Strohmänner. Beim sogenannten "Mocking" ahmen einfache Testgerüste bestimmte Fähigkeiten externer Systeme perfekt reproduzierbar nach. Dazu kommt zum Beispiel ein vereinfachter lokaler Webserver zum Einsatz, der nur statische Seiten ausliefern kann oder nur Fehlercodes meldet. Go kann dank seiner quasi gleichzeitig laufenden Goroutinen sogar im gleichen Programm einen Server laufen lassen und muss dazu keinen externen Prozess starten. Das ist ungeheuer praktisch, denn das ganze zeitaufwändige und auch noch fehleranfällige Brimborium zum ordnungsgemäßen Starten und vor allem Stoppen externer Prozesse entfällt.
01 package webfetcher 02 03 import ( 04 "fmt" 05 "net/http" 06 "net/http/httptest" 07 "testing" 08 ) 09 10 const ContentString = "Hello, client." 11 12 func Always200(w http.ResponseWriter, 13 r *http.Request) { 14 w.WriteHeader(http.StatusOK) 15 fmt.Fprint(w, ContentString) 16 } 17 18 func TestWebfetchOk(t *testing.T) { 19 srv := httptest.NewServer( 20 http.HandlerFunc(Always200)) 21 content, err := Webfetch(srv.URL) 22 23 if err != nil { 24 t.Errorf("Error on 200") 25 } 26 27 if content != ContentString { 28 t.Errorf("Expected %s but got %s", 29 ContentString, content) 30 } 31 }
So prüft Listing 2, ob die Funktion Webfetch()
aus Listing 1 im Erfolgsfall, wenn der Server eine Textdatei ausliefert wie angepriesen funktioniert. Dazu definiert es ab Zeile 18 die Funktion TestWebfetchOk()
, die als Parameter einen Pointer auf eine Struktur vom Typ testing.T
erhält, die es weiter unten nutzt, um der Testsuite eventuell auftretende Fehler zu melden. Zu beachten ist, dass Go viel auf Konventionen hält und alles davon abweichende störrisch ignoriert. So müssen die Namen aller Testdateien auf _test.go
enden. So kann webfetch_200_test.go
nicht etwa webfetch_test_200.go
heißen, denn sonst findet das im Verzeichnis aufgerufene Kommando "go test" auf einmal keine Tests zum Ausführen mehr. Die Namen der Testroutinen der Testsuite müssen mit "func TestXXX" beginnen, sonst droht ein ähnliches Debakel. Und schließlich gilt in Go die Regel "ein Paket pro Verzeichnis", alle drei Go-Programme, die Library webfetch.go
sowie die beiden Dateien mit den Unit-Tests, führen im Code eingangs package webfetch
an.
Aus der konventionsgemäß definierten Funktion TestWebfetchOk()
in Listing 2 wird so ein Teil der Testsuite, und aus den Fehlerprüfungen in den if-Statements der Zeilen 23 und 27 deren Testfälle. Zeile 23 verifiziert, dass der Server auch einen Statuscode 200 geschickt hat und Zeile 27 vergleicht den empfangenen String mit dem in Zeile 10 vorgegebenen ("Hello, client."). In beiden Fällen schweigt die Testsuite still, falls alles glatt lief, und meldet nur auftretende Fehler. Wer's lieber etwas gesprächiger mag, kann mit t.Logf()
jeweils vor dem Testfall eine Nachricht ausgeben und im Erfolgsfall ebenfalls Meldung machen. So wie in Listing 2 implementiert, druckt die mit "go test -v" im Verbose-Modus aufgerufene Testsuite im Erfolgsfall einfach nichts über die einzelnen Testfälle, sondern meldet nur die ausgeführten Testfunktionen (Abbildung 1). Ginge aber etwas schief, kämen die mit t.Errorf()
ausgeworfenen Fehlermeldungen zu Tage.
Abbildung 1: Die Testsuite prüft sowohl den Erfolgs- wie den Fehlerfall der Webfetch-Bibliothek. |
Das Verhalten des eingebauten Test-Webservers definiert der Handler Always200()
ab Zeile 12 in Listing 2. Egal wie der eingehende Request vom Typ *http.Request
aussieht, gibt er einfach den Status-Code http.StatusOK
(also 200) im Header der HTTP-Antwort zurück und schickt den String "Hello, client." als Inhalt der Seite hinterher. Zeile 19 wirft den eigentlichen Webserver aus dem Paket httptest
an und gibt ihm den vorher definierten Handler als Funktion mit, die sie vorher in den Typ http.HandlerFunc
konvertiert. Auf welchem Host und Port der neue Server lauscht, gibt er im Attribut URL
an, welches Zeile 21 an die zu testende Client-Funktion Webfetch()
als URL übergibt. Zurück kommt wie erwartet ein Status-Code 200 und der vorher eingestellte String, also bringt die Testsuite keinen Fehler hoch.
Aber auch Fehlerfälle sollte Webfetch()
ordnungsgemäß behandeln. Um dies zu prüfen, definiert Listing 3 den Handler Always404()
, der den Webserver anweist, auf jede Anfrage den Status-Code 404 und eine Seite leeren Inhalts zum Client zu senden. Flugs in den neuen Webserver ab Zeile 15 eingebaut, erhält Webfetch()
nun "Nicht Gefunden"-Meldungen vom Server, und stellt dies in den if-Bedingungen ab Zeile 19 in Listing 3 sicher.
01 package webfetcher 02 03 import ( 04 "net/http" 05 "net/http/httptest" 06 "testing" 07 ) 08 09 func Always404(w http.ResponseWriter, 10 r *http.Request) { 11 w.WriteHeader(http.StatusNotFound) 12 } 13 14 func TestWebfetch404(t *testing.T) { 15 srv := httptest.NewServer( 16 http.HandlerFunc(Always404)) 17 content, err := Webfetch(srv.URL) 18 19 if err == nil { 20 t.Errorf("No error on 404") 21 } 22 23 if len(content) != 0 { 24 t.Error("Content not empty on 404") 25 } 26 }
Doch nicht immer stehen elegante Inline-Server mit konfigurierbaren Handlern zur Verfügung, was tun, wenn zum Beispiel ein System eine Datenbank braucht? Hier ist schon in der Designphase des Hauptprogramms darauf zu achten, dass dessen Abhängigkeit von der Datenbank nicht festgebacken irgendwo im Innern des Systems sitzt, sondern sich von außen einstellen lässt. Das Verfahren heißt "Dependency Injection" und steckt neuen Objekten bei der Konstruktion Strukturen zu, die externe Ziele definieren. Das kann bei größeren Software-Architekturen zu wahren Irrgärten an Abhängigkeiten führen, weswegen die Firmen Uber und Google schon Pakete zu deren Bewältigung geschrieben haben ([2], [3]).
Um dem Endanwender einer Library möglichst wenig Kopfzerbrechen zu bereiten, würden viele Entwickler im ersten Ansatz versuchen, möglichst viel Details verstecken. Ein Storage-Service für Namen, namestore
mit angeschlossener Datenbank wie in Listing 4 würde zunächst gar nicht offenbaren, dass eine Datenbank im Spiel ist und sie hinter dem Vorhang einfach anlegen und manipulieren.
01 package main 02 03 import ( 04 ns "namestore" 05 ) 06 07 func main() { 08 nstore := ns.NewStore() 09 nstore.Insert("foo") 10 }
Allerdings hat dies fatale Folgen für Unit-Tests, die über die Schnittstelle des Pakets namestore
nun nichts mehr tricksen können, um zum Beispiel statt einer aufwändig zu installierenden MySQL-Datenbank eine testfreundliche SQLite-Datenbank oder gar einen Treiber für ein CSV-Format als Backend zu nutzen. Besser ist es beim Design, die Abhängigkeiten (wie zum Beispiel die verwendete Datenbank) dem Kontruktor von der Nutzerseite her mitzugeben, wie in Listing 5. Dort öffnet der Library-User die Datenbank (in diesem Fall SQLite) und reicht das Datenbank-Handle dem Konstruktor des namestore
-Objekts, der es dann für Zugriffe nutzt.
01 package main 02 03 import ( 04 "database/sql" 05 _ "github.com/mattn/go-sqlite3" 06 ns "namestore" 07 ) 08 09 func main() { 10 db, err := 11 sql.Open("sqlite3", "names.db") 12 if err != nil { 13 panic(err) 14 } 15 16 nstore := ns.NewStore(ns.Config{Db: db}) 17 nstore.Insert("foo") 18 }
Die Implementierung einer solchen Unit-Test-freundlichen Library zeigt Listing 6. Zeile 8 definert die Struktur vom Typ Config
, die der Konstruktor NewStore()
ab Zeile 12 entgegen nimmt. Letzterer braucht nur einen Pointer darauf zurückzugeben, den die Insert()
-Methode ab Zeile 16 nutzt diese Datenstruktur als Receiver und erhält so Zugang auf die vom User eingestellte Datenbankverbindung über config.Db
.
01 package namestore 02 03 import ( 04 "database/sql" 05 _ "github.com/mattn/go-sqlite3" 06 ) 07 08 type Config struct { 09 Db *sql.DB 10 } 11 12 func NewStore(config Config) (*Config) { 13 return &config 14 } 15 16 func (config *Config) Insert( 17 name string) { 18 stmt, err := config.Db.Prepare( 19 "INSERT INTO names VALUES(?)") 20 if err != nil { 21 panic(err) 22 } 23 24 _, err = stmt.Exec(name) 25 if err != nil { 26 panic(err) 27 } 28 29 return 30 }
Dank Unit-Test-freundlichem Design ist so gewährleistet, dass Tests ohne zusätzlichen Installationsaufwand auskommen und blitzschnell durchlaufen.
Wer liest schon Manualseiten? Am interessantesten sind doch funktionierende Anwendungsbeispiele, die man direkt kopiert und flugs an die örtlichen Gegebenheiten anpasst. Diese stehen meist weiter unten in den Instruktionen, sodass viele User erstmal ganz nach unten blättern. Doch leider kommt es vor, dass diese Anwendungsbeispiele gar nicht (mehr) funktionieren, weil der Entwickler den Code geändert hat und vergessen hat, den entsprechenden Abschnitt auf der Manualseite nachzuziehen. Abhilfe schafft hier Gos automatische Manualseitenerstellung mit in die Unit-Test-Suite eingebetteten Beispielen.
1 package myhello 2 3 import ( 4 "fmt" 5 ) 6 7 func SayHello() { 8 fmt.Println("hello") 9 }
1 package myhello 2 3 func ExampleSayHello() { 4 SayHello() 5 // Output: hello 6 }
Abbildung 2: Dokumentation mit Beispiel |
Abbildung 2 zeigt, dass der Anwender in der Web-Version der Manualseite automatisch generierte, getestete Beispiele zu Gesicht bekommt. Das Kommando
$ godoc -http=:6060
startet einen Webserver auf Port 6060, und wer einen Browser auf http://localhost:6060/pkg
ausrichtet, kann zur Manualseite des Pakets manövrieren. Klickt der User auf den abwärts zeigenden Pfeil neben dem Wort "Example", öffnet sich ein Abschnitt, der sowohl den Beispielcode als auch das auf der Standardausgabe erwartete Ergebnis zeigt. Der Clou is nun dabei, dass dieses Beispiel automatisch aus dem Testcode des Pakets in Listing 8 generiert wurde, und bei jedem Ablauf der Testsuite mit "go test" die tatsächliche Ausgabe des Beispielcodes mit der angegebenen verglichen wird -- so bleibt die Dokumentation immer auf dem aktuellen Stand!
Das Ganze funktioniert, indem Go die Funktion ExampleSayHello()
wegen des Präfixes Example
in der Test-Datei (Suffix _test
) als dokumentationswürdiges Anwendungsbeispiel erkennt und die Web-Version des godoc
-Kommandos sie dort aufnimmt. Den mit // Output:
eingeleiteten Kommentar in Zeile 5 der Testdatei interpretiert Go per Konvention als erwartete Ausgabe und go test
führt tatsächlich ExampleSayHello()
aus und prüft, ob auch "hello"
auf der Standardausgabe erscheint. Zusammen mit dem automatisch aus Kommentarzeilen in der Library-Datei in Listing 7 erstellten Dokumentation ergibt sich selbstprüfende Dokumentation. Ein klarer Gewinn für Programmierer, die ungern Instruktionen lesen und gerne einfach drauflos kopieren, aber auch für Software-Maintainer, die sich so peinliche Fehler ersparen, wie wenn gleich das erste Anwendungsbeispiel der Dokumentation nicht mehr funktioniert.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/07/snapshot/
"Dig: A reflection based dependency injection toolkit for Go", https://github.com/uber-go/dig
"Compile-time Dependency Injection for Go", https://github.com/google/wire
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc