Testen mit Go (Linux-Magazin, Juli 2019)

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.

Was schiefgehen kann

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.

Listing 1: webfetch.go

    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 }

Ohne Brimborium

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.

Listing 2: webfetch_200_test.go

    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 }

Konventionen

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.

Keine falschen Fehler

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.

Listing 3: webfetch_404_test.go

    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]).

Injektion: Gleich piekt's

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.

Listing 4: main-wrong.go

    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.

Listing 5: main.go

    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.

Listing 6: namestore.go

    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.

Anhand von Beispielen

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.

Listing 7: example.go

    1 package myhello
    2 
    3 import (
    4     "fmt"
    5 )
    6 
    7 func SayHello() {
    8     fmt.Println("hello")
    9 }

Listing 8: example_test.go

    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.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/07/snapshot/

[2]

"Dig: A reflection based dependency injection toolkit for Go", https://github.com/uber-go/dig

[3]

"Compile-time Dependency Injection for Go", https://github.com/google/wire

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc