Funktionsweise und Zusatznutzen von Strict TDD

Seite 2: TDD-Prozess

Inhaltsverzeichnis

Zur Jahrtausendwende hat sich Test-first Programming unter Hinzunahme des Schritts "refactoring" weiter zu TDD entwickelt; teilweise werden die Begriffe TDD und Test-first Programming auch synonym verwendet. Der TDD-Prozess ist damit "red, green, refactor". Bei TDD findet die Designarbeit so teilweise erst nach der Implementierung im Rahmen des Refactoring statt. Zu wenig bekannt ist der hochinkrementelle Charakter von TDD, der dazu dient, das Feedback zu beschleunigen. Entwickler zerhacken dazu die gerade zu entwickelnde Funktionalität in kleinstmögliche Teilinkremente ("baby steps" [1]) und durchlaufen den ganzen TDD-Zyklus für jedes einzeln. Dagegen verstößt zum Beispiel die beliebte Praxis, einen Stapel Tests auf einmal zu schreiben und anschließend alle am Stück zu implementieren.

Der TDD-Zyklus aus "red", "green" und "refactor"

Ein gerade für TDD-Neulinge oft schwieriger Schritt erwähnt der TDD-Prozess gar nicht explizit: die Auswahl des nächsten Testfalls, der am besten dazu geeignet ist, die Implementierung weiter in die richtige Richtung zu treiben. Dabei sollte man die Grundstrategie verfolgen, immer den Test als nächsten zu wählen, dessen Implementierung den kleinstmöglichen nächsten Schritt bedeutet. Das heißt etwa von einfachen (" ", leere Liste …) zu komplexen Eingaben und von den wichtigsten wichtigsten (z. B. "Gutfälle") zu den weniger wichtigen Testfällen, aber auch erst die Fälle in einer (Eingabeparameter-)Dimension auszuschöpfen, bis man sich auf eine weitere stürzt. Vertiefend liefert "Uncle Bob" Martin mit seiner "Transformation Priority Premise" Überlegungen zur Bewertung möglicher nächster Schritte. Fallen Entwicklern zwischendurch Ideen für neue Fälle ein, sollten sie sie in einem "Test-Backlog" festhalten, damit sie nicht verloren gehen.

Sobald die Entscheidung für einen Testfall gefallen ist, muss er geschrieben werden und bei der anschließenden Ausführung erst einmal fehlschlagen. Auf den ersten Blick erscheint das als fragwürdiges TDD-Voodoo, doch das ist wichtig. Denn es bewahrt vor den hinterhältigen "false positives", die einem sonst (wie übrigens zwingend beim Test-last-Ansatz) leicht unterlaufen können. Dabei läuft ein Test zwar erfolgreich durch, prüft aber nichts oder das Falsche. Nur wenn ein Test nach korrekter Implementierung von Rot auf Grün wechselt, können Entwickler sicher sein, dass die Ursache dafür auch die korrekte Implementierung ist und der Test nicht schon immer erfolgreich durchlief. Zwingen sie sich konsequent zum Wechsel zwischen Rot und Grün, kommen sie zudem unverhofft zu besseren Testfällen.

Wenn der Test fehlschlägt, ist das auch der beste Zeitpunkt, eine aussagekräftige Fehlermeldung zu erstellen, da man sie bei der Gelegenheit auch gleich ausprobieren kann. Die Meldung ist dabei fürTestautoren uninteressant, da sie den Kontext des aktuellen Tests ja präsent haben. Sie richtet sich vielmehr an Entwickler, denen bei einer zukünftigen Änderung der Test "um die Ohren fliegt", der den Testfall aber vermutlich gar nicht kennt. Dabei spannen aussagekräftige Namen von Testklasse und -methode den Kontext auf, in dem die Fehlermeldung interpretiert wird, und sind so bereits Teil der Fehlermeldung. Bei der Meldung selbst hilft das Setzen des optionalen Description-Parameters der Assert-Methode, falls der geprüfte Wert noch nicht aussagekräftig genug ist. Hamcrest-Matcher bieten Erleichterung, wenn die Assertions komplexer sind oder Objekte eigener Typen verglichen werden sollen.

An dieser Stelle schreiben Entwickler nur so viel Code, dass der aktuelle Test gerade grün wird, aber nicht mehr [1]. Im ersten Schritt arbeitet man häufig nach dem Pattern "fake it (until you make it)", um das Feedback zu beschleunigen. Ein Java-Beispiel zur Verdeutlichung: Eine getestete Methode getUpper- Case("a") liefert etwa konstant den festen Wert "A" zurück. Ein weiterer Testfall für getUpperCase("b") lässt sich noch durch das Einfügen von

if ("b".equals(string)) return "B";

bedienen. Kommt jetzt der Testfall getUpperCase("c") hinzu, würde ein weiteres if-Statement nach demselben Muster zu struktureller Codeduplizierung führen. Damit "zwingt" dieser Testfall die Implementierung in Richtung Generalisierung (sogenannte "triangulation"). Dieser Prozess führt somit zu einer höheren Testabdeckung. "Fake it" und "triangulation" erscheinen Neulingen erst einmal völlig absurd und sind gerade in simplen Fällen nicht immer sinnvoll. Denn je einfacher das Problem zu lösen ist (z. B. durch Verwendung bereits bestehenden und getesteten Codes), desto eher lohnt sich stattdessen die Strategie "obvious implementation". Im Java-Beispiel wäre beispielsweise eine Implementierung sinnvoller, die einfach eine bestehende (JDK-)Methode aufruft:

return string.toUpperCase();

Wäre der UpperCase-Algorithmus hingegen von Hand zu implementieren, würden "fake it" und "triangulation" helfen, "baby steps" zu erreichen.

Die Komplexität wächst mit der Größe des Problems leider meist exponentiell statt linear. Um sie dennoch in den Griff zu bekommen, zerlegt man üblicherweise mit der "Teile und herrsche"-Strategie ("divide and conquer") ein großes Problem in eine Menge kleinerer Teilprobleme, die sich dann einfacher angehen lassen. Beim Design versucht man zum Beispiel ein System in kleine modulare Teile zu schneiden, die dann jeweils nur noch eine einzige Verantwortlichkeit haben ("single responsibility principle").

Auch bei zu bewältigenden Aufgaben gefährdet eine hohe Komplexität ein zügiges Vorankommen, da man zu viel auf einmal im Kopf behalten muss. Daher überträgt der TDD-Prozess die Strategie auf die zeitliche Dimension: quasi das "single responsibility principle" für zusammenhängende Änderungen. Halten Entwickler sich an die klar voneinander abgegrenzten Phasen des TDD-Zyklus, müssen sie sich zu einem festen Zeitpunkt nur auf genau einen Aspekt konzentrieren:

  • Auswahl des nächsten Testfalls
  • Formulierung des Testfalls (und evtl. Design der zu erstellenden API-Schnittstelle)
  • einfachstmögliche Implementierung (ohne Designgedanken)
  • Design während der Refactoring-Phase

Da der letzte stabile Stand nicht weit zurückliegt, können Entwickler Fehler schneller finden und vermeiden lange Debugging- Sessions. Durch entsprechend häufige Commits können sie die Übergänge zwischen den Phasen sogar im Versionskontrollsystem abbilden. Wenn sie sich einmal verfahren haben, fällt es so leicht, zum letzten funktionierenden Stand zurückzukehren.

In die gleiche Richtung geht die Forderung nach "baby steps". Man schneidet die zu entwickelnde Funktion in Mikroinkremente, die sich leichter umsetzen lassen und schneller Feedback liefern. Hier geht es wirklich darum, dass ein Zyklus nicht mehr als eine Handvoll Minuten dauert. Wie der Name vermuten lässt, hilft die Übung "Taking baby steps" hervorragend beim Erlernen der Technik. Continuous-Testing-Tools wie Infinitest oder NCrunch beschleunigen die Feedback-Geschwindigkeit sogar noch weiter. Dazu führen sie bei jedem Speichern automatisch im Hintergrund zumindest alle schnell laufenden Unit-Tests aus, ohne dass dazu ein Testrunner manuell zu starten wäre.