Mit Hilfe von Softwaretests können Sie Code auf die Erfüllung bestimmter, zuvor definierter Anforderungen prüfen und die Qualität einer Anwendung messen und langfristig sicherstellen. Unit-Tests spielen hierbei in der Software-Entwicklung eine entscheidende Rolle und sollten in jedem Projekt in ausreichender Zahl vorhanden und regelmäßig ausgeführt werden.
Golang bietet mit dem testing
-Package bereits in der Standardbibliothek die Möglichkeit Unit-Tests für Code zu erstellen und direkt auszuführen. Dieses Thema wurde bereits im Artikel Unit Tests und Benchmarks für Go erstellen gezeigt. In diesem Artikel stelle ich das Framework gomock vor, mit dem man Mock-Implementierungen erstellen und in eigenen Tests verwenden kann. Das Framework ist Teil des Golang-Team Bereichs von GitHub und kann von dort, wie wir uns weiter unten anschauen, installiert werden.
Für die Umsetzung von z. B. Microservices in Go bieten sich Unit-Tests mit Mock-Implementierungen an, da so eine hohe Testabdeckung erreicht und die Qualität der Anwendung nachhaltig verbessert werden kann.
Softwaremodule- oder Komponenten können oftmals nicht als (rein) eigenständige Einheiten betrachtet werden. Sie besitzen Abhängigkeiten zu anderen Komponenten oder Modulen und nutzen diese zur Erfüllung ihrer Aufgabe. Erst das Zusammenspiel mehrerer Komponenten ermöglicht die Bearbeitung größerer Aufgaben. Prinzipien wie z. B. devide and conquer bzw. Teile und herrsche fördern die Aufteilung der Anwendung in kleine beherrschbare Bestandteile, die als Ganzes komplexere Lösungen bieten.
Im Umkehrschluss heißt das, dass oftmals eine Komponente nur im Zusammenspiel mit ihren Abhängigkeiten sinnvoll und komplett getestet werden kann. Wie solche Tests erstellt werden können schauen wir uns im Folgenden an.
Das Klassendiagramm zeigt den klassischen Aufbau eines VehicleService
, der sich um die Verwaltung von Fahrzeugen kümmert und im Folgenden getestet werden soll. Die Ablage der Fahrzeuge geschieht innerhalb einer MongoDB-Datenbank durch eine entsprechende Implementierung des VehicleRepository
-Interfaces.
.
├── go.mod
├── go.sum
├── mongodb
│ └── VehicleRepository.go
├── VehicleService.go
Die Implementierung der Methode Create
sieht eventuell wie im folgenden Codeschnipsel aus. Der Parameter vom Typ Vehicle
wird auf eine vorhandene VIN geprüft und im Erfolgsfall wird das Fahrzeug mittels der Store
-Methode des VehicleRepository
gespeichert. Eine syntaktische Überprüfung des Wertes wäre hier sinnvoll, ist aber für die Übersichtlichkeit ausgelassen.
//NewVehicleService creates a new VehicleService with the given VehicleRepository
func NewVehicleService(repo VehicleRepository) VehicleService {
return VehicleService{repo}
}
//VehicleService manages Vehicles
type VehicleService struct {
repo VehicleRepository
}
//Create creates a new Vehicle in the data store
func (vs *VehicleService) Create(vehicle *Vehicle) (*Vehicle, error) {
if vehicle.Vin == "" {
return vehicle, errors.New("given Vehicle doesn't have a Vin")
}
return vs.repo.Store(vehicle)
}
Das komplette Beispiel kann auch in GitHub gefunden werden.
In einem einfachen Unit-Test ist es meist schwer eine Komponente völlig isoliert und vor Allem ohne Abhängigkeiten zu anderen Komponenten zu testen. Besitzt z.B. der VehicleService
, wie im Beispiel, eine VehicleRepository
-Implementierung, die sich um die Persistenz dieser Fahrzeuge kümmert, muss eventuell dieses VehicleRepository
innerhalb eines Unit-Tests angesprochen werden. Wie so etwas funktionieren kann, schauen wir uns gleich an.
Beim Testen kann zwischen state verification und behavior verification unterschieden werden. Bei der Überprüfung des Zustands wird für eine Komponente überprüft ob sich durch Methoden oder Funktionsaufrufe der Zustand der Komponente wie erwartet verändert hat. Die zweite Variante, die Prüfung des Verhaltens (behavior verification), prüft, wie der Name schon sagt, das Verhalten der getesteten Komponente.
Ein Zustandstest könnte wie dementsprechend so aussehen (Fehlerhandling absichtlich ausgeklammert):
func TestState(t *testing.T) {
//given
vehicleService := mocktest.NewVehicleService(&dummyRepo{})
dummyVIN := "12345678901234567"
vehicleToSave := &mocktest.Vehicle{Vin: dummyVIN}
//when
vehicle, _ := vehicleService.Create(vehicleToSave)
//then
//check errors
readVehicle, _ := vehicleService.Get(dummyVIN)
//check errors
if readVehicle == nil {
t.Errorf("no vehicle found")
return
}
//compare
if !reflect.DeepEqual(vehicle, readVehicle) {
t.Errorf("vehicles differ")
return
}
}
Der Test prüft die Komponente “von außen” und testet ob sich durch den Aufruf von Methoden der interne Zustand verändert hat.
Zuerst wird über die Create
-Methode versucht ein Fahrzeug zu speichern und danach wird überprüft, ob der Service dieses beim Aufruf der Methode Get
auch wieder ausliefert. Diese Prüfung erfolgt auf dem Zustand der Komponente.
Möchten Sie alternativ das Verhalten einer Komponente testen sieht der Test etwas anders aus. Es muss geprüft werden ob und eventuell wie abhängige Komponenten aufgerufen werden. Im Beispiel heißt das, dass geprüft wird, ob der VehicleService
beim Aufruf der Create
-Methode auch die Store
-Methode des VehicleRepository
aufruft. Das wäre das erwartete Verhalten. Zusätzlich kann der Test noch den übergebenen Parameter der Store
-Methode inhaltlich prüfen.
In folgendem Beispieltest wird nach dem Aufruf der Create
-Methode des VehicleService
überprüft, ob die Store
-Methode des Repository einmal aufgerufen wurde (die Aufrufanzahl des Repository ist mit 0 initialisiert). Für diesen Zweck wird eine spezielle, eigenentwickelte VehicleRepository
-Implementierung, ein sogenannter Mock, verwendet. Es wird das Verhalten des VehicleService
getestet.
func TestBehaviour(t *testing.T) {
//given
repo := &dummyRepo{storeCount: 0}
vehicleService := mocktest.NewVehicleService(repo)
dummyVIN := "12345678901234567"
vehicleToSave := &mocktest.Vehicle{Vin: dummyVIN}
//when
_, _ = vehicleService.Create(vehicleToSave)
//then
if repo.storeCount != 1 {
t.Error("repo should be called at least once")
return
}
}
Das Wort Mock oder auch Attrappe beschreibt in der Softwareentwicklung einen Programmteil, der Funktionalität vortäuscht. Dieses Vortäuschen an Funktionalität können wir uns innerhalb des Tests zu nutzen machen. Im Beispiel des Tests für den VehicleService
, wird eine Mock-Implementierung als VehicleRepository
-Implementierung eingesetzt um damit das Speichern der Fahrzeuge “vorzutäuschen”. Die Mock-Implementierung muss die passende Schnittstelle implementieren und kann dementsprechend als Ersatz oder Stellvertreter verwendet werden.
Die Mock-Implementierung im Test ist innerhalb der Testdatei selbstentwickelt und sehr rudimentär aufgebaut. Sie speichert intern wie oft die Store
-Methode aufgerufen wurde. Dieser Wert kann dann später innerhalb eines Tests, wie im Beispiel oben, abgefragt werden.
type dummyRepo struct {
vehicle *mocktest.Vehicle
storeCount int
}
func (dr *dummyRepo) Store(vehicle *mocktest.Vehicle) (*mocktest.Vehicle, error) {
dr.vehicle = vehicle
dr.storeCount++
return vehicle, nil
}
func (dr *dummyRepo) Get(string) (*mocktest.Vehicle, error) {
return dr.vehicle, nil
}
Diese simple Implementierung ist im obigen Test vielleicht noch ausreichend. Wenn allerdings viele solcher Mocks benötigt werden, kann es sehr zeitaufwändig werden diese zu erstellen. Falls man beginnt weitere Logik zur Prüfung in die Mock-Implementierung aufzunehmen werden auch diese fehleranfällig und können zu einem Problem der Testqualität werden.
Beim Einsatz eines Mock-Frameworks können entsprechende Mock-Implementierungen allerdings automatisch generiert und eingesetzt werden. Der Funktionsumfang dieser Implementierungen ist deutlich höher und sie bieten viel mehr Test- und Validierungsmöglichkeiten während eines Testlaufs.
Das wollen wir uns anhand von dem Mock-Framework gomock
näher anschauen.
Prüfen Sie den Einsatz von Mock-Frameworks genau, bevor sie eigene Mock-Implementierungen erstellen. Der Einsatz ist meist, wenn nicht immer, sinnvoll.
Das folgende Klassendiagramm zeigt zwei Implementierungen des VehicleRepository
-Interfaces. Eine Implementierung übernimmt die Ablage der Daten in eine NoSQL basierten MongoDB Datenbank. Bei der zweiten Implementierung handelt es sich um eine durch gomock
generierte Stellvertreter-Implementierung zum Testen, die wir uns näher betrachten wollen. Das Speichern der Daten in einer MongoDB werden wir in diesem Artikel nicht weiter betrachten.
Die Installation von gomock
läuft über ein einfaches go get
Kommando:
GO111MODULE=on go get github.com/golang/mock/mockgen@v1.5.0
Sie können die Installation auch ohne Angabe einer speziellen Version durchführen, allerdings bietet es sich an eine fixe Version zu verwenden und damit Probleme bei einem “plötzlichen” Update vorzubeugen.
Weitere Informationen zu gomock
findet Sie unter der Projektseite github.com/golang/mock.
Nachdem Sie gomock
installiert haben, können Mock-Implementierungen erstellt werden. Im Funktionsumfang von gomock
befindet sich hierzu ein Kommandozeilentool, das diese Aufgabe übernimmt.
Wenn Sie bereits das bin
Verzeichnis Ihres Go-Workspace im Ausführungspfad haben, können Sie mockgen
direkt aufrufen:
user@de35e3e73579:/app# mockgen
mockgen has two modes of operation: source and reflect.
Source mode generates mock interfaces from a source file.
It is enabled by using the -source flag. Other flags that
may be useful in this mode are -imports and -aux_files.
Example:
mockgen -source=foo.go [other options]
Reflect mode generates mock interfaces by building a program
that uses reflection to understand interfaces. It is enabled
by passing two non-flag arguments: an import path, and a
comma-separated list of symbols.
...
Wie in der Ausgabe zu erkennen, stehen zwei Modi zur Verfügung, auf welcher Basis die Mock-Implementierung generiert werden soll:
Für unser Beispiel werden wir die Variante über Interfaces, da wir bereits das VehicleRepository
-Interface besitzen, verwenden.
Die Generierung kann dementsprechend mit gleich folgendem Kommando gestartet werden. Als Parameter wird die VehicleService.go
Datei angegeben, in der sich das VehicleRepository
-Interface befindet. Ausgegeben werden soll das Ergebnis in die Datei mocks/VehicleRepository.go
und das Zielpackage soll mocks
heißen.
mockgen -source=VehicleService.go -destination=mocks/VehicleRepository.go -package mocks
Damit Sie sich das Kommando nicht merken, bzw. ein separates Skript pflegen müssen, bietet sich die Angabe einer go generate
Anweisung innerhalb der Test-Datei an. Somit werden dann beim Aufruf von go generate ./...
im Root-Verzeichnis der Anwendung sämtliche benötigten Quellen neu erstellt.
//go:generate mockgen -source=VehicleService.go -destination=mocks/VehicleRepository.go -package mocks
.
├── go.mod
├── go.sum
├── mocks
│ └── VehicleRepository.go
├── mongodb
│ └── VehicleRepository.go
├── VehicleService.go
Die generierte Mock-Implementierung kann nun in einem Unit-Test verwendet werden. Im Beispiel befinden sich die Tests in der Datei VehicleService_test.go
und um Abhängigkeitsprobleme einer zyklischen Referenz zu vermeiden werden die Tests in einem separatem Test-Package definiert, das sich im selben Verzeichnis wie das eigentliche Package befindet (siehe Statement weiter unten).
Der sogenannte Import Cycle entsteht, da der Test die Mock-Implementierung referenziert und die Mock-Implementierung wiederrum das Root-Package. Es würde folgender Fehler entstehen:
import cycle not allowed in test
. Achten Sie auf das separate Test-Package!
.
├── go.mod
├── go.sum
├── mocks
│ └── VehicleRepository.go
├── mongodb
│ └── VehicleRepository.go
├── VehicleService.go
└── VehicleService_test.go
Package-Angabe innerhalb der VehicleService_test.go
Datei für ein separates Test-Package:
package mocktest_test
Um eine durch gomock
erzeugte Mock-Implementierung zu nutzen, müssen Sie zuerst durch den Aufruf von gomock.NewController(t)
einen sogenannten Controller
anlegen, der später dem Mock übergeben wird. Er definiert den Rahmen und die Zeitspanne, in dem die Mocks verwendet werden können. Als Parameter der NewController
-Funktion wird der Pointer auf testing.T
übergeben, der widerrum als Parameter der Test-Funktion übergeben wird.
Im Anschluß können Sie durch entsprechende generierte Factory-Funktionen neue Mock-Implementierung instantiieren. Im Beispiel wird so eine VehicleRepository
-Mock-Implementierung durch mocks.NewMockVehicleRepository(ctrl)
erzeugt und verwendet. Eine Test-Funktion sieht dann dementsprechend aus:
Zur Erinnerung: Bei der Mock-Generierung wurde das
mock
Package als Ziel angegeben. Dieses muss entsprechend verwendet werden.
func TestSave(t *testing.T) {
//given
ctrl := gomock.NewController(t)
repo := mocks.NewMockVehicleRepository(ctrl)
calcService := mocktest.NewVehicleService(repo)
vehicle := &mocktest.Vehicle{"12345678901234567"}
//when
calcService.Create(vehicle)
//then
}
Die Ausführung dieses Tests scheitert allerdings mit folgendem Fehler:
VehicleService.go:30: Unexpected call to *mocks.MockVehicleRepository.Store([0xc000056510])
at .../golang.source-fellows.com/mocktest/mocks/repository.go:39 because:
there are no expected calls of the method "Store" for that receiver
Sie müssen der Mock-Implementierung noch mitteilen welches Verhalten von ihr erwartet wird.
gomock
nutzt hierzu die Methode EXPECT()
, die selbst wieder eine passende Mock-Implementierung zurückliefert. Diese implementiert allerdings nicht das VehicleRepository
-Interface. Es handlet sich um einen sogenannten Recorder
, dessen Schnittstelle, wie im Klassendiagramm zu erkennen, “offener” aufgebaut ist.
Der Einsatz des intface{}
-Types ermöglicht es sogenannte Matcher zu übergeben um die Parameterübergaben an die Methode zu prüfen. Eine Verhaltenserwartung können Sie bei gomock
so formulieren:
repo.EXPECT().Store(gomock.Any())
Zu lesen ist es so: “Erwarte einen Aufruf der Store
-Methode des VehicleRepository
-Mocks. Der Parameter beim Aufruf spielt keine Rolle. Alle Werte (gomock.Any()
) sind als Parameter ok.”
Auch Rückgabewerte können für die Mock-Implementierung angegeben werden. Hierzu bietet der MockVehicleRepositoryMockRecorder
die Methode Return
an.
repo.EXPECT().Store(gomock.Any()).Return(vehicle, nil)
Ein kompletter gomock
basierter Test kann dann so aussehen:
package mocktest_test
import (
"testing"
"github.com/golang/mock/gomock"
"golang.source-fellows.com/mocktest"
"golang.source-fellows.com/mocktest/mocks"
)
//go:generate mockgen -source=VehicleService.go -destination=mocks/VehicleRepository.go -package mocks
func TestSave(t *testing.T) {
//given
ctrl := gomock.NewController(t)
repo := mocks.NewMockVehicleRepository(ctrl)
calcService := mocktest.NewVehicleService(repo)
vehicle := &mocktest.Vehicle{"12345678901234567"}
repo.EXPECT().Store(gomock.Any()).Return(vehicle, nil)
//when
v, err := calcService.Create(vehicle)
//then
if err != nil {
t.Errorf("could not create because of %v", err)
return
}
if v == nil {
t.Errorf("no vehicle returned")
return
}
}
Das komplette Beispiel kann auch in GitHub gefunden werden.
Die MockVehicleRepositoryMockRecorder
-Implementierung bietet zusätzlich die Möglichkeit Stubs zu erzeugen und damit Werte bzw. Parameter aufzuzeichnen und im Test zu prüfen. Dies kann über die Do
-Methode des Recorders erreicht werden.
Hier ein Beispiel aus der offiziellen gomock Dokumentation:
package main
import (
"fmt"
"testing"
"github.com/golang/mock/gomock"
mock_sample "github.com/golang/mock/sample/mock_user"
)
func main() {
t := &testing.T{} // provided by test
ctrl := gomock.NewController(t)
mockIndex := mock_sample.NewMockIndex(ctrl)
var s string
mockIndex.EXPECT().Anon(gomock.AssignableToTypeOf(s)).Do(
// signature of anonymous function must have the same
// number of input and output arguments as the mocked method.
func(arg string) {
s = arg
},
)
mockIndex.Anon("foo")
fmt.Println(s)
}
Natürlich müssen Sie die Stub Implementierung nicht ausschließlich dafür einsetzen. So können Sie z. B. auch eigenes Testverhalten implementieren.
Wenn Sie die Reihenfolgen der Aufrufen testen wollen, können Sie das über die Methoden wie z. B. After
machen. Das folgende Beispiel ist auch aus der offiziellen Dokumentation übernommen und zeigt das Vorgehen:
firstCall := mockObj.EXPECT().SomeMethod(1, "first")
secondCall := mockObj.EXPECT().SomeMethod(2, "second").After(firstCall)
mockObj.EXPECT().SomeMethod(3, "third").After(secondCall)
Alternativ:
gomock.InOrder(
mockObj.EXPECT().SomeMethod(1, "first"),
mockObj.EXPECT().SomeMethod(2, "second"),
mockObj.EXPECT().SomeMethod(3, "third"),
)
Insgesamt bietet gomock
viele Möglichkeiten behavior verification tests mit Mocks zu implementieren.
Ich hoffe der Artikel macht Lust auf gomock. Viel Erfolg!
Hiernocheinmal weiterführende Links:
26.02.2021
Der Author auf LinkedIn: Kristian Köhler und Mastodon: @kkoehler@mastodontech.de
Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.
zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)
Source Fellows GmbH
Lerchenstraße 31
72762 Reutlingen
Telefon: (0049) 07121 6969 802
E-Mail: info@source-fellows.com