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:

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:

return int(term1*(term2-term3) + 0.5)

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.


1. Es gibt auch Befürworter des Ansatzes: erst einen Test schreiben, dann die Funktion einbauen. Das zu diskutieren würde aber leider über den Rahmen des Buchs hinaus gehen.
2. Mach das bitte nicht für die erste Implementierung, der Rechner würde eine gefühlte Ewigkeit benötigen, um die Funktion für eine hohe Zahl auszuführen