Über unsMediaKontaktImpressum
Peter Fichtner 05. September 2017

TDD: Erfahrungen bei der Einführung

Wer agil arbeitet, kommt um testgetriebene Entwicklung kaum herum. Warum auch, überwiegen die Vorteile doch ganz klar: Kein ungetesteter Code mehr, wenig oder gar keine Redundanzen und eine Konzentration auf das Wesentliche. Das sind nur einige Argumente für dieses Vorgehen.

Automatisierte Tests sind für manchen Entwickler ein rotes Tuch. Warum eigentlich? Oft werden Grundsätze guter Unit-Tests verletzt: ein Unit-Test sollte beispielsweise frei von Abhängigkeiten zu Umsystemen und autark lauffähig sein. Durch solche Verletzungen grundsätzlicher Anforderungen sind Tests fragil. Und dies führt zu Folgeproblemen. 

Oft liegt es auch daran, dass ein passender Komponentenschnitt versäumt oder komplett ignoriert wurde. Folglich durchlaufen die Tests mehr Businesscode als zur Erfüllung des Testziels eigentlich notwendig ist.
 Der Aufbau der Testkonstellation wird von Test zu Test komplexer und zäher. Hier bleibt oft unklar, ob und in welchen Fällen sie als fehlgeschlagen zu bewerten sind oder - schlimmer noch - was sie überhaupt testen.

Ist erst einmal ein Test rot und wird der Grund des Fehlschlags nicht analysiert und behoben, so führt dies im Laufe der Zeit dazu, dass weitere fehlschlagende Tests nicht auffallen. So sammelt sich schnell eine größere Anzahl von fehlgeschlagenen Tests an (broken window theory).

Entwicklungsteams stehen dann irgendwann vor der Herausforderung, hunderte roter Tests korrigieren zu müssen. Der Aufwand hierfür ist oft so groß, dass man sich dazu entschließt, die Tests lieber wegzuwerfen. Wurde dieser Zustand einmal erreicht, werden Tests als hinderlich angesehen. Sie stehen Weiterentwicklungen oder internen Änderungen (Refactorings) im Weg, statt das zu fördern, was sie eigentlich sollten: Weiterentwicklung und Refactorings unterstützen und absichern. 

Testgetrieben? Was heißt das eigentlich?

Eine Möglichkeit, zu guten Tests mit fachlichem Bezug zu kommen, ist Test Driven Development (TDD). Das Wort "testgetrieben" wird hierbei oft falsch interpretiert. Bei TDD steht nicht das Erstellen von Tests im Fokus. Es geht vielmehr darum, sich während der Implementierung durch seine eigenen Tests treiben lassen. TDD wird des Öfteren mit Test-First gleichgesetzt. Test-First hingegen treibt nicht die Entwicklung, sondern definiert im Vorfeld Kriterien, welche das fertige Artefakt am Ende zu erfüllen hat. Zwischen Erstellung der Tests und Erstellung des Businesscodes liegt immer ein zeitlicher Versatz. Die einzelnen Tests spiegeln bei Test-First also nicht Entwicklungsschritte wider, sondern fokussieren sich eher auf unterschiedliche Eingabeparameter und Rückgabewerte. Das Erstellen des Businesscodes und des Testcodes findet bei TDD hingegen im ständigen Wechsel statt.

Die Tests stellen sicher, dass bereits entstandene Funktionalität während der noch andauernden Entwicklungsphase erhalten bleibt. Da es jedoch sinnvoll ist, auf diese Absicherung auch bei späteren Erweiterungen oder internen Änderungen wieder zurückgreifen zu können, wird dieses Nebenprodukt ebenfalls aufbewahrt. Damit die Tests langfristig verwendbar sind, sollten sie ähnlichen Ansprüchen genügen wie der Businesscode selbst.

Grundlagen des Test Driven Developments

FIRST ist ein erster guter Ansatz: FIRST steht für Fast, Isolates, Repeatable, Self-validating, Timely [1]. Die Erfahrung zeigt jedoch, dass ein weiteres Kriterium wichtig ist, welches in FIRST nicht enthalten ist: die Fachlichkeit (siehe A-TRIP, "Pragmatic Unit Testing in Java with JUnit") [2]. Denn ein guter Test lässt Rückschlüsse auf seine Fachlichkeit bzw. seine Notwendigkeit zu.


Ein Test, zu welchem kein Rückschluss auf seine Notwendigkeit hergestellt werden kann, wird im Zweifel bei Fehlschlägen ignoriert, gelöscht oder noch schlimmer: Die Assertion an die aufgetretene Abweichung angepasst. Der Grund, warum ein Test überhaupt existiert, muss ersichtlich sein und der Test sollte beim Fehlschlag eine eindeutige und verständliche Meldung erzeugen. Im Idealfall ist der Rückschluss auf den Grund eines Fehlschlags ohne Blick in den Test- oder Businesscode möglich.

Test Driven Development am Beispiel

Oft fällt es Entwicklern schwer, einen Test zu schreiben, obwohl noch gar kein Businesscode existiert. Wie soll etwas getestet werden, was es noch gar nicht gibt? Die Frage sollte aber eigentlich lauten: Was soll im nächsten Schritt implementiert werden und wie kann sicherstellt werden, dass das Hinzugefügte auch tatsächlich das tut, was man beabsichtigt? Tests stellen dabei sicher, dass bereits erfüllte Anforderungen an den Businesscode auch weiterhin erfüllt bleiben.

TDD sieht eine Folge von Red-Green-Refactors vor. Zuerst beschreibt man im Test Verhalten, von welchem man annimmt, dass der Businesscode dieses noch nicht erfüllt. Um das zu bestätigen, führt man den Test aus. Würde man das Ausführen auf rot unterlassen und direkt auf grün implementieren, wäre die Schlussfolgerung beim erstmaligen Ausführen der Tests, dass die gestellten Anforderungen erfüllt sind. Die Konsequenz: Ein Blindflug, in dem die Anforderungen unter Umständen gar nicht erfüllt werden. Ein von Anfang an grüner Test hat möglicherweise unglücklich gewählte Eingabeparameter als Ursache. Ein Beispiel: Der Test einer Sortierung, welche im Businesscode unter bestimmten Voraussetzungen ausgeführt werden soll, wobei die zu sortierenden Daten bereits sortiert dem Test zur Verfügung gestellt werden.

Ist der Test erstmals rot, erweitert man den Businesscode nun so weit, dass der eben noch rote Test grün wird (und natürlich ohne dass ein bestehender rot wird). Die Implementierung sollte mit Absicht hier nicht gleich die perfekte, performanteste oder schönste Lösung sein, sondern erst einmal eine, die funktioniert, sprich die gestellten Anforderungen erfüllt. Das Verschönern, Perfektionieren und Optimieren erfolgt dann erst in der nächsten Phase, der Refactoring-Phase, und folgt dem Leitsatz "make it green, then make it clean".

Mit TDD besteht die Möglichkeit, den Code weiter zu optimieren und dabei jederzeit zu verifizieren, dass dieser auch weiterhin noch das wichtigste Kriterium erfüllt, nämlich: "passes the tests" [3]. Erfahrungsgemäß tun sich hier TDD-Einsteiger bei der Anwendung der TDD-Praktiken am schwersten. Oft wird eben doch ein Großteil der Implementierung in der Phase von rot auf grün erstellt (lang andauernde Rot-Phase), während erfahrenere TDD-Entwickler oft extrem kurz auf rot sind und die gesamte Implementierung dann in der Refactoringphase entstehen lassen. Verschiedene Möglichkeiten beschreibt Kent Beck unter dem Begriff "green bar patterns" [4].

Test Driven Development: Debuggen ganz einfach

Wird während der Refactoringphase ein Test plötzlich rot, ist es oft empfehlenswert, einfach die letzten Schritte zurück zu gehen, bis man wieder auf grün ist. Je häufiger man die Tests ausführt, desto weniger Änderungen muss man wieder zurücknehmen. Im Idealfall sind dies nur wenige, wenn nicht sogar nur eine einzige Zeile Code. Dort, wo man ohne TDD mühsam im Debugger versucht, nachzuvollziehen, warum der Code nicht das tut, was er soll, macht man mit TDD ohne Ursachenanalyse den gemachten kleinen Schritt, durch welchen offensichtlich das Fehlverhalten soeben entstand, einfach wieder rückgängig und versucht nochmals den Schritt nach vorne. Hilfreich kann dabei sein, den gemachten Schritt noch einmal in kleinere Teilschritte zu zerlegen, nach welchen jeweils die Tests ausgeführt werden.


In der Refactoringphase findet nicht nur die Optimierung von Statements und das Anwenden von Clean Code-Prinzipien statt, sondern auch größere Refactorings, wie z. B. das Verschieben von Code, Herauslösen von Methoden oder Klassen oder auch der Einbau von Patterns. Hiervon bekommt der aufrufende Test nichts mit. Er testet weiterhin über die bereits geschaffene öffentliche Schnittstelle – Interna interessieren ihn nicht.

Mit wachsender Erfahrung im Bereich TDD wird es zunehmend leichter, den richtigen Zeitpunkt für solche Refactorings zu finden. Denn neben dem offensichtlichen – dem zu späten – kann auch ein zu frühes Refactoren hinderlich sein.

TDD – Outside In vs. Inside Out

Bei TDD unterscheidet man grundsätzlich zwischen den Varianten Outside In TDD (Top Down/London School) und Inside Out TDD (Bottom Up/Chicago School). Outside In-Tests sollten von außen über die öffentliche Schnittstelle der Komponente geschehen. Aber was ist die Komponente, was ist deren öffentliche Schnittstelle? Bildlich gesehen bestehen Komponenten aus weiteren Komponenten, je weiter man in dieses Bild hineinzoomt, desto mehr entfernt man sich von der Fachlichkeit und umso näher rückt man an die Technik. Streng genommen kann man selbst Basisklassen wie java.lang.String als Komponente betrachten, die noch technischere Details wie den Umgang mit dem darunterliegenden char-Array abstrahieren und kapseln. Als Autor der Klasse String möchte man sicherlich mit einem Unit-Test das korrekte Verhalten der Klasse String beschreiben und sicherstellen, somit ist für diesen die Klasse die Komponente und das System under Test (SUT).


Wie findet man nun in dem Bild den richtigen Zoomlevel, um die Komponente zu bestimmen, gegen die sich die kommenden Tests richten? Die wichtigste Frage lautet: Richtet sich der Test gegen etwas, dessen Verhalten manifestiert werden soll oder handelt es sich doch um Implementierungsdetails? Je mehr Implementierungsdetails getestet werden, desto spezifischer ist zwar bei einem Fehlschlag der Hinweis darauf, welche Stelle im System betroffen ist. Dies wird aber teuer damit erkauft, dass zukünftige Refactorings deutlich aufwändiger werden, da Tests angepasst oder zusätzlich erstellt werden müssen. Des Weiteren ist es schwerer, den Zusammenhang zwischen den einzelnen Tests und damit den Zusammenhang der getesteten einzelnen Komponenten zu erkennen. Ebenso besteht die Gefahr, dass der Sinn des Tests in Bezug auf das Gesamtsystem nicht erkennbar ist. Aus diesem Grund verfolgen wir den Outside In-Ansatz, da man so zu fachlicheren Tests kommt. Fachliche Tests sind stabiler gegenüber späteren, nicht-fachlichen, Änderungen im Businesscode.


Test Driven Development: Granularität der Tests festlegen

Ein weiteres Beispiel: Angenommen, es soll etwas erstellt werden, das Befehle in Form von Strings in Instanzen eines Command Interfaces übersetzt, also eine Art Parser für ein Protokoll. Ist dieser Protokoll-Parser Bestandteil einer größeren Komponente oder stellt er selbst eine Komponente dar? Ist er nur Bestandteil und damit Implementierungsdetail einer Komponente, so sollten sich Tests gegen die Komponente richten. Soll der Parser aber ein wiederverwendbares Stück Software und damit letztendlich eben eine Komponente sein, so sollten sich die Tests gegen ihn richten. Diese Entscheidung sollte möglichst vor Beginn der Implementierung und damit vor der Erstellung des ersten Tests getroffen werden. Wurden Tests direkt gegen den Protokoll-Parser erstellt, ist es nicht sinnvoll, die im Protokoll-Parser befindliche Fachlichkeit eine Ebene höher nochmals zu testen. Damit ist auch ersichtlich, dass Tests auf anderen Ebenen andere Ziele haben, z. B. den Test der Integration von getesteten Komponenten.

Am offensichtlichsten ist dies, wenn man Third Party-Bibliotheken mit ins Spiel bringt. Sieht zum Beispiel die Bibliothek die Implementierung eines eigenen Producers vor und man würde Tests gegen diesen erstellen, so testet man Implementierungsdetails. Es ist offensichtlich, dass man – sobald die Bibliothek die Signaturen des Producers ändert – neben der Implementierung auch seine Tests anpassen muss – und das sind oft alle vorhandenen Tests. Hätte man dagegen fachliche Tests gegen die Komponente, welche intern die Bibliothek nutzt, wäre es ausreichend, nur den Businesscode zu ändern. Die Tests blieben unverändert.

Wenn die Bibliothek sogar den Ablauf komplett ändert, weil z. B. das vorherige Verhalten, welches der Producer implementierte, auf mehrere Interfaces aufgeteilt wurde und dadurch die Implementierung ganz anderer Interfaces mit anderen Signaturen, erwartet, so wird der Aufwand der Testcodeänderung noch größer. Hier stellt sich sogar die Frage, ob man die bestehenden Tests überhaupt auf das neue Konzept migrieren kann. Es ist offensichtlich, dass sich diese Tests gegen die öffentliche Schnittstelle der Komponente richten sollten. Genau so sollte aber auch mit eigenen Komponenten verfahren werden, die Vorteile sind die gleichen. Wichtig ist also, zu erkennen, was ein Implementierungsdetail ist und was nicht. Tests sollten keine Implementierungsdetails testen.

Softwareentwicklung: Warum nicht im Nachgang testen?

Es gibt viele Argumente, warum Tests im Nachgang (Test Last) weniger sinnvoll sind. Tests für etwas zu erstellen, von dem man annimmt, dass es funktioniert, macht wenig Spaß. Nebenbei ist es natürlich auch befriedigender, permanent Erfolgsmeldungen zu erhalten, dass etwas funktioniert, was vorher nicht funktioniert hat (rot -> grün). Bei Tests im Nachgang schreibt man einen grünen Test nach dem anderen. Außerdem stellt sich oft die Frage, was überhaupt getestet werden soll, da der Zusammenhang einzelner Klassen/Methoden selten offensichtlich ist. Daher ist es schwer, im Nachgang zu sinnvollen Tests zu kommen, welche die Fachlichkeit im Fokus haben.

Die Verifikation von Rückgabewerten von Methodenaufrufen findet man bei TDD nach Outside In eher selten, da die Tests meist eher die Veränderungen im System beschreiben und verifizieren. Eine durch TDD entstandene Komponente ist von Anfang an auf Testbarkeit ausgelegt. Vieles lässt sich bei Tests im Nachgang außerdem gar nicht mehr testen oder bedingt Refactorings, um Komponenten testbar zu machen, zum Beispiel weil der zu testende Businesscode mit Zugriffslogik auf Datenquelle/Datensenke verschmolzen ist. Da aber keine Tests existieren, besteht bei den dann notwendigen Refactorings die große Gefahr, Verhalten unwissentlich abzuändern. Dadurch entstehen unter Umständen dann sogar Tests, welche falsches Verhalten beschreiben und sicherstellen.

Oft wird in diesen Situationen, in denen unterschiedliche Zuständigkeiten vermischt oder verschmolzen wurden, mit schwergewichtigen Mocking-Tools versucht, die geschaffenen Probleme zu umgehen. Dies führt zu extrem schwer lesbaren, sehr technisch anmutenden Tests, die meist viel Detailwissen über die Implementierung in sich tragen. Beispiele hierfür sind Zugriffe auf Umsysteme, deren Zugriffe dann auf Domainobjektebene oder noch schlimmer, auf Byte Array-Ebene, gestubt werden, obwohl eigentlich eine Berechnung im Businesscode getestet werden sollte. Mit TDD wäre für diesen Zugriff eine überschreibbare Methode mit fachlichem Namen, ein entsprechender Adapter mit fachlichem Namen oder Ähnliches entstanden. Mit TDD entsteht meist, bedingt durch SLA (Single Level of Abstraction), deutlich mehr fachlich anmutender Code.


Die Anzahl der Testfälle steigt durch zunehmende Komplexität (Branches: if/else/switch/...) und nicht durch die Zunahme der Lines Of Code. Eine Mapperklasse, welche einfach 1:1 (bedingungslos und ohne Fallunterscheidung) die Attribute Vorname, Nachname, Straße, Hausnummer, Ort und PLZ mappt, hat genau einen Testfall. Kommt ein zusätzliches Attribut hinzu, welches ebenfalls 1:1 (bedingungslos und ohne Fallunterscheidung) gemappt werden soll, so wird kein neuer Testfall erstellt, sondern der bestehende Testfall erweitert. Somit müsste man bei Tests im Nachgang analysieren, welche Branches es im Code gibt und für diese Testfälle erstellen. Der Grund, warum ein bestimmtes Stück Software erstellt wurde, war jedoch zum Implementierungszeitpunkt bekannt.

Bei Test Last wird diese Überlegung nochmals und zusätzlich noch mit Zeitversatz angestellt. Durch das so stattfindende Reverse Engineering werden die Tests zu einer Beschreibung, was der Businesscode zum Zeitpunkt der Analyse macht. Das stimmt aber nicht zwangsläufig mit dem, was er tun soll, überein. Der Test beschreibt also nicht, aus welchem Grund er und der Businesscode erstellt wurden. Mit TDD hingegen erhält man mit den Tests ein Stück lebende Dokumentation bzw. Spezifikation, welche im Gegenzug zu einer Dokumentation immer den aktuellen Stand des Businesscodes beschreibt. ("tests are specs", [5]) – regelmäßige Testausführung natürlich vorausgesetzt.

Aufbau des Test-Designs

So trivial es klingen mag, die ersten Fragen sollten sein: Was will ich testen? Was ist meine Komponente, also das System under Test (SUT)? Bei Fehlern/Problemen ist es wichtig zu wissen, in welchen Teilen der Software der Test rot werden soll und bei welchen er grün bleiben soll. Dass ein Test, obwohl in bestimmten Teilen ein Problem herrscht, grün bleiben soll, ist ein klares Zeichen dafür, dass es sich hier offensichtlich um mehr als eine Komponente handelt und der Teil, von dem man sich unabhängig machen möchte, selbst eine eigene Komponente darstellt.


Diese Komponente ist dann wiederum in sich zu testen. Offen ist dann noch, ob das Zusammenspiel dieser zwei Komponenten auch korrekt funktioniert. Hier kommen nun die Integrationstests ins Spiel, die das korrekte Zusammenspiel verifizieren (und zwar nur das Zusammenspiel und nicht mehr). Man steckt also sein System aus vielen, in sich getesteten Komponenten zusammen. Deshalb ist es immens wichtig, ausreichend viele Tests zu haben, welche die bislang ungetesteten Komponentenschnittpunkte testen.


Keine Ebene der Testpyramide macht eine andere Ebene überflüssig. Tests sollten auf der untersten möglichen Ebene mit der Frage, ob man Komponentenschnittstelle oder Implementierungsdetails testet, erstellt werden. Ist man nicht auf der Ebene von Unit-Tests, sollte man sich erneut fragen, was genau getestet werden soll und ob zur Sicherstellung dieses Verhaltens tatsächlich andere Komponenten benötigt werden. Die gleiche Frage gilt analog für die darüberliegenden Ebenen.


Auch wenn der Aufwand für einen automatisierten Test einmal größer erscheinen sollte, so sollte immer gegengerechnet werden, welcher Aufwand entsteht, wenn das Verhalten in manuellen Tests dauerhaft sichergestellt werden soll. Meist rechnen sich selbst hier die einmaligen Aufwände für automatisierte Tests gegenüber den immer wieder stattfindenden manuellen Tests. Falls bei der Erstellung von Tests erhöhte Aufwände ersichtlich werden, so existieren diese meist nur beim ersten Test, also beim technischen Durchstich. Die folgenden Tests können dann meist mit kleinem Aufwand erstellt werden.

Es ist hilfreich, wenn Tests nach dem "AAA"-Pattern (Arrange-Act-Assert/ Tripple-A) aufgebaut werden und dies in den Tests ersichtlich ist, denn auch für Tests gilt das SLA-(Single Level of Abstraction)-Prinzip. Bei komplexeren Arrange-Szenarien kann oft ein Builder, bei komplexeren Assertion ein eigener Matcher helfen. Sollte beim Aufruf der zu testenden Komponente ein entsprechendes Setup notwendig sein, so hilft hier das Auslagern in eine private Methode/Funktion mit einem Namen, der die Fachlichkeit widerspiegelt.

Aus TDD heraus kann durchaus ein internes Design/interne Architektur entstehen. Es ist jedoch wichtig, von Beginn an zu überlegen, ob man für den geplanten Test die richtige Ebene/Höhe (Zoomlevel) gewählt hat. Big design up front (BDOF) ist also auch bei TDD, zumindest bis zur Komponenten-Ebene nötig, da dies der Einstiegspunkt für Tests nach Outside In darstellt. Für Inside Out müsste das Vorabdesign sogar noch weiter vorangetrieben werden. Tests einer Komponente dürfen natürlich auf unterschiedlichen Ebenen platziert werden. Wichtig ist, dass im zu testenden Businesscode nur Komponenten genutzt werden, die in sich bereits getestet sind, man also von der korrekten Funktionsweise ausgehen kann, und keine Komponente auf Daten, Umsystem oder sonstige Dinge zugreift, die nicht in der Hoheit der zu testenden Komponente bzw. des Tests liegen.

Wie bei den meisten Dingen, lernt man Testen nicht von heute auf morgen und lernt auch niemals aus. Das trifft auch hier zu. Ein "falsch" gibt es nicht und selbst schlechte Tests sind oft besser als gar keine – gerade an diesen lernt man dazu. Und um dazuzulernen sollte man eines tun: Einfach mit TDD loslegen!

Quellen
  1. FIRST
  2. J. Langr, A. Hunt, D. Thomas, 2015: Pragmatic Unit Testing in Java 8 with JUnit, O'Reilly UK Ltd.
  3. Martin Fowler: BeckDesignRules
  4. Kent Beck, 2002: Test-Driven Development by example; Addison-Wesley Professional
  5. Test first
     

Autor

Peter Fichtner

Peter Fichtner greift auf zwei Jahrzehnte Erfahrung als Architekt, Designer und Entwickler für verschiedene Themen im Java-Umfeld zurück.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben