zurück zum Artikel

Funktionsweise und Zusatznutzen von Strict TDD

Know-how
Funktionsweise und Zusatznutzen von Strict TDD

Obwohl inzwischen viele Entwickler auf automatisierte Tests setzen, praktizieren immer noch nur wenige Test-Driven Development (TDD) in Reinform. Dabei bringt das strikte Befolgen des Prozesses neben dem Gewinn an Sicherheit noch einigen Zusatznutzen. Wer also ohnehin viel Zeit in das Schreiben von Tests investiert, sollte das Potenzial von TDD nicht verschenken.

In aller Regel erwarten Kunden qualitativ hochwertige Software. Klassischerweise führen deshalb Tester manuelle Tests mit der zuvor erstellten Software durch und prüfen sie so gegen die Spezifikation. Um bei der Umsetzung neuer Features nicht als Seiteneffekt Fehler in bestehenden Funktionen zu provozieren, sind diese durch sogenannte Regressionstests erneut zu kontrollieren. Agile Methoden fordern eine stark erhöhte Taktrate der Releases, um schneller Feedback zu erhalten und Features baldmöglich produktiv nutzen zu können. Dadurch werden aber mit wachsendem Funktionsumfang manuelle Regressionstests unwirtschaftlich bis unmöglich.

Zum Umgehen dieses Dilemmas wenden deshalb viele Projekte automatisierte Regressionstests an. Um Qualität früh im Entwicklungsprozess zu verankern, setzen zunehmend mehr Entwickler parallel zu den Testern auf automatisierte Entwicklertests. Meist baut ein Continuous-Integration-Server wie Jenkins die Software bei jedem Check-in in das Versionskontrollsystem oder zumindest einmal pro Nacht im "nightly build". Anschließend starten sie die Tests und informiert die Entwickler per Mail, wenn sie fehlschlagen. Somit fungieren die Tests als Sicherheitsnetz, um bestehende Features des Systems zu konservieren.

Viele Entwickler glauben, damit bereits testgetriebene Entwicklung [1] im Projekt einzusetzen, bleiben aber bei automatisierten Tests stehen und lassen wesentliche Elemente von TDD aus. Das ist schade, denn TDD kostet nicht mehr als das ohnehin durchgeführte Schreiben automatisierter Tests, kann aber mehr. Der Artikel beleuchtet Funktionsweise und Zusatznutzen von "Strict TDD", wie Minimierung unnötigen Codes, schnelleres Feedback, fokussierteres Arbeiten und besseres Design.

Im Anfang ist der Test

"Klassisch" erstellen Entwickler zuerst den Produktivcode und erst danach die Tests ("test last"). Mit den ersten Projekten Ende der 90er-Jahre, bei denen die agile Entwicklungsmethode "Extreme Programming" (XP) [2] zum Einsatz kam, wurde das sogenannte "test-first programming" bekannt. Dabei drehen Entwickler die Reihenfolge um: Sie schreiben bereits vor der eigentlichen Implementierung einen Test, der fehlschlägt ("red"). Erst dann erstellen sie den Produktivcode, sodass der Test erfolgreich durchläuft ("green"). Damit verhilft das Test-first-Vorgehen zu einer besseren Disziplin beim Schreiben der Tests, denn diese werden nicht einfach weggelassen, wenn gegen Ende wie üblich die Zeit knapp wird.

"Test first" motiviert zudem stärker als das Test-last-Vorgehen. Der Wechsel von Rot auf Grün signalisiert Entwicklern einen Fortschritt in den erstellten Features. "Test last“ hingegen ist langweilig und hat einen destruktiven und demotivierenden Charakter. Im "produktivste" Fall (Wechsel von Grün auf Rot) findet man einen Fehler im eigentlich schon fertig implementierten Code.

In der Praxis wird ein großer Anteil der Features einer Software von keinem Benutzer verwendet. Die Disziplin des Requirements Engineering hat daher zum Ziel, diesen unnötigen Implementierungsaufwand zu reduzieren. Aber auch Entwickler neigen gerade beim Test-last-Vorgehen zum Phänomen "you ain't gonna need it" (YAGNI): Sie entwickeln Code, den Auftraggeber nicht brauchen. Das kann eine nie aufgerufene Methode sein, aber auch ein Feature, das zwar Entwickler, nicht aber die tatsächlichen Benutzer für sinnvoll erachten.

Von Test zu Behaviour-Driven Development

Das Test-first-Prinzip fordert, Produktivcode nur schreiben zu dürfen, wenn er dazu dient, einen Test auf Grün zu bringen. Das hilft dabei, sich darauf zu konzentrieren, nur wirklich notwendigen Code zu schreiben. Beim Erstellen eines Tests vor der Umsetzung müssen sich Entwickler erst einmal genauer damit auseinandersetzen, welche Features sie überhaupt umsetzen sollen, und sich somit an den Anforderungen ausrichten. Beim Test-last-Vorgehen hatte der Test nur die Aufgabe der Validierung. Bei "test first" bekommt er plötzlich eine weitere Rolle: Er wird zur Spezifikation des zu implementierenden Codes.

Diese Beobachtung hat Dan North 2007 dazu veranlasst, Behaviour-Driven Development (BDD) aus der Taufe zu heben [1] und damit diesen Umstand auch begrifflich explizit zu machen: Aus "test" wird "behaviour". Der Begriff "test" wird mit Validierung und damit als Test-last-Vorgehen assoziiert. Eine "specification by example [2]" hingegen beschreibt das umzusetzende Verhalten anhand eines konkreten Beispiels bereits, bevor der implementierende Code existiert, also "test first".

BDD hat sich vor allem auf Ebene der Akzeptanztests etabliert, auch wenn die Idee genauso für Unit-Tests gilt. In den Fokus rückt damit das Finden und Dokumentieren der für die gegebene Problemstellung richtigen Anforderungen. Im Idealfall setzen sich in einem "specification workshop" die verschiedenen Projektbeteiligten mit ihren unterschiedlichen Blickwinkeln auf die Anforderungen gemeinsam an einen Tisch: Die "three amigos [3]", Auftraggeber, Entwickler und Tester, entwickeln zusammen anhand von Beispielen ein einheitliches Verständnis der Akzeptanzkriterien.

TDD-Prozess

Der TDD-Prozess aus "red, green, refactor"

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"
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 [4]" Ü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 [5] 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.

Divide and conquer

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 [6]").

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:

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 [7]" hervorragend beim Erlernen der Technik. Continuous-Testing-Tools wie Infinitest [8] oder NCrunch [9] 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.

Zyklisch und kontinuierlich

In Zyklen entwickeln

Grundsätzlich ist der TDD-Prozess "fraktal", also unabhängig von der Granularität des Testgegenstandes (Klasse, Komponente, System). Allerdings dauert die Umsetzung eines grobgranularen Tests wesentlich länger als ein TDD-Zyklus auf Unit-Ebene. Bei einem Akzeptanztest gegen die GUI oder eine Systemschnittstelle bewegen sich Entwickler schnell eher im Stunden- als im erstrebenswerten Minutenbereich. Hier setzt Acceptance Test-Driven Development (ATDD) an, indem es den TDD-Zyklus hierarchisch schachtelt [3].

Ein Akzeptanztestzyklus klammert eine Menge von Unit-Test-Zyklen. Diese setzen die vom Akzeptanztest geforderte Funktion Stück für Stück um, bis auch er erfolgreich durchläuft. Dabei sollte der Entwickler den gröberen Test nach dem initialen Fehlschlag temporär deaktivieren oder nicht ausführen, damit das Rot des Akzeptanztests nicht das Feedback der Unit-Tests überlagert.

Zeichnet sich ab, dass selbst die Umsetzung eines einzelnen Unit-Tests länger dauern wird, sollten Entwickler die Strategie auch im Kleinen beherzigen und einen Hilfstest schreiben, der die Implementierung einer Teilfunktion zum Beispiel in einer Hilfsmethode treibt. Alternativ zur Kombination von Hilfstest und -methode können sie deren Aufruf durch eine Mock-Implementierung ersetzen und so die eigentliche Methode isoliert testen.

Kontinuierlich wachsendes Design

In "klassischen" Projekten entwerfen im Extremfall Architekten das gesamte Projekt vor der Implementierung auf dem Reißbrett ("big design upfront", BDUF). Das Problem dabei ist eine viel zu lange Feedback-Schleife: Es stellt sich erst relativ spät heraus, ob das im "Elfenbeinturm" erdachte Design in der Praxis funktioniert. Der agile Gegenentwurf dazu ist das sogenannte "emergente Design", das durch Anwendung von TDD und Refactoring kontinuierlich "wächst".

TDD und Design sind eng aneinander gekoppelt. Führt dann TDD zu einem guten Design? Nicht automatisch [10]. TDD liefert zwar sofort Feedback aufs Design – grob gesagt: Was schwer zu testen ist, hat auch Designprobleme –, doch Entwickler brauchen für TDD gute Design-Skills. Nur so können sie erkennen, was sie am Design verbessern müssen, um es unter Test zu bekommen.

Diskussionswürdig ist, wie viel Designarbeit schon beim Schreiben des Tests geschehen soll. Beim Test-first-Ansatz sehen sich TDD-Neulinge gerade beim ersten Test mit einem vermeintlichen Henne-Ei-Problem konfrontiert. Wie sollen sie einen Test gegen eine noch nicht existierende Schnittstelle schreiben, wenn sie noch keinen Produktivcode entwickeln dürfen? Üblicherweise entsteht das Schnittstellen-Design bereits während der Testerstellung. Dazu schreibt man einen Test inklusive Beispielaufruf einer bis jetzt noch nicht existenten Methode. In einer mehr statischen Sprache wie Java unterringelt die IDE diesen Aufruf wegen eines Kompilierfehlers rot und bietet an, eine Methode mit der zum Beispiel passenden Signatur zu erzeugen. Der Test fungiert also als erster Client und setzt typische Aufrufe auf die Schnittstelle ab. Das liefert sofort Feedback bezüglich der Brauchbarkeit des Schnittstellendesigns.

Eine extreme Variante lernt kennen, wer sich mit der fortgeschrittenen Übung "TDD as if you meant it [11]" auseinandersetzt: Darin wird das Schnittstellen-Design komplett auf die Refactoring-Phase verschoben. Dazu starten der Entwickler jegliche Implementierung initial direkt in der Testmethode und darf sie erst während der Refactoring-Phase in Methoden, Klassen und Interfaces extrahieren.

Implementierung und Refactoring sind klar getrennt: In der Implementierungsphase sollten sich Entwickler bewusst auf die einfachstmögliche Implementierung konzentrieren und jegliche Fragen zu gutem Design gezielt auf das anschließende Refactoring verschieben. Ein Refactoring [4] transformiert ein Design A in ein Design B. Dabei darf sich die bestehende Funktion nicht verändern. Eine Kultur des konsequenten Refactoring geht daher Hand in Hand mit TDD und einer hohen Testabdeckung. Denn andernfalls müssen Entwickler bei Änderungen an bestehendem Code immer fürchten, dass sie bestehende Funktionen brechen.

Das Herzstück testgetriebenen Designs

Die Refactoring-Phase kann allerdings einen ganzen Stapel verschiedener Refactorings umfassen. Im Sinne des schnellen Feedbacks sollte man auch hier versuchen, die Phase in kleinstmögliche "baby steps" zu zerteilen, sodass nach jedem Mikro-Refactoring die Tests wieder grün werden. Konsequent eingesetzt halten die Refactorings das Design permanent sauber. Damit verlieren die unliebsamen Makro-Refactorings mehr und mehr an Bedeutung, die sowohl riskanter als auch schwieriger einzuplanen und dem Kunden zu verkaufen sind.

XP bietet zur Orientierung beim Refactoring folgende minimale Menge von Anforderungen an gutes Design [12]: ausdrucksstarke Namen, keine Codeduplikation sowie minimale Methoden, Klassen und Module. So entsteht durch TDD ein modulares Design aus vielen kleinen Einheiten, die sich leicht isoliert testen, flexibel wiederverwenden und gut warten lassen. Ziel ist, das Design zu jedem Zeitpunkt so einfach wie möglich zu halten, um durch Over-Engineering bedingte unnötige Komplexität zu vermeiden. Designentscheidungen werden zum spätestmöglichen Zeitpunkt mit dem bestmöglichen Wissen getroffen. Bei der Umsetzung helfen vor allem Extract-, Inline- und Rename-Refactorings. Moderne IDEs steigern durch inzwischen umfangreiche und durch Tastenkürzel effektive Refactoring-Unterstützung Sicherheit und Geschwindigkeit. Außerdem ist unbedingt der Testcode zu refaktorieren, da auch dieser sonst schnell zum Wartungsmonster mutiert.

Fazit

TDD klingt aufgrund der wenigen Regeln zunächst einfach. Doch TDD ist schwierig, und im praktischen Projekteinsatz jenseits einfacher Übungsaufgaben lauern viele Fallstricke. Auch das Schreiben von Tests will gelernt sein. Zudem versperrt eine Vielzahl an Design-Problemen den Weg zum ungetrübten TDD-Vergnügen. Es fehlt also oft neben grundlegendem TDD- auch das notwendige Design-Know-how. So gibt der TDD-Neuling schnell frustriert mit dem Gefühl auf, dass TDD nicht funktioniert und es sich bei TDD-Anhängern um eine realitätsferne Gruppe von Spinnern handelt. Um dem entgegenzuwirken, hat sich der Wissenstransfer durch "pair programming" zusammen mit einem TDD-erfahrenen Entwickler bewährt: ob "on the job" mit einem Kollegen oder externem Coach oder im Rahmen privat besuchter Code Retreats und Dojos.

Hat man die steile Lernkurve allerdings durchlaufen, bringt TDD ein großes Qualitätspaket in die Entwicklerstube. Jeder Lauf der automatisierten Testsuite validiert, ob die gewünschte Funktion erfüllt ist, und erspart Entwicklern schlaflose Nächte vor dem Release. Zusätzlich zur reinen Testautomatisierung hat TDD aber weitere Qualitätsverbesserungen im Gepäck.

Der Test-first-Ansatz steigert die Testdisziplin. Er und BDD helfen, nicht benötigten Code zu vermeiden und die Anforderungen zu verbessern. Weiterhin unterstützt der TDD-Prozess dabei, die zu erledigende Aufgabe in kleine mundgerechte Häppchen zu schneiden und so den Fokus der Entwickler zu verbessern. Die Aufteilung in die drei Phasen des TDD-Zyklus führt zu einer klar definierten Aufgabe zu jedem Zeitpunkt. Die "Baby steps"-Strategie verkürzt den Zyklus und beschleunigt das Feedback. Test-Driven Design bedeutet hohe interne Codequalität.

TDD ist ein Werkzeug der Entwickler. Dass Tests und Implementierung aus einer Hand stammen, bringt zwar viele Vorteile, birgt aber auch die Gefahr der Betriebsblindheit. Hier können Tester eine weitere Perspektive einbringen. Durch Beteiligung am "specification workshop", durch ausschöpfendes, aber auch exploratives Testen können sie die automatisierten Entwicklertests optimal ergänzen [13]. (ane [14])

David Völkel
arbeitet als Entwickler und Consultant für codecentric. Als begeisterter "Software Craftsman" spricht er auf Konferenzen und organisiert die Softwerkskammer-Meetups in München.

Literatur
  1. Kent Beck; Test-Driven Development: By Example; Addison-Wesley 2002
  2. Kent Beck, Cynthia Andres; Extreme Programming Explained: Embrace Change; Addison-Wesley 2004
  3. Steve Freeman, Nat Pryce; Growing Object-Oriented Software, Guided by Tests; Addison-Wesley 2009
  4. Martin Fowler; Refactoring: Improving the Design of Existing Code; Addison-Wesley 1999

URL dieses Artikels:
https://www.heise.de/-3342583

Links in diesem Artikel:
[1] https://dannorth.net/introducing-bdd/
[2] https://www.infoq.com/articles/virtual-panel-bdd
[3] https://www.stickyminds.com/better-software-magazine/three-amigos
[4] https://en.wikipedia.org/wiki/Transformation_Priority_Premise
[5] https://code.google.com/archive/p/hamcrest/
[6] https://en.wikipedia.org/wiki/Single_responsibility_principle
[7] http://blog.adrianbolboaca.ro/2013/01/the-history-of-taking-baby-steps/
[8] https://infinitest.github.io/
[9] http://www.ncrunch.net/
[10] https://gojko.net/2011/02/04/tdd-breaking-the-mould/
[11] http://coderetreat.org/facilitating/activities/tdd-as-if-you-meant-it
[12] http://wall-skills.com/2014/simplicity-rules-from-extreme-programming/
[13] http://www.shino.de/2013/03/21/automated-vs-exploratory-we-got-it-wrong/
[14] mailto:ane@heise.de