Unit Tests und Benchmarks für Go erstellen

Unit Tests und Benchmarks für Go erstellen

Mit Hilfe von Software-Tests lässt sich Code auf die Erfüllung bestimmter, zuvor definierter, Anforderungen prüfen und die Qualität einer Anwendung messen und sicherstellen.

Durch eine (zusätzliche) kontinuierliche Ausführung der Tests z. B. im Rahmen einer Kontinuierliche Integration lässt sich die Qualität somit auch nachhaltig verbessern und die Wartungskosten minimieren.

In welchem Rahmen die Tests geschrieben werden spielt, technisch gesehen, eine untergeordnete Rolle. So können die Tests im Rahmen eines Test-Driven Developments vorab oder “klassisch” parallel bzw. nachträglich implementiert werden. Wichtig sollte sein, dass Tests geschrieben und regelmäßig ausgeführt werden.

Der Artikel geht auf Beispiele in Go ein und zeigt wie man eine Go Anwendung testet.

Keep the bar green to keep the code clean!

Test mit Go

In der Golang Standardlibrary befindet sich das package testing mit dessen Hilfe Test in Go geschrieben werden. Die Tests können dann auf Kommandozeile mit dem Kommando go test ausgeführt werden.

Im Artikel soll im ersten Schritt folgende Add Funktion getestet werden:

package main

import "fmt"

func Add(first int, second int) int {
    return first + second
}

func main() {
    fmt.Printf("Hello World, %d \n", Add(10, 10))
}

Die Funktion ist mit Absicht einfach gehalten und soll nicht durch ihre eigene Komlexität vom Testing mit Go ablenken.

Startet man hier go test wird das mit folgender Ausgabe quittiert:

?       github.com/kkoehler/golang/testing      [no test files]

Unit-Test erstellen

Unit-Tests müssen bei Go in separate Dateien implementiert werden und die Dateiendung _test.go besitzen. Sie können sich im gleichen Verzeichnisbaum wie die Quelldateien befinden und müssen nicht wie z. B. bei Java/Maven in einen separaten Ast gelegt werden.

Je nachdem was man testen möchte empfiehlt es sich die Test Dateien entweder im gleichen Package oder in einem zweiten Package abzulegen. Möchte man z. B. die externe Schnittstelle des Package testen, kann es Sinn machen den Test außerhalb des Packages zu halten, da man so wie ein “echter” Client des Packages agieren muss. Möchte man interne Details testen ist die Ablage im gleichen Package sinnvoll. Eine verbreitete Empfehlung ist die Unit-Tests im gleichen Package abzulegen.

Bei Go heißt es eigentlich ein Verzeichnis, ein Package. Bei Tests ist das nicht ganz der Fall. In einem Verzeichnis kann zusätzlich ein Test-Package angelegt werden. Dieses muss den Suffix _test besitzen. Möchte man z. B. für das package add ein Test-Package anlegen, so nennt man es im gleichen Verzeichnis package add_test.

Tests werden nicht beim normalen Build mit übersetzt und entsprechend auch nicht ausgeliefert. Sie werden nur bei go test übersetzt und ausgeführt.

Einzelne Tests werden in Test-Methoden implementiert. Diese müssen folgende Aufbau besitzen:

  • Der Methodenname muss dem Muster TestXxx entsprechen, wobei Xxx nicht mit einem Kleinbuchstaben beginnen darf.
  • Genau ein Parameter vom Typ *testing.T

Im Beispiel kann ein Test so aussehen:

package main

import "testing"

func TestAdd(t *testing.T) {

}

Nutzt man z. B. Visual Studio Code und die Golang Extension kann man auch direkt in der IDE die Tests ausführen. Wie im Screenshot zu sehen werden über der Testfunktion Links zur Ausführung eingeblendet.

Testing Go in VSCode

Testing Go in VSCode

Assertions

Die Macher von Go haben sich explizit gegen die Einführung von speziellen assert Methoden, wie sie z. B. in Java’s JUnit oder Node.js Mocha vorhanden sind, entschieden. Man wollte keine “separate Sprache” für Tests entwickeln, die man sich zusätzlich erlernen muss. Es sollte möglichst einheitlich programmiert werden können.

Fehler in Tests kann man mit t.Error bzw. t.Errorf oder t.Fail melden. t.Error meldet einen Fehler, bricht den Test aber nicht ab. t.Fail bricht die aktuelle Ausführung ab.

package main

import "testing"

func TestAdd(t *testing.T) {

	result := Add(10, 10)

	if result != 20 {
        t.Errorf("Wrong result. Got %d but wanted 20", result)
	}

}

Eine Ausführung des Test mittels go test führt dann zu:

PASS
ok      <..>/src/github.com/kkoehler/golang/testing 0.001s

Die Ausführung mittels go test -v liefert zusätzliche Informationen zur Testausführung:

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/kkoehler/golang/testing      0.001s

Table-Driven Tests

Eine Möglichkeit mehrere Testfälle mit nur geänderten Parameter durchzuführen bieten Table-Driven Tests. Hierbei werden die Parameter in einem Slice gespeichert und die Tests über eine Schleife abgearbeitet. So lassen sich recht schnell viele Szenarien mit wenig Code durchspielen. Durch die Nutzung von t.Error laufen auch alle Tests ohne dass die Ausführung beim ersten Fehler abgebrochen wird.

