Testen von Golang Anwendungen mit Mocks

Testen von Golang Anwendungen mit Mocks

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.

Was sind Mocks überhaupt

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.

Beispielservice zum Testen

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.

UML-Klassendiagramm Go VehicleService

UML-Klassendiagramm Go VehicleService

.
├── 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.

Varianten zum Testen

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.

State verification test / Zustandstest

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.

Behavior verification test / Verhaltenstest

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
	}

}

Mock-Implementierung zum Testen

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.

Mock-Implementierung von gomock

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.

Klassendiagramm Go Service mit gomock

Klassendiagramm Go Service mit gomock

Installation von gomock

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.

Generierung einer Mock-Implementierung

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:

  • Generierung aus Interfaces
  • Generierung über Reflection

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

Einsatz von gomock in Unit-Tests

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.

Klassendiagramm gomock Generierung

Klassendiagramm gomock Generierung

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.

Weitere Features von gomock

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 Twitter: