Sind Tests in der Entwicklung ein Begriff für dich? Wenn ja, hast du bestimmt schon mal den Begriff TDD gehört, aber was versteckt sich eigentlich hinter dieser Abkürzung? Diesem Thema will ich den heutigen Artikel widmen. Um es kurz einzuleiten, TDD steht ausgeschrieben für Test Driven Development, also auf Deutsch so viel wie testgetriebene Entwicklung.
Ein Test ist eine Einheit, die dazu dient, die Qualität und Funktion der Software zu testen. Dabei gibt es verschiedene Ziele in der Entwicklung, die durch einen oder mehrere Tests unterstützt werden können.
Folgende Vorteile sehe ich in einem Test bei der Entwicklung von Software:
Diese sind die offensichtlichen Vorteile, die sich aus dem Schreiben von Automatisieren Test ergeben, hinzu kommen dann daraus folgende noch solche Effekte wie Zeitersparnis, da Fehler sehr frühzeitig auffallen können bei einer guten Testabdeckung (test coverage). Was später die Suche nach Fehlern oder das Umschreiben von Quelltext deutlich reduziert.
Testgetriebene Entwicklung ist primär ein Prozess aus drei sich wiederholenden Phasen, bis eine Funktion/Feature fertig ist. Die Phasen heißen Red, Green und Refactor, dabei beginnt man mit der Phase Red, in dieser schreibt man den ersten Test („test first“), dieser sollte fehlschlagen, umgangssprachlich also Rot sein. Nachdem nun bekannt ist, dass der Test richtig funktioniert und auch rot sein kann, schreibt man gerade so viel Quelltext, dass der Test grün wird. Nachdem der Schritt erfolgreich ist, geht man dazu über, ein Refactoring zu machen. Danach beginnt man wieder bei der roten Phase. Und so entwickelt man Testgetrieben.
Diese Infografik fasst die Phasen der testgetriebenen Entwicklung noch einmal zusammen und kann dir als Bild im Kopf dienen, um dich mit dem Konzept von TDD in der Praxis vertraut zu machen. Es ist ein Kreislauf von Red, Green und Refactor. Als ersten Schritt hatte ich bereits den Test definiert, „test first“, an zweiter Stelle steht der Quelltext. Diese sollte man sich am Anfang eines TDD Prozesses immer wieder bewusst machen.
Ein wichtiges Konzept im Zusammenhang mit testgetriebener Entwicklung ist das der sogenannten Testpyramide; dieses Bild kann einem bei der Entwicklung helfen. Denn die Unit-Tests sind meist deutlich schneller zu implementieren als Integrationstests oder auch End-to-End-Tests. Da die Unit-Tests nur die kleinste Einheit testen.
Aber deshalb können wir uns nicht nur auf die Unit-Tests verlassen, wir müssen auch die Integration einer kompletten API testen oder auch das Frontend im End-to-End-Tests, um sicherzustellen, dass alle Bereiche der Software auch miteinander funktionieren. Dieses direkt am Anfang zu schreiben halte ich nicht für sinnvoll, sondern erst dann, wenn mehrere Einheiten, die eine Funktion ergeben, vorhanden sind, um einen Integrationstest oder End-to-End-Test zu schreiben. Da hier gerade auf den grünen Wiesen noch häufig Veränderungen an Schnittstellen vorgenommen werden.
Zu guter Letzte möchte ich diesen Abschnitt mit einem Hinweis beenden: auch wenn wir eine 100 % Test Coverage haben sollten, heißt es nicht, dass unsere Software fehlerfrei ist, die Wahrscheinlichkeit dafür ist höher, aber wir Menschen machen Fehler, vergessen einen Wichtigen Edge Case oder übersehen sogar einen Use Case.
Um dir den Prozess der testgetriebenen Entwicklung etwas näherzubringen, wollen wir ein kleines Beispiel anhand von Golang machen. Diese kannst du auf jegliche Programmiersprache adaptieren.
Use Case: Wir benötigen eine Funktion, die 2 Zahlen addieren kann, aber sobald eine Zahl kleiner als 0 ist, soll 0 zurückgegeben werden.
Im ersten Schritt definieren wir zunächst die Signatur für unsere Methode und einen Rückgabewert, wo der Test fehlschlagen wird; diese ist notwendig, da wir sonst in Go den Test nicht ausführen können.
package example
import "math"
func Sum(a int, b int) int {
return math.MinInt64
}
Nun können wir unseren Test erstellen; in Go verwenden wir hier für das Table Driven Konzept, dabei geben wir eine Struktur, unsere Werte an und iterieren über diese, um unsere Methode zum Testen mit verschiedenen Werten aufrufen zu können. Hier decken wir erst mal nur einen ganz simplen Test ab, wie 1 + 1 = 2, dieser wird nun beim Ausführen fehlschlagen.
package example_test
import (
"testing"
"github.com/fschuermeyer/hellocoding-go-tdd-test/internal/example"
)
func TestSum(t *testing.T) {
cases := []struct {
a, b, want int
}{
{1, 1, 2},
}
for _, c := range cases {
t.Run("Sum", func(t *testing.T) {
r := example.Sum(c.a, c.b)
if r != c.want {
t.Errorf("TestSum(%d, %d) == %d, want %d", c.a, c.b, r, c.want)
}
})
}
}
Nun können wir einfach den Wert 2 zurückgeben, da wir nur einen Test haben und ihn so schnell erfüllen können. Dieser Test wird jetzt erfolgreich sein, also umgangssprachlich „Green“.
...
func Sum(a int, b int) int {
return 2
}
Uns ist aufgefallen, dass wir für a
und b
jeweils explizit definieren, dass es sich um einen Integer handelt, diese ist aber nicht notwendig; wir können das Ganze kürzen und nur einmal den Typen definieren. Unser Test sollte nun weiterhin grün sein.
...
func Sum(a, b int) int {
return 2
}
Nachdem wir nun den ersten Prozess durchlaufen haben, können wir einen weiteren Test ergänzen, um zum Beispiel zu prüfen, dass auch größere Zahlen korrekt addiert werden können. Wir ergänzen unsere Tests um einen weiteren.
...
{120, 240, 360},
...
Unser Test schlägt nun wiederum fehl und im nächsten Schritt werden wir unseren Quelltext nun anpassen, um den Test wieder grün zu machen.
Nun können wir wieder unseren Quelltext an die neuen Vorgaben anpassen, damit dieser erneut grün wird.
func Sum(a, b int) int {
return a + b
}
Nun hast du den gesamten iterativen Prozess einmal in der Praxis gesehen und immer so weiter geht es, bis das Ziel des Use Cases erreicht und alle Vorgaben auch über die Tests entsprechend abgedeckt sind.
Als Aufgabe kannst du gerne das Beispiel einmal in einer dir bekannten Programmiersprache umsetzen, zum Beispiel in Go oder in Python. Für Python habe ich einen entsprechenden Artikel über Unit-Testing geschrieben, der dir den Einstieg noch mal erleichtern kann: Wie Unit Testing in Python funktioniert?
Unter dem folgenden Github Repository findest du eine Methode und einen Test, der die gesamte Funktionalität des Use Cases abdeckt; anhand von TDD ist dieser Quelltext entstanden.
Im Bereich vom Frontend wird es je nach Szenario mit TDD immer etwas knifflig, weil sich das HTML im Stylingprozess immer noch mal sehr stark verändern kann. Die Tests müssen dann häufig überarbeitet werden, dort tendiere ich momentan eher dazu zu sagen, erst mal das Styling gerade zu ziehen und anschließend mit den Tests zu beginnen, sobald die Logik in die Komponente implementiert wird. Um das Styling besser prüfen zu können, kann ich Storybook empfehlen; darüber können die Komponenten betrachtet werden und es kann auch noch Cypress angebunden werden für das End-to-End-Testing.
Ein weiterer Kritikpunkt, den ich an TDD habe, ist das Thema Zeit und Geld, denn am Anfang entstehen durch TDD erst einmal höhere Zeitaufwände. Für die Programmierer:innen ist es erst mal ein ungewohntes Konzept. Die Bereiche der Applikation müssen erst mal fürs Testen vorbereitet werden, da Testlogik bislang nicht bei allen Programmiersprachen schon mit dem Core geliefert wird, sondern durch Abhängigkeiten erst passend eingerichtet werden muss.
Ein Punkt, der mir aus persönlicher Erfahrung schwerfällt: Bei einem Legacy-Projekt ohne vorhandene Tests nachträglich Tests einzuführen. Der Aufwand ist erheblich höher, da der Code stark verkapselt ist und viele Seiteneffekte hat, was den Umstieg auf TDD im laufenden Projekt extrem erschwert.
Im ersten Moment wirkt TDD wie ein großes träges Monster, aber wenn man erst mal ein neues Projekt hat, in dem frisch mit der testgetriebenen Entwicklung gestartet werden kann, halte ich es für ein sehr spannendes Konzept für Entwickler. Persönlich denke ich durch die Entwicklung mit TDD mehr über meine Tests nach, als wenn ich sie erst am Ende schreiben würde. Zusätzlich habe ich den Vorteil, dass meine Qualität noch einmal erhöht wird durch klare Phasen des Refactorings, die bei der „normalen“ Entwicklung gerne mal zu kurz kommen. Aber wenn man dann Tests in seiner DNA hat, geht es runter wie Butter, in diesem Prozess zu arbeiten.
Wichtig finde ich, bevor mit TDD in einem Projekt gestartet wird, sollte man sich seine Testpakete & Bibliotheken, die eingesetzt werden, noch einmal im Detail anschauen, um einen Überblick darüber zu haben, welche Möglichkeiten man hat. Zum Beispiel welche Asserts grundsätzlich möglich sind oder was schnell gemockt werden kann.
Ein weiterer relevanter Punkt für mich ist, dass man daran denkt, die Tests auch über Pipelines in seinen Projekten immer auszuführen, zum Beispiel über die Github Actions oder den Gitlab CI/CD-Prozessen.
Wie denkst du persönlich über TDD in deiner Arbeit? Ist es für dich ein praktikabler Ansatz oder hast du andere Befürchtungen, die ich in meinem Artikel bisher nicht geäußert habe?
Hinterlasse mir gerne einen Kommentar zum Artikel und wie er dir weitergeholfen hat beziehungsweise, was dir helfen würde das Thema besser zu verstehen. Oder hast du einen Fehler entdeckt, den ich korrigieren sollte? Schreibe mir auch dazu gerne ein Feedback!
Es sind noch keine Kommentare vorhanden? Sei der/die Erste und verfasse einen Kommentar zum Artikel "Wie funktioniert Test Driven Development eigentlich?"!