func TestTableAdd(t *testing.T) {

	tables := []struct {
		first int
		y     int
		n     int
	}{
		{1, 1, 2},
		{1, 2, 3},
		{2, 2, 4},
		{5, 2, 7},
	}

	for _, table := range tables {
		total := Add(table.first, table.y)
		if total != table.n {
			t.Errorf("Sum of (%d+%d) was incorrect, got: %d, want: %d.", table.first, table.y, total, table.n)
		}
	}

}

Kontrolle über Ausführung

Bei der Ausführung von Tests kann man mit dem run Parameter steuern welche Tests ausgeführt werden sollen. Dem angegebenen Pattern entsprechende Tests werden ausgeführt. In folgendem Beispiel werden alle Tests ausgeführt, die dem Pattern Add entsprechen.

go test -run Add

Möchte man die Ausführung der Table Driven Tests auch feingranularer steuern kann man die t.Run Methode nutzen. Die t.Run Methode benötigt zwei Parameter. Zum einen den Namen des Tests und zum Anderen die auszuführende Testmethode.

Die Erweiterung des oberen Beispiels sieht dann so aus:

func TestTableAdd(t *testing.T) {

	tables := []struct {
		name  string
		first int
		y     int
		n     int
	}{
		{"one and one", 1, 1, 2},
		{"one and two", 1, 2, 3},
		{"two and two", 2, 2, 4},
		{"five and two", 5, 2, 7},
	}

	for _, table := range tables {

		t.Run(table.name, func(t *testing.T) {
			total := Add(table.first, table.y)
			if total != table.n {
				t.Errorf("%v: Sum of (%d+%d) was incorrect, got: %d, want: %d.", table.name, table.first, table.y, total, table.n)
			}
		})

	}

}

Eine Ausführung mit go test -v führt dann zu folgender Ausgabe:

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestTableAdd
=== RUN   TestTableAdd/one_and_one
=== RUN   TestTableAdd/one_and_two
=== RUN   TestTableAdd/two_and_two
=== RUN   TestTableAdd/five_and_two
--- PASS: TestTableAdd (0.00s)
    --- PASS: TestTableAdd/one_and_one (0.00s)
    --- PASS: TestTableAdd/one_and_two (0.00s)
    --- PASS: TestTableAdd/two_and_two (0.00s)
    --- PASS: TestTableAdd/five_and_two (0.00s)
PASS
ok      github.com/kkoehler/golang/testing      0.001s

Auch hier lässt sich mit dem run Parameter die Ausführung einschränken.

Coverage Reports

Ein wichtiger Bestandteil bei der Prüfung der eigenen Test-Qualität ist die Bestimmung der Testabdeckung. Welcher Code wird durch einen Test überprüft und welcher nicht. Das Test Kommando von Go bietet hierzu die Option -cover mit der ein Coverage Report erstellt werden kann. Dieser kann dann zu einem HTML Bericht umformatiert werden.

go test -cover -coverprofile=c.out
go tool cover -html=c.out -o coverage.html
Coverage Report einer Go Anwednung

Coverage Report einer Go Anwednung

In obigen Beispiel liegt die Testabdeckung, wie man sieht, bei 50%. Die nichtabgedeckten Stellen sind rot markiert.

Benchmark

Zusätzlich zur Unit-Test Funktionalität des package testing besteht die möglichkeit Benchmarks zu implementieren und die Performance des Go Codes zu überprüfen.

Wer tiefer in das Profiling von Go Anwendungen einsteigen möchte, sollte noch den Blog Eintrag Profiling Go Programs anschauen.

Benchmarks werden wie Tests in den _test.go Dateien implementiert und nutzen ebenso Funktionalität des package testing. Die Hauptunterschiede eines Benchmarks zu einem Unit-Test sind:

  • Funktionsnamen beginnen mit Benchmark anstelle von Test
  • Benchmark Funktionen werden mehrmals ausgeführt bis der Benchmark Runner mit der Stabilität des Ergebnisses zufrieden ist.
  • Jede Funktion muss den Test b.N mal ausführen.

Ein einfaches Beispiel sieht so aus:

func BenchmarkAdd(b *testing.B) {

	for n := 0; n < b.N; n++ {
		Add(10, 10)
	}

}

Die Ausführung von go test --bench . führt dann zu folgendem Ergebnis:

goos: linux
goarch: amd64
BenchmarkAdd-8          2000000000               0.53 ns/op
PASS
ok      _/../github.com/kkoehler/golang/testing 1.120s

Im Ergebnis wird, neben der Ausgabe der Umgebung, dargestellt, dass auf der Testmaschine die Add Funktion im Durchschnitt 0.53 Nanosekunden zur Ausführung benötigt hat.

Auch bei den Benchmark Funktionen können die gleichen Parameter wie oben beschrieben zur Einschränkung der Funktionen benutzt werden.

Fazit

Wie dargestellt können mit Go recht einfach Unit-Tests erstellt und ausgeführt werden. Mit der selben Funktionalität lassen sich Benchmarks umsetzen.

Das komplette Beispiel kann auch in GitHub gefunden werden.

06.03.2019

 

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com