Testing in Go einfach gemacht. So geht's.

28.07.2021 Ricky Elfner
Cloud DevOps Go Testing Distributed Systems Kubernetes DevOps Framework Hands-on

In dem heutigen Beitrag beschäftigen wir uns mit dem Testing von Go-Applikation. Dies ist der zweite Teil in unserer 'Go exklusiv'-Reihe. Letzte Woche hat Raffael die bekannte und oft gebrauchte Cobra-Library präsentiert und wie man damit in nur 15 Minuten ein vollwertiges CLI-Tool schreiben kann.

Heute aber geht's ums Testing. Zuerst sei gesagt, dass Go mit seiner Standard-Library bereits ein offizielles testing Package mit den notwendigen Tools zur Verfügung stellt. Damit kann man Unit Tests ohne grossen Aufwand gleich out-of-the-box schreiben. Dabei zeigen wir Ihnen zunächst einmal die Grundlagen und anschliessend die verschiedenen Testarten, sowie weitere hilfreiche Informationen und Optionen.

Grundlegendes zuerst

Die Datei

Wenn Sie Unit Tests schreiben wollen, sollten diese sich innerhalb des Package befinden, welches sie testen möchten. Haben Sie Beispielsweise das Package hello mit der Datei hello.go so müssen Sie ein weiteres .go-File anlegen mit dem Suffix _test.go.

└── pkg
    └── hello
        ├── hello.go
        └── hello_test.go

So erkennt Go, dass es sich um ein Test-File handelt und werden dadurch auch von den normalen Package Builds ausgeschlossen.

Die Funktion

Auch bei den Namen müssen Sie sich an eine vordefinierte Namenskonvention halten. Dabei muss die Funktion mit dem Präfix Test beginnen und mit einem Grossbuchstaben weitergeführt werden.

func TestHelloName(t *testing.T) {...}

Durch dieses Präfix werden auch die zwei Testarten die standardmässig bereitgestellt werden unterschieden. Bei Test-Cases die mit Test beginnen handelt es sich um die üblichen Unit-Tests, beginnt der Namen jedoch mit Benchmark, handelt es sich wie bereits vermutet um Benchmark-Tests. Zu den unterschieden und Beispielen werden wir später noch eingehen.

Ausführen

Sobald Sie ihre Unit-Tests geschrieben haben, wollen Sie dieses natürlich auch ausführen. Hier zu gibt es den Befehl go test. Dabei können Sie wählen, ob alle Tests, nur ein gewünschtes Package oder nur ein bestimmter Test ausgeführt werden soll.

Möchten Sie alles Tests ausführen, wird dieser wie folgt aus dem Workspace ausgeführt:

go test ./...

Wenn Sie sich in dem Package befinden, welches Sie testen möchten und darin alle Tests ausgeführt haben wollen reicht dies:

go test

Selbstverständlich gibt es auch den Fall, dass Sie nur einen ganz bestimmten Test ausführen wollen, dazu müssen Sie sich in dem entsprechenden Package befinden und den Namen der Funktion angeben:

go test -run TestHelloEmpty

Um bei all diesen Befehlen genauere Informationen zu bekommen, können Sie die Option -v nutzen. Wichtig ist noch zu wissen, dass Go die Testergebnisse Cached. Damit wird verhindert, dass alle Tests immer wieder durchlaufen müssen, obwohl diese bereits erfolgreich waren. Wenn Sie zuvor den Test Cache löschen können Sie sicher sein, dass alle Test durchlaufen:

go clean -testcache [pacakge(s)]

Eine weitere Möglichkeit um den Cache zu deaktivieren, ist das setzen über die GOCACHE Environment Variable mit dem Wert off.

Test-Coverage

Innerhalb der Standard Library von Go steht Ihnen auch die Möglichkeit einen Bericht über die Testabdeckungen zu bekommen. Hierfür können Sie im ersten Schritt einen Coverage Report erstellen und im zweiten Schritt können Sie dienen in einen lesefreundlichen HTML-Bericht formatieren.

go test -coverprofile=cover.txt
go tool cover -html=cover.txt -o cover.html

Der Bericht sieht dann beispielsweise wie folgt aus:

Test Arten

Unit

Wie bereits erwähnt muss ein Unittest mit dem Präfix Test beginnen, damit dieser korrekt erkannt wird. Eine Testfunktion in Go hat immer nur einen einzigen Parameter t *testing.T.

Des weiteren gibt es keine Assertions innerhalb von Go, wie es in anderen Programmiersprachen bekannt ist. Der Grund hierfür ist relative simple, denn dadurch wird auf eine weitere 'Sprache' verzichtet und verlässt sich auf die internen Mittel die bereits zur verfügung gestellt werden.

Wenn Sie nun Tests schreiben wollen, wird innerhalb von Go mit IF-Bedingungen gearbeitet. Sollten diese ungleich sein, gibt es zwei, bzw. vier, Möglichkeiten diesen Error zu werfen.

  • t.Error* | t.Errorf* → gibt fehlerhaften Test an, führt die folgenden Test jedoch weiter aus

  • t.Fatal* | t.Fatalf* → gibt fehlerhaften Test an, stoppt jedoch den aktuellen Test sofort

Dabei hat die Funktion mit dem f zusätzlich die Möglichkeit den Text nach eigenen Wünschen zu formatieren. Welche Funktion verwendet werden, soll kommt immer ganz auf Ihren eigenen Testfall an.

Schauen wir uns dies am besten an einem Beispiel an. Dabei wollen wir die Funktion Hello() testen, welche eine Begrüssungsnachricht zurückliefert mit dem Namen, der als Parameter übergeben wird.
Zu erst legen wir das gewünschte Ergebnis fest, dies soll in dem Beispiel Hi, b-nova! lauten. Im Anschluss wird die zu testende Methode aufgerufen und b-nova als Parameter übergeben. Danach wird mithilfe einer If-Bedingung überprüft, ob der Rückgabewert der Methode dem Soll-Zustand entspricht. Wenn nicht, wird ein Error ausgegeben.

func TestHelloName(t *testing.T) {
	want := "Hi, b-nova!"
	msg, err := hello.Hello("b-nova")
	if msg != want || err != nil {
		t.Fatalf(`Want: %v --> return value: %v`, want, msg)
	}
}

Mit dem Befehl go test -v -run TestHelloNamekönnen Sie diesen Test nun ausführen. Dieser wird erfolgreich durchlaufen:

> go test -v -run TestHelloName
> === RUN   TestHelloName
> --- PASS: TestHelloName (0.00s)
> PASS
> ok      hello-world/pkg/hello   0.269s

Sollte der Test jedoch Fehlschlagen, in dem Sie beispielsweise einen anderen Parameter der Funktion übergeben, gibt es diese Meldung:

> go test -v -run TestHelloName
> === RUN   TestHelloName
>   hello_test.go:25:  Want: Hi, b-nova! --> return value: Hi, max!
> --- FAIL: TestHelloName (0.00s)
> FAIL
> exit status 1
> FAIL    hello-world/pkg/hello   0.310s

Benchmark-Tests

Wenn Sie einen Benchmark-Test schreiben muss der Name immer mit dem Präfix Benchmark beginnen, anstatt mit Test. Dabei gibt es weitere Unterschiede zu den normalen Unittests, denn diese werden mit dem Flag -bench ausgeführt. Dadurch werden die Tests sequentiell ausgeführt und laufen mehrmals, bis ein stabiles Ergebnis vorhanden ist. Dies wird durch durch den For-Loop und der Variable b.N erreicht. Ebenfalls ändert sich der Parameter zu b *testing.B.

func BenchmarkHello(b *testing.B) {
   for n := 0; n < b.N; n++ {
      hello.Hello("b-nova")
   }
}

Der Benchmark-Test kann gestartet werden mit dem Flag -bench und dem entsprechenden Package:

go test -bench=.

Innerhalb des Ergebnisses werden Ihnen einige weitere Information bereitgestellt. Mit goos wird das Betriebssystem und mit goarch wird die Architektur des Testsystems angegeben. Des weiteren sehen Sie welches Package getestet wird und um was für eine CPU es sich handelt. Danach wird die Funktion angegeben mit der zusätzlichen Information auf wie vielen Kernen dieser Test ausgeführt wurde, in diesem Fall auf 16 Kernen. Die darauffolgende Zahl bestimmt die Anzahl der Durchläufe, die durch b.Ndefiniert sind. Und zum Schluss wird noch die durchschnittliche Zeit pro Operation ausgegeben.

> goos: darwin
> goarch: amd64
> pkg: hello-world/pkg/hello
> cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
> BenchmarkHello-16            10509346               101.1 ns/op
> PASS
> ok      hello-world/pkg/hello   1.332s

Table-Driven Tests

Wenn Sie schon des Öfteren Unit Tests geschrieben haben, wissen Sie bestimmt, dass sich oft nur die Parameter ändern. Damit Sie nun nicht immer wieder den gesamten Test neu schreiben müssen, gibt es in Go die Möglichkeit Table-Driven Tests zu schreiben. Dies erspart Ihnen lästiges wiederholen von gleichem Test Funktionen.

Dafür sollten Sie sich zu nächst einen strukturierten Datentyp Case für Ihren Testfall anlegen.

type Case struct {
   name    string
   want   string
}

Sobald dies erledigt ist, können Sie innerhalb der Testfunktion ein Slice anlegen von dem zuvor erstellten Datentyp und festlegen, welche Werte sie erwarten.

func TestHelloAll (t *testing.T) {
   cases := []Case{
      Case{
         name: "b-nova",
         want: "Hi, b-nova!",
      },
      Case{
         name: "",
         want: "",
      },
   }

   ...
   
}

Danach können Sie mittels einer For-Schleife über alle Einträge des Slices gehen und somit alle Tests durchführen.

func TestHelloAll (t *testing.T) {
   
   ...

   for _,c := range cases {
		msg, _ := hello.Hello(c.name)
		if msg != c.want {
			t.Errorf(`Want: %v --> return value: %v`, c.want, msg)
		}
	}
}

Da in diesem Beispiel auch wieder alle Tests korrekt sind, bekommen Sie diese Ausgabe im Terminal:

> === RUN   TestHelloAll
> --- PASS: TestHelloAll (0.00s)
> PASS

Für eine bessere Übersicht, welcher Test gerade durch gelaufen ist, gibt es noch t.Run(). Dazu benötigt diese Methode zwei Parameter. Zum einen den Namen der Methode, sowie die Funktion die getestet werden soll.

func TestHelloAll (t *testing.T) {
   
   ...

   for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			msg,_ := hello.Hello(c.name)
			if msg != c.want {
				t.Errorf("expected: %v, got: %v", c.want, msg)
			}
		})
	}
}

Dabei unterscheidet sich die Ausgabe zum vorherigen Test, dass die Namen, die Sie der t.Run() Funktion übergeben, werden angezeigt werden. Somit können Sie genau wissen, welche Tests durchgelaufen sind.

> === RUN   TestHelloAll
> --- PASS: TestHelloAll (0.00s)
> === RUN   TestHelloAll/b-nova
>     --- PASS: TestHelloAll/b-nova (0.00s)
> === RUN   TestHelloAll/#00
>     --- PASS: TestHelloAll/#00 (0.00s)
> PASS

Nice-To-Know

Assertions

Da es, wie zu Beginn erwähnt keine Assertions gibt, findet man mittlerweile einige Alternativen. Hierzu gehört vor allen testify.

Dieses können Sie mit go get github.com/stretchr/testify installieren. Wie in anderen Sprachen üblich stehen folgende Assertions nun zur Verfügung:

  • assert.Equal()

  • assert.NotEqual()

  • assert.Nil()

  • assert.NotNil()

Die Funktion TestHelloName() welche Sie als Beispiel in dem Abschnitt Unit-Tests gesehen haben, können Sie nun auch auf diese Art und Weise schreiben:

func TestHelloNameAssertEqual(t *testing.T) {
   want := "Hi, b-nova!"
   msg, _ := hello.Hello("b-nova")

   assert.Equalf(t, msg, want, `Want: %v --> return value: %v`, want, msg)
}

Ein weiterer Vorteil ist auch die Ausgabe der Fehler im Terminal, die durch dieses Package bereitgestellt werden. Dies sieht durch die testify-Funktionen so aus:

> === RUN   TestHelloNameAssertEqual
>     hello_test.go:22: 
>         	Error Trace:	hello_test.go:22
>         	Error:      	Should not be: "Hi, b-nova!"
>         	Test:       	TestHelloNameAssertEqual
>         	Messages:   	Want: Hi, b-nova! --> return value: Hi, b-nova!
> --- FAIL: TestHelloNameAssertEqual (0.00s)
> 
> FAIL

Testdaten

Wenn Sie Tests schreiben gibt es auch den Fall, dass Sie Testdaten benötigen, welche sich in einer externen Datei befinden. Dafür legen Sie innerhalb des Package, in dem sich auch die Tests befinden ein Ordern mit dem Namen testdata an. Dieser wird nämlich nicht bei den normalen Builds mit eingeschlossen. Anschliessend können Sie mit os.Open() die Datei öffnen und verwenden. Ein Test mit diesen Daten könnte wie folgt aussehen:

func TestReadData(t *testing.T){
   file, err := os.Open("./testdata/data.txt")

   if assert.NotNil(t, file) && assert.Nil(t, err) {
      defer file.Close()

      scanner := bufio.NewScanner(file)

      for scanner.Scan() {
         want := "this is my first row"
         msg := scanner.Text()
         assert.Equalf(t, msg, want, `Want: %v --> return value: %v`,msg, want)
      }

      if err := scanner.Err(); err != nil {
         log.Fatal(err)
      }
   }
}

Fazit

So, wir haben gesehen wie man flott mit der Standard-Library testing Unittests schreiben kann. Auch die Verwendung mit einer spezialisierten Library die das Testing an die gewohnte Ausgabe und Handling von Java-Tests erinnert ist schnell und einfach gewährleistet.

Ricky Elfner – Denker, Überlebenskünstler, Gadget-Sammler. Dabei ist er immer auf der Suche nach neuen Innovationen, sowie Tech News, um immer über aktuelle Themen schreiben zu können.