Testgetriebene Softwareentwicklung mit Golang

golang.jpg

Go ist eine Sprache, die ihren Schwerpunkt auf hohe Performance, einfaches Handling von Nebenläufigkeit, geringen Speicherbedarf und geringe Kompilierzeit legt. Außerdem bringt Go einen Garbage-Collector und ein sehr reduziertes Vererbungsmodell mit.

Go ist ideal geeignet um hochverfügbare und performante Webanwendungen zu erstellen. Außerdem ist es sehr angenehm, dass die erzeugten Anwendungen - üblicherweise Docker-Images - unerwartet klein sind. Ein Go-Docker-Image ist in der Regel kleiner als 20MB und kommt mit sehr wenig Ram aus. Wo eine Java Anwendung mit Spring Boot oder eine native Servlet-Implementierung direkt nach dem Start bereits 300 MB RAM verbraucht, ohne dass auch nur ein Request verarbeitet wurde, braucht die Go Anwendung weniger als 2 MB.

In den letzten Jahren hat sich herausgestellt, dass ein automatisiertes Testing zur Sicherstellung von Codequalität als auch zur Beibehaltung der Funktionalität unerlässlich ist. Außerdem sollten die Tests das ganze System als Blackbox testen - ohne einzelne Elemente durch Mocks zu ersetzen oder Delegationen zu testen. Es interessiert nicht, wie die Anwendung zu dem Ergebnis kommt, sondern dass das Ergebnis korrekt ist.

Man kann Datenbanken zu Testzwecken hochfahren. Allerdings kostet das immer Ausführungszeit und hindert daran die Persistenzschicht zu verändern - sprich die Datenbanktechnologie zu wechseln. Daher sollten Zugriffe auf Daten über Repositories - ganz im Sinne von Domain-driven Design - gekapselt werden. Dann kann man Zugriffe auf Daten, als auch auf Fremdsysteme mit Test-Alternativimplementierungen versehen.

Aus Sprachen/Frameworks wie Java EE, Spring Boot und Kotlin ist man es gewöhnt die Services für Zugriffe auf Datenbanken oder Fremdsysteme durch Test-Alternativ-Implementierungen mittels Dependency-Injection oder Service-Discovery Mechanismen auszutauschen.

Go bietet so etwas erst einmal nicht. Um Integrationstest in geeigneter Form umsetzen zu können, müssen solche Mechanismen selbst geschaffen werden.

Üblicherweise besteht der Einstiegspunkt in einer Go-Webanwendung in der Registrierung von Handler-Functions, die sich um die Verarbeitung eines Endpoints kümmern.

func handler(w http.ResponseWriter, r *http.Request) {
   _, _ = fmt.Fprintf(w, "Hello world!")
}

func main() {
   http.HandleFunc("/", handler)
   log.Fatal(http.ListenAndServe(":8080", nil))
}

Allerdings hat man so keine Möglichkeit die Abhängigkeit zu Fremdsystemen bei der Ausführung der Businesslogik zu steuern.

Um das zu ermöglichen führen wir das Konzept der Umgebung (Environment) ein.
Ein Environment definiert alle Schnittstellen zu Datenbanken und externen Services. Die Businesslogik arbeitet ausschließlich mit den durch die Umgebung bereitgestellten Schnittstellen und kennt die konkreten Implementierungen nicht.

Es werden dann Ausprägungen für Test- und Produktivbetrieb implementiert.
Dann kann die Ausführung steuern, welche der Umgebungsimplementierungen die Businesslogik verwenden soll.

Implementierungsbeispiel

Der folgende Code soll das Prinzip verdeutlichen und ist nicht vollständig.
Statt Funktionssignatur func(http.ResponseWriter, *http.Request) soll die Businesslogik diese Signatur implementieren: func(Environment, http.ResponseWriter, *http.Request)
Go bietet die Möglichkeit einen Handler zu implementieren. Im Handler legen wir die Umgebung und eine Funktion mit der gewünschten Signatur ab.

type Handler struct {
   Environment     Environment
   HandlerFunction func(e Environment, w http.ResponseWriter, r *http.Request)
}

Die Implementierung der ServerHttp Funktion des allgemeinen Handlers ruft die gewünschte Businesslogik-Funktion auf und übergibt dabei die Umgebung.
In diesem Beispiel ist auch noch eine rudimentäre Fehlerbehandlung implementiert.

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   defer func() {
      if rec := recover(); rec != nil {        
         http.Error(w, fmt.Sprintf(`{"errorMessage": "%s"}`, rec), http.StatusInternalServerError)        
      }
  }()

   h.HandlerFunction(h.Environment, w, r)
}

In diesem Beispiel besteht die Umgebung aus einem Datenbank-Repository und einem externern Service:

type DataRepository interface {
   FindData(key string) string
}

type ExternalServiceAdapter interface {
   CallService(options string) string
}

type Environment interface {
   GetDataRepository() DataRepository
   GetExternalService() ExternalServiceAdapter
}

Test- und Produktivausprägungen können nach diesem Schema implementiert werden:

type ProductionEnvironment struct {
   dataRepository *productionDataRepository
   externalServiceAdapter *productionExternalServiceAdapter
}

func NewProductionEnvironment() *ProductionEnvironment {
   return &ProductionEnvironment{
      newDataRepository(),
      newExternalServiceAdapter(),      
   }
}

Im Einstiegspunkt setzt man nun die Produktivversion der Umgebung:

func main() {
   environment := production.NewProductionEnvironment()

   http.Handle("/find", Handler{environment, BusinessLogicHandler})
   log.Fatal(http.ListenAndServe(":8080", nil))   
}

Die zu prüfende Fachlogik könnte so aussehen:

func BusinessLogicHandler(env project.Environment, w http.ResponseWriter, r *http.Request) {
   keyToFind := r.URL.Query().Get("key")
   value := env.GetDataRepository().FindData(keyToFind)
   _, _ = fmt.Fprintf(w, fmt.Sprintf(`{"value": "%s"}`, value))
}

In den Tests kann nun die Testversion der Umgebung benutzt werden.
Die Testimplementierung kann zusätzliche Methoden bieten, um die Testdaten zu manipulieren. Z.B. könnte die Repository-Testimplementierung die Daten in Listen oder Maps halten. Dadurch kann man den Test relativ einfach in einen gewünschten Zustand bringen.

func Test_FindKeyInDatabase(t *testing.T) {
   environment := test.NewTestEnvironment[FS1] ()
   environment.TestDataRepository.Store("keyValue", "valueInDatabase")

   handlerToTest := project.Handler{environment, BusinessLogicHandler}
   test.TestGetRequest(t, "/find?key=keyValue", `{"value": "valueInDatabase"}`, handlerToTest, http.StatusBadRequest)
}

Die Implementierungen der Produktiv-Repositories und Services sollten via Unit-Test geprüft werden, um hier keine Überraschungen zu erleben.

Gopher image by Renee French, licensed under Creative Commons 3.0 Attributions license.