Testen und optimieren von Programmen
Im Laufe der Jahre hat sich die Erkenntnis durchgesetzt, dass automatisch durchgeführte Softwaretests die Qualität der Softwareentwicklung stark erhöhen kann. Das hat mehrere Gründe:
- Die Entwicklung von Software ist nur ein Teil ihres Lebenszyklus. Meist wird Software gewartet. Es müssen Fehler behoben werden, die Funktionalität soll erweitert oder an neue Gegebenheiten angepasst werden. Doch fast immer sollen schon bestehende Programme weiterhin mit der Software zusammenarbeiten. Um sicherzustellen, dass sich Schnittstellen in ihrem Verhalten nicht ändern, werden automatische Tests eingesetzt.
- Das Erstellen von Tests gleichzeitig mit der eigentlichen Software stärkt das Bewusstsein für die getesteten Funktionen. Dadurch entdeckt man häufig Fehler, ohne einen Debugger zu benutzen.
- In der Entwicklungsphase wird man den Code häufig refaktorisieren, also Variablen und Funktionen umbenennen und zusammenfassen, Programmteile neu schreiben und so fort. Durch automatische Softwaretest stellt man sicher, dass dabei keine Fehler passieren.
Kurzum: Softwaretest sind eine sehr gute Idee.
Man wird in der Praxis entweder zu wenig testen, oder zu viel. Bestimmte Situationen wird man übersehen. Viele Dinge lassen sich nicht, oder nur mit viel Aufwand überprüfen. Daher: bitte nicht abschrecken lassen. Ein Test ist besser als keiner, zwei Tests sind besser als einer und so fort. Mit kleinen Schritten kommt man sehr gut voran. |
Der Idealfall fürs Testen der Software während des Einsatzes sieht so aus: wenn ein Fehler im Programm entdeckt wird, schreibt man erst einmal einen passenden Softwaretest, der den Fehler auslöst. Dann wird der Fehler korrigiert und alle Tests sollten wieder problemlos durchlaufen. Dadurch stellt man sicher, dass derselbe Fehler nicht noch einmal versehentlich eingebaut wird.
Unittests mit Go
Die Entwickler von Go haben für automatisierte Softwaretests schon vorgesorgt und entsprechende Funktionalität in die Werkzeuge eingebaut. Go unterstützt die gebräuchlichen Unittests oder auf Deutsch: Komponententests. Dabei werden einzelne Funktionen oder Methoden eines Pakets aufgerufen und das Ergebnis mit einem vorher bestimmten Soll-Ergebnis verglichen. Weichen die beiden voneinander ab, kann der Anwender eine Fehlermeldung zurückgeben.
In Go werden Test auf Paketebene ausgeführt. Die Tests gehören immer zu dem Paket, das getestet wird. Die Beispiele hier werden etwas umfangreicher, da wir immer mindestens zwei Dateien erstellen müssen. Die Implementierung ist stets unabhängig von den Test. Die Tests sollen ja nicht ausgeführt werden, wenn wir den »Produktions-Code« erstellen.
Die automatischen Tests werden wie folgt aufgerufen:
go test paketname
Es werden nun alle Dateien aus dem angegebenen Paket gesucht mit der
Dateiendung _test.go
und ausgeführt. Als Beispiel soll ein Paket fib
dienen, in dem auf unterschiedliche Weisen Funktionen implementiert werden,
die Fibonacci-Folge generieren. Die Fibonacci-Folge ist die Folge der Zahlen
0, 1, 1, 2, 3, 5, 8, …, in der jede Zahl die Summer der beiden
Vorgängerzahlen ist. Es gibt einige Anwendungen in der Informatik, die auf
diese Zahlenfolge aufbauen.
Für Tests wird die Go-typische Verzeichnishierarchie benutzt:
mkdir -p src/fib
Die Variable GOPATH
muss nun auf das Verzeichnis zeigen, in dem das
src
-Verzeichnis liegt.
Als erstes[1] wird eine »normale«
Quelltext-Datei für das Paket fib
mit einer Funktion erstellt:
package fib
// naive Implementierung, entspricht der Definition
func Fib1(n int) int {
if n < 2 {
return n
}
return Fib1(n-1) + Fib1(n-2)
}
Unter welchem Namen die Datei gespeichert wird ist fast egal, z.B.
src/fib/fib.go
. Sie sollte nur nicht mit _test.go
enden, da diese
für die automatischen Tests vorbehalten sind. Da wir einen Test schreiben
wollen, erstellen wir eine Datei mit einem entsprechenden Namen, z.B.
src/fib/fib_test.go
:
package fib
import "testing"
func TestFib1(t *testing.T) {
if Fib1(6) != 8 {
t.Error("Fib1(6) muss 8 ergeben!")
}
}
Hinweis: die Funktionen, die die Tests implementieren, müssen mit Test
anfangen und können danach beliebige Zeichen enthalten, das erste Zeichen muss
aber ein Großbuchstabe sein. test_multiplizieren
wäre zwar ein gültiger
Funktionsname in Go, das Test-Framework erkennt aber anhand des Namens ob die
Funktion beim Aufruf von go test
mit ausgeführt werden soll. Ein Aufruf von
$ go test fib
ok fib 0.038s
zeigt, dass alles in Ordnung ist.
Wir haben aber nur einen Wert überprüft. Wenn möglich, sollt aber ein größerer Wertebereich getestet werden. Um nicht für jeden Wert eine eigene Testfunktion zu implementieren, speichert man einfach die erwarteten Werte in einer Liste und überprüft Element für Element, ob die Funktion korrekt arbeitet.
package fib
import "testing"
var results = []int{0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987}
func TestFib1(t *testing.T) {
for i, erwartet := range results {
if ergebnis := Fib1(i); ergebnis != erwartet {
t.Errorf("Fib1(%d) = %d, erwartet: %d",
i, ergebnis, erwartet)
}
}
}
Jetzt können wir versuchen, unsere Funktion Fib1
zu optimieren. Wenn wir die
Funktion genau anschauen, sehen wir, dass jeder Aufruf der Funktion zwei neue
Funktionsaufrufe generiert, sofern die Variable n
größer ist als 1. Das mag
für kleine Werte von n noch akzeptabel sein, für große Werte ist eine
dauernde Verdoppelung der Funktionsaufrufe sehr ungünstig. Bei n=20 sind es
schon über eine Millionen Funktionsaufrufe, bei n=30 eine Milliarde. Hier
gibt es verschiedene Möglichkeiten der Optimierung, die ausprobiert werden
sollen:
-
Berechnung mit einer
for
-Schleife - Direktes berechnen der Fibonacci-Zahl
- Speichern schon berechneter Werte
Da wir den Test erstellt haben, können wir leicht überprüfen, ob unsere neuen
Implementierungen der Fibonacci-Funktion korrekt arbeiten. Die zweite Funktion
berechnet die Zahlenfolge in einer einfachen for
-Schleife. In die schon
erstellte Datei src/fib/fib.go
fügen wir eine weitere Funktion ein:
func Fib2(n int) int {
if n < 2 {
return n
}
a := 1
b := 0
for i := 2; i < n; i++ {
a, b = a+b, a
}
return a + b
}
Vermutlich ist es nicht jedem Leser sofort klar, dass diese Funktion das
gewünschte Ergebnis liefert. Daher überprüfen wir das mit unserem Test (hier
aber angewandt auf die neue Funktion Fib2
):
func TestFib2(t *testing.T) {
for i, erwartet := range results {
if ergebnis := Fib2(i); ergebnis != erwartet {
t.Errorf("Fib2(%d) = %d, erwartet %d",
i, ergebnis, erwartet)
}
}
}
Wir starten jetzt den Test mit -v
um zu sehen, dass der Test auch wirklich
ausgeführt wird:
$ go test -v fib
=== RUN TestFib1
--- PASS: TestFib1 (0.00 seconds)
=== RUN TestFib2
--- PASS: TestFib2 (0.00 seconds)
PASS
ok fib 0.242s
Benchmarks
Es ist vielleicht nicht ersichtlich, ob die Funktion Fib2()
schneller arbeitet, als die erste Implementierung. Praktischerweise bietet Go
auch hierfür eine Hilfe an. In der Testdatei können auch Benchmarks definiert
werden, die eine Funktion wiederholt ausführen und die benötigte Zeit je
Durchlauf angeben. Für Fib1()
und Fib2()
schreiben wir in die schon
erstellte Test-Datei zwei Benchmarks:
func BenchmarkFib1(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib1(30)
}
}
func BenchmarkFib2(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib2(30)
}
}
Die Benchmark-Funktionen haben eine ähnliche Namensgebung wie die Test-
Funktionen. Sie müssen mit Benchmark
anfangen und als Argument ein Objekt
vom Typ *testing.B
akzeptieren.
Die Implementierung der Benchmark-Funktion ist auf den ersten Blick etwas
ungewöhnlich. Es wird eine Schleife N-Mal ausgeführt in der Fib1(30)
berechnet wird. Wie groß der Wert N ist, bestimmt Go selbständig. Er ist
mindestens so groß, dass ein aussagekräftiger Durchschnittswert gebildet werden
kann, aber maximal so groß, dass die Ausführungszeit möglichst klein bleibt.
Wie groß N ist, wird beim Durchlauf der Benchmarks ersichtlich:
$ go test -bench . fib
PASS
BenchmarkFib1 500 6617664 ns/op
BenchmarkFib2 100000000 24.6 ns/op
ok fib 6.681s
Fib1(30)
wird 500 Mal aufgerufen, jede Ausführung dauert hier 6,6 Mio.
Nanosekunden. Die andere Funktion wird entsprechend häufiger aufgerufen, weil
sie nur knapp 25 Nanosekunden benötigt. Man muss kein Experte sein, um den
Vorteil der zweiten Implementierung zu sehen.
Benchmarks sind immer mit Vorsicht zu genießen. Implementierungsfehler oder Compileroptimierungen können die Messungen sehr stark beeinflussen. Man sollte die Werte mit kritischem Blick betrachten und überprüfen, ob die Werte stimmen können und ob sie überhaupt aussagekräftig sind. Außerdem ist nicht jede schnellere Funktion auch die bessere. Wenn in einem Prozess, der mehrere Minuten andauert, wenige Millisekunden gespart werden, ist das nicht unbedingt hilfreich. Richtig angewendet können Benchmarks aber eine große Hilfe sein, wie in unserem Beispiel. |
Für große Werte bietet es sich an, die Fibonacci-Zahlen direkt zu berechnen. Dazu gibt es die Formel von Moivre-Binet:

Diese Formel lässt sich direkt in Go implementieren. Für die Wurzel- und Exponentialfunktionen muss das Paket math
eingebunden werden:
package fib
import "math"
// Fib1 und Fib2 wie oben
func Fib3(n int) int {
// alle Funktionen aus dem math-Paket arbeiten mit float64
nfloat := float64(n)
term1 := 1 / math.Sqrt(5.0)
term2 := math.Pow((1+math.Sqrt(5.0))/2.0, nfloat)
term3 := math.Pow((1-math.Sqrt(5.0))/2.0, nfloat)
return int(term1 * (term2 - term3))
}
Die Implementierung ergibt sich direkt aus der Formel. Da die Funktionen aus
dem Paket math
nur den Datentyp float64
verarbeiten, wird der Übergabewert
erst in diesen Datentyp gewandelt. Die Testfunktion ist wie bei Fib1()
und
Fib2()
. Rufe sie einfach mal auf:
$ go test -run Fib3 fib
--- FAIL: TestFib3 (0.00 seconds)
fib_test.go:29: Fib3(12) sollte 144 sein, es wurde aber 143 berechnet
fib_test.go:29: Fib3(13) sollte 233 sein, es wurde aber 232 berechnet
fib_test.go:29: Fib3(16) sollte 987 sein, es wurde aber 986 berechnet
FAIL
FAIL fib 0.031s
Mit -run Fib3
wird nur die Testfunktion TestFib3()
ausgewählt.
Interessanterweise funktioniert die Berechnung für einige Werte nicht. Wenn
wir die Funktion Fib3()
genau anschauen, sehen wir in der letzten Zeile die
Konvertierung der float64
Datentypen zurück in int
. Die Umwandlung
schneidet Kommazahlen ab (ist also im Prinzip die mathematische
floor
-Funktion). Da aber Gleitkommazahlen oft keine exakten Werte darstellen
können, in dem Beispiel oben sehr wahrscheinlich ganz knapp unterhalb der
gewünschten Werte sind, müssen wir zuerst aufrunden um anschließend die
Nachkommastellen abzuschneiden. Daher ändern wir die letzte Zeile der Funktion in:
Ein anschließender Test mit go test fib
zeigt, dass alles in Ordnung ist.
Bevor wir die Geschwindigkeit der Funktion testen, möchte ich eine Technik präsentieren, die die Berechnung der Fibonacci-Folge zumindest für kleine Zahlen erheblich beschleunigt. Wenn wir uns die berechneten Zahlen merken, können wir sie später einfach wieder benutzen:
// Paket-globale Variable
var merken map[int]int
func init() {
merken = make(map[int]int)
}
func Fib4(n int) int {
if n < 2 {
return n
}
if m := merken[n]; m != 0 {
return m
}
fib := Fib4(n-1) + Fib4(n-2)
merken[n] = fib
return fib
}
Die globale Variable wird in der Funktion init()
initialisiert. Wie in
Funktionen schon beschrieben, wird diese Funktion aufgerufen, wenn
das Paket geladen wird. Damit ist die Variable im ganzen Paket sichtbar
(außerhalb nicht, da sie mit einem Kleinbuchstaben anfängt). Wir benutzen sie
nur in der Funktion Fib4()
und speichern in ihr die berechneten Werte.
Fib4()
ist fast identisch mit unserer ersten, naiven Implementierung mit dem
Unterschied dass die Rekursion nur genutzt wird, wenn die Zahl noch nicht
berechnet wurde.
Die Benchmarks unserer vier Implementierungen sehen wie folgt aus:
$ go test -bench . fib
PASS
BenchmarkFib1 500 6550161 ns/op
BenchmarkFib2 100000000 23.9 ns/op
BenchmarkFib3 20000000 129 ns/op
BenchmarkFib4 100000000 20.9 ns/op
ok fib 11.421s
In unseren Benchmarks werden die Funktionen mit dem Wert 30 aufgerufen. Wenn du den Wert für die zweite, dritte und vierte Implementierung erhöhst[2], stellst du folgendes fest:
Der Wert für Fib2()
erhöht sich nur ein wenig, der Wert für Fib3()
sollte
konstant bleiben und Fib4()
wird für das künstliche Szenario im Benchmark
lange Zeit die schnellste Funktion sein.