Test Driven Development in sechs einfachen Schritten

Sie möchten eine neue Software entwickeln. Dabei haben Sie natürlich die Absicht ein tolles Produkt mit einer hervorragenden technischen Qualität zu erzeugen. Und diese hervorragende technische Qualität wollen Sie mit einer möglichst umfassenden Testabdeckung beweisen und absichern. Doch in erschreckend vielen Fällen werden die Punkte hervorragende technische Qualität und umfassende Testabdeckung deutlich verfehlt.

Bei der Suche nach einem Weg, wie Sie das in Zukunft besser machen können wird dann oft ein Konzept in den Raum geworfen, dass die Lösung für all unsere Probleme sein soll:

Test Driven Development

Wenn Sie dann fragen, wie Test Driven Development dieses Wunder denn vollbringen möchte, dann bekommen Sie oft zur Antwort, dass Sie einfach nur die Reihenfolge von Programmierung und Testen umdrehen müssen und der Rest regelt sich dann schon von ganz alleine.

In diesem Artikel wollen wir uns damit beschäftigen, ob dieses Versprechen zu schön ist, um wahr zu sein oder ob da doch etwas dran ist.

Um es hier gleich zum Anfang zu verraten: Ja, Test Driven Development kann tatsächlich dieses scheinbare Wunder wirken. Allerdings nur, wenn wir Test Driven Development, oft einfach auch nur TDD genannt, wirklich verstehen. Inklusive allem, was rund um das Grundkonzept mit zum Gesamtpaket TDD gehört.

„Test Driven Development ist  nicht nur ‚erst Test, dann Programmierung‘, es ist ein Gesamtpaket“

Sehr oft beobachten wir, dass Unternehmen TDD einführen wollen und kurz danach wieder verwerfen, weil es bei ihnen scheinbar nicht funktioniert oder nicht passt. Unsere These ist dann in den meisten Fällen, dass sie nicht den vollen Umfang von TDD verstanden und nur den offensichtlichen Teil probiert haben. Deshalb schauen wir uns heute gemeinsam an, woraus das Gesamtpaket TDD besteht und welche Schritte notwendig sind, damit es auch bei Ihnen funktionieren kann.

Betrachten wir TDD genauer, dann stellen wir fest, dass bevor wir zum offensichtlichen Kernelement kommen, also die Reihenfolge Implementierung/Test umzudrehen, wir erst ein paar Hausaufgaben zu machen haben. Und dass wir nach der Umsetzung des Kernelements noch einen zusätzlichen Schritt weitergehen müssen, um wirklichen Erfolg haben zu können.

Aber keine Angst, in diesem einfachen 6-Schritte-Programm zeigen wir Ihnen, wie Sie Test Driven Development erfolgreich in Ihrem Unternehmen oder Projekt einführen und damit in Zukunft Ihre Erwartungen an technische Qualität und Testabdeckung erfüllen können.

Schritt Eins: Die Motivation zum Testen klären

Der erste Schritt besteht darin, zu klären was Tests eigentlich sind oder besser, warum wir eigentlich testen wollen.

„Warum müssen wir denn über den Grund für das Testen reden, der ist doch klar. Ich will nur wissen, ob mein Programm auch funktioniert!“ mag sich da so mancher nun denken. So haben es viele von uns auch ursprünglich mal gelernt. Wir schreiben ein Stück Software und dann überprüfen wir, ob es auch funktioniert. Fertig!

Doch die Realität ist ein bisschen komplexer. In unserer Wahrnehmung gibt es vier Gründe zum Testen bzw. zum Schreiben von automatisierten Tests:

  1. Wir suchen die Ursache für einen Fehler und testen deshalb das Verhalten eines gegebenen Systems
  2. Wir haben ein System oder einen Teil eines Systems erstellt und wollen nun die einwandfreie Funktionsweise unserer Schöpfung beweisen und auf Dauer sicherstellen
  3. Wir haben vor, ein System oder einen Teil eines Systems zu erstellen und wollen über Tests bereits im Vorfeld unsere Erwartungshaltung definieren, damit wir genau das Richtige implementieren
  4. Wir haben vor, ein System oder einen Teil eines Systems zu erstellen und wollen mit Tests dabei helfen, dass unser System wartbar und weiterentwickelbar ist bzw. bleibt

Für jede dieser Intentionen gibt es verschiedene Arten von Test und Vorgehensweisen, die den angestrebten Zweck erreichen können. Abhängig davon, was wir eigentlich mit unserem Testen erreichen wollen, müssen oder können wir die dazu passenden Formen von Tests und Vorgehensweisen wählen.

Das klingt sehr trivial und ist es auch. Vielleicht ist es gerade deshalb so wichtig, diesen Schritt bewusst zu gehen. Wenn wir ein neues Produkt erstellen oder ein bereits bestehendes um neue Funktionalitäten erweitern wollen, dann sind vor allem der dritte und vierte Grund unsere Hauptmotivation in Verbindung mit dem zweiten.

Nur den zweiten Grund als Motivation zum Testen zu sehen ist eine sehr eingeschränkte Sichtweise, die das volle Potenzial des Testens außen vor lässt und unsere Möglichkeiten beschränkt, die bessere Software erstellen zu können. Erst hinterher durch Tests die korrekte Funktionsweise einer Software sicherstellen zu wollen ist der Grund, warum in vielen Unternehmen die Qualitätssicherung nichts mit der Produktentwicklung zu tun hat und warum Testen als ein separater, von der Programmierung getrennter Schritt verstanden wird. Das ist die Absicht, erst hinterher festzustellen, dass „es“ nicht funktioniert und dann noch zu versuchen, das Produkt zu retten. 

Wir dahingehen wollen Qualitätssicherung oder Qualitätsmanagement als Bestandteil der Produktentwicklung sehen. Wir wollen direkt das funktionierende, wartbare und erweiterbare Produkt bauen.

Schritt Zwei: Die Eigenschaften guter Software erkennen

Das führt uns direkt zum zweiten Schritt in unserem Programm, die technischen Eigenschaften guter Software. Gute, robuste Softwarekomponenten zeichnen sich durch folgende vier Eigenschaften aus:

  • Lauffähigkeit
  • Einfachheit
  • Relevanz
  • Redundanzfreiheit

Was bedeutet das? In erster Linie wollen wir, dass unsere Software funktioniert. Und zwar fehlerfrei. Das ist schon mal klar verständlich.

Dann möchten wir aus Gründen der Wartbarkeit, dass sie möglichst einfach ist bzw. dass alle einzelnen Komponenten möglichst simple und leicht verständlich sind. Jeder Softwareentwickler, der schon mal in eher schwer verständlichen Sourcecode Änderungen einbauen musste, ist von diesem Punkt direkt ein großer Fan. Und ebenso jeder Projektverantwortliche, der für eine Weiterentwicklung schon mal astronomische Aufwandsschätzungen zu hören bekam mit der Begründung, der aktuelle Softwarestand wäre schwer zu verstehen und deshalb schwer zu erweitern.

Und natürlich ist es eine gute Idee, wenn jedes Stück Sourcecode auch wirklich einen relevanten Teil zur Lösung des Problems beiträgt. Unnützer oder toter Code reduziert nur die Wartbarkeit. 

Das gleiche gilt auch für die Freiheit von Redundanzen, also doppeltem Code; auch diese erhöht die Wartbarkeit und reduziert das Fehlerrisiko.

Relevanz und Redundanzfreiheit sind in dieser Liste Begriffe, die in sich schon sehr gut erklären, wann sie tatsächlich gegeben sind. Wenn jedes Stück Sourcecode auch wirklich benutzt wird, dann ist alles relevant und wenn es nirgendwo identische Codestellen gibt, dann ist die Software auch redundanzfrei.

Spannender wird es schon bei der Lauffähigkeit und der Einfachheit, denn für beides ist eine genauere Definition nötig. Die Lauffähigkeit lässt sich gut durch Akzeptanzkriterien definieren oder auch eine sogenannte Definition of Done, in der die Beteiligten gemeinsam festhalten, was es bedeutet, dass eine Funktionalität oder ein Feature fertig ist.

Ähnliches gilt für Einfachheit, auch diese Worthülse darf mit einer gemeinsamen Definition mit echtem Leben gefüllt werden, z.B. mit Code Metriken, Style Guides oder gemeinsamen Architekturkonzepten.

Schritt Drei: Das Schreiben echter Unit Tests lernen

Im nächsten Schritt dürfen wir lernen, was echte Unit Tests sind und wie sie geschrieben werden. 

Wie wir jetzt auf Unit Tests kommen? Wie wir gleich sehen werden, sind Unit Tests die unterste und elementarste Ebene der technischen Tests und eine Grundlage beim Test Driven Development. Deshalb beschäftigen wir uns nun in diesem Schritt mit Unit Tests.

Bei dem Begriff Unit Test haben wir ähnliche Herausforderungen wie bei den Begriffen Lauffähigkeit und Einfachheit: Jeder hat den Begriff schon gehört und auch eine Vorstellung davon, was diese bedeuten. Nur bedeutet das noch lange nicht, dass wir auch alle das Gleiche darunter verstehen.

Die babylonische Sprachverwirrung

Modul-, Komponenten-, Integrations-, System-, Akzeptanz-, End-to-End- oder Abnahmetest, Namen für unterschiedlichen Testlevel oder -arten gibt es viele. Allerdings sind die Bedeutungen nicht wirklich standardisiert. Sie sind nur Vereinbarungen zwischen den betroffenen Personen. Stellen Sie also sicher, dass Sie wirklich alle das gleiche unter diesen Begriffen verstehen und gehen Sie nicht einfach blind davon aus. Den Begriffen oben ist gemeinsam, dass sie alle die Reichweite, den Scope, der Teststufe beschreiben und nicht das Verfahren. Im Gegensatz zu Begriffen wie Performance-, Last-, Langzeit-, Monkey- oder Smoketests. Und auch hier gilt: Klären Sie das gemeinsame Verständnis der Begriffe.

Definitionen zu Unit-Test, die wir schon oft von unseren Kunden zu gehört haben sind diese:

  • „Unit Tests sind die Entwicklertests, also alles, was die Entwickler testen“
  • „Mit Unit Tests testen wir einzelne Einheiten“
  • „Unit Tests sind Klassentests“
  • „Unit Tests testen die Funktionen von Klassen“
  • „Unit Tests sind die automatisierten Tests“
  • „Unit Tests sind Tests, die mit JUnit geschrieben wurden“

Jede dieser Definitionen ist durchaus nachvollziehbar und doch ist keine davon so richtig hilfreich. Gehen wir also in die genaue Klärung des Begriffs Unit Test.

In der Liste der Motivationen zum Testen sind Unit Tests ein geeignetes Mittel für die Punkte zwei, drei und vier. Um diese Behauptung überprüfen zu können, betrachten wir, was denn ein Unit Test eigentlich sein soll. Unser erster Versuch einer Definition lautet wie folgt:

Unit Tests sind ein Verfahren, um das Verhalten einer Komponente isoliert, d.h. ohne ihre Abhängigkeiten zu anderen Komponenten, zu überprüfen.

Das bedeutet, wir wollen ein bestimmtes Verhalten in einem klar definierten Kontext überprüfen und dabei jede Form von Seiteneffekten vermeiden. Deshalb klammern wir die Abhängigkeiten zu anderen Komponenten explizit aus. Jede weitere Komponente, von der unser zu testendes Verhalten abhängig ist, bedeutet einen weiteren Faktor, den wir kontrollieren müssen bzw. der das Ergebnis unseres Tests beeinflussen kann. 

Wie können wir nun diesen Anspruch auf isoliertes Testen in unseren Tests umsetzen?

Sehen wir uns dazu ein Beispiel in Java an. In einem Baseballszenario wirft ein Pitcher einen Ball, den ein Spieler zu treffen versucht. Wir möchten einen Unit Test für die Methode hitBaseball() schreiben, bei der der Spieler einen durchschnittlich geworfenen Ball erfolgreich treffen soll. Spontan sähe unser Test so aus:

Der Pitcher erzeugt beim Werfen einen Ball, der alle Attribute eines Balles hat, der durchschnittlich geworfen wurden. Der Spieler versucht den Ball zu treffen und wir gehen eigentlich davon aus, dass er das müsste. Das ist unsere Erwartungshaltung.

Ist das nun ein Unit Test gemäß unserer Definition oben? Ganz klar nein! 

Neben der Methode hitBaseball(x), dem Verhalten, dass wir eigentlich testen wollen, testen wir hier auch direkt noch throw(y) von Pitcher mit und die interne Implementierung von Baseball. Wenn dieser Test fehlschlägt, dann ist nicht deutlich ersichtlich, woran das liegen könnte, die Anzahl der Codestellen, die sich unerwartet verhalten ist einfach zu groß.

Probieren wir es mal anders:

Was ist jetzt anders? 

Um die Methode hitBaseball testen zu können, benötigen wir einen Baseball. Nur lassen wir uns den diesmal nicht von einem Pitcher, den wir ja hier überhaupt nicht testen wollen, erzeugen. Stattdessen erzeugen wir uns mit Hilfe eines Mock Frameworks, hier Mockito, einen Mock, der so aussieht, wie ein Baseball.

Wir brauchen jetzt keinen Pitcher mehr und sind damit in unserem Test auch nicht mehr davon abhängig, dass der Pitcher sich wirklich wie erwartet verhält. Durch den Mock haben wir uns selber etwas erzeugt, dass wie ein Baseball aussieht. Allerdings müssen wir nun noch sicherstellen, dass sich dieser Mock auch wie ein durchschnittlich geworfener Baseball verhält.

In diesem Beispiel wissen wir aus der Implementierung vom BaseballPlayer, dass wir den Ball nach seiner Geschwindigkeit fragen werden. Also erklären wir dem Mock, dass er 90 (mph) zu antworten hat, wenn er nach seiner Geschwindigkeit gefragt wird.

So testen wir exakt nur noch das Verhalten von hitBaseball(x).

Wenn wir Tests auf diese Art und Weise schreiben, dann bekommen wir als Ergebnis eine ganze Menge an Tests, die jeweils nur ein ganz bestimmtes Verhalten einer Komponente testen. Wir sagen manchmal auch, ein Unit Test testet nur einen einzelnen Aspekt einer Komponente. Das verfeinert unsere Definition von Unit Test.

Aber warum tun wir uns das an? Es ist schon eine ganze Menge Arbeit, so viele so kleine Tests zu schreiben. Und es ist ja nicht nur die Menge der Tests, die nun zu schreiben sind. Auch die Implementierung selbst ist so zu halten, dass diese einzelnen Aspekte überhaupt isoliert testbar sind. 

Wir könnten doch auch einfach eine kleinere Anzahl an großen Tests schreiben, die direkt mehrere, oder sogar alle Aspekte einer Komponente und das Zusammenspiel mit ihren abhängigen Komponenten testen, oder? Ja, könnten wir. Aber genau das wollen wir nicht mehr. 

Lassen Sie uns drei Motivationen anführen, warum wir stattdessen echte Unit Tests verwenden möchten:

a. Wenn meine „großen“, komplexen Tests grün laufen, also kein Fehler auftritt, dann ist alles in Ordnung. Easy going. Aber wenn jetzt doch was nicht in Ordnung ist, was genau stimmt dann nicht. Mein „großer“ Test verrät es im schlimmsten Fall nicht, die einzige Aussage wäre: „Irgendetwas stimmt nicht!“ Und selbst bei einem besser strukturierten Test bleiben noch eine Reihe von potenziellen Fehlergründen offen, die wir nun manuell durchtesten müssten. Bis wir wüssten, was tatsächlich das Problem ist und es beheben könnten, müssten wir viel weitere Arbeit investieren. Haben wir viele einzelne Tests, die unabhängig voneinander einzelne Aspekte testen, zeigt uns der fehlschlagende Test direkt die Fehlerursache an. Fehlerfinden und -beheben wird damit zum Kinderspiel.

b. Ein größerer Testkontext besteht meistens aus einer größeren Anzahl an Parametern, Ausgangsbedingungen oder Ablaufmöglichkeiten, die unterschiedliche Ergebnisse erzielen können. Selbst wenn wir nur eine repräsentative Auswahl an realistischen Szenarien mit Tests abbilden möchten, wird durch die Komplexität der Abhängigkeiten die Anzahl der notwendigen Tests schnell groß, oft sogar sehr groß. Und wir reden hier von „größeren“ Tests, also höherem Aufwand pro Test. Haben Sie dadurch schon mal sehenden Auges eine geringere Testabdeckung in Kauf genommen, obwohl Sie ein schlechtes Gefühl dabei hatten? Seien Sie ehrlich! Die Reduzierung des Testkontextes auf den einzelnen Aspekt einer Komponente, reduziert damit auch die Komplexität der Tests. Weil diese Test unabhängig voneinander sind, entsteht für die Testabdeckung nun auch nicht mehr das Kreuzprodukt aller möglichen Kombinationen. Für die Auswahl der übergeordneten Tests, die das Zusammenspiel der Aspekte testen, können wir uns nun auf eine wirklich repräsentative Auswahl realistischer Szenarien beschränken. Wir werden später noch über diese Art von Tests reden.

c. Was schrieben wir oben? Für Unit Tests muss die Implementierung so geschrieben sein, dass die einzelnen Aspekte überhaupt einzeln testbar sind. Das bedeutet nichts anderes als ein wirklich modulares Design bis in den letzten Winkel und ein hohes Maß an loser Kopplung. Müssen wir hier noch über die Vorteile einer derartigen Architektur reden? Wohl kaum. Unit Tests zwingen uns also zum besseren technischen Design.

AAA-Regel

Um jetzt gute Unit Tests zu schreiben, gibt es ein paar Faustformeln, wie ein Unit Test aussehen sollte. Eine davon ist die AAA-Regel. AAA steht dabei für

  • Arrange
  • Act
  • Assert

und beschreibt den Aufbau eines Unit Tests. 

Im Arrange wird die Startsituation des Tests definiert. Die Komponente wird erzeugt und initialisiert, Mocks werden erzeugt, gesetzt und evtl. das Verhalten der Mocks definiert, Variablen werden gesetzt.

Im Act wird die eigentliche Aktion, die das zu verifizierende Ergebnis erzeugen soll, durchgeführt. In der Regel ist das der Aufruf der Methode, deren Verhalten getestet werden soll. Ein guter Unit-Test besteht hier nur aus einem Aufruf. Ist mehr als ein Aufruf notwendig, dann ist das ein Hinweis darauf, dass mehr als nur ein einfacher Aspekt getestet wird!

Und schlussendlich wird im Assert nun überprüft, ob auch das erwartete Ergebnis durch das Act erzielt wurde. Hier gilt, dass nur ein einzelnes fachliches Ergebnis geprüft werden soll. Und dabei liegt die Betonung auf fachliches Ergebnis, nicht technisches. Das bedeutet also nicht zwangsläufig, dass dafür nur ein einzelnes Assert-Statement verwendet werden darf. Wenn über mehrere Vergleiche verschiedene Teilaspekte des fachlichen Ergebnisses überprüft werden müssen, dann ist das schon in Ordnung. Wenn allerdings z.B. ein Unit-Test TestInsertKunde im Assert prüft, ob der Kunde wirklich in der die Datenbank geschrieben UND ein entsprechender Logeintrag im Logfile erzeugt wurde, dann ist das ein Hinweis darauf, dass hier mehr als nur ein einzelner Aspekt getestet wurde. 

So simple und vielleicht auch nervig, wie die Einhaltung der AAA-Regel auch scheinen mag, sie hilft, echte Unit Tests zu identifizieren.

Wir sehen in dem Beispiel, dass hier Act und Assert in einer Zeile, quasi ineinander verschachtelt stehen. Hier kann man argumentieren, dass für die bessere Lesbarkeit dieses zu trennen ist, also den Schlag des Spielers in einer einzelnen Zeile und das Ergebnis daraus in einer lokalen Variable fangen und dann diese im Assert auswerten.

Ob das nun der besseren Lesbarkeit und Wartbarkeit wegen wirklich notwendig ist, ist schwer objektiv zu beurteilen. Deshalb überlassen wir diese Entscheidung denen, die mit dem Code und den Tests arbeiten müssen.

FIRST

Ein anderes Akronym zur Identifizierung von guten Unit Tests ist FIRST, auch als FIRST Properties von Unit Tests bekannt. FIRST steht dabei für:

  • Fast
  • Isolated
  • Repeatable
  • Self-Validating
  • Timely

Fast: Ein Unit Test soll schnell in der Ausführung sein. Warum? Nun, so granular wie Unit Tests sind, reden wir bei der Durchführung der Tests nicht von einem oder zwei Unit Tests, sondern im Laufe der Zeit wird die Anzahl eher auf ein paar Hundert Unit Tests ansteigen, vielleicht auch ein paar Tausend. Und der große Vorteil einer guten Codeabdeckung durch automatisierte Tests ist, dass wir bei Änderungen quasi durch einen Mausklick überprüfen können, ob noch alles funktioniert. Wenn der Durchlauf aller Tests aber ein oder zwei Stunden dauert, oder vielleicht noch länger, dann wird die Motivation abnehmen, diese Testläufe in kurzen Intervallen durchzuführen. Das geht wieder auf Kosten der Qualität, weshalb jeder einzelne Unit Test schnell sein muss, zwei bis drei Sekunden maximal. Und noch schneller wäre noch besser.

Isolated: Unit Tests sind unabhängig voneinander. Das bedeutet, die Durchführbarkeit eines Unit Tests hängt weder davon ab, dass ein anderer Test bereits gelaufen ist, noch dass ein anderer Test vorher ein bestimmtes Ergebnis erzielt hat. Jeder Unit Test erzeugt im Arrange selbst die für ihn notwendige Startkonstellation.

Repeatable: Ein Test muss beliebig oft wiederholbar sein. Und zwar ohne, dass zwischen zwei Testläufen manuell oder auch durch einen anderen Prozess (z.B. ein anderer Test) die im ersten Testlauf erzeugten Zustände zurückgesetzt werden müssen. Oder anderes formuliert: Ein Unit Test hinterlässt keine persistenten Zustandsänderungen im System. Entweder erzeugt er erst keine Zustandsänderungen oder er räumt sie selber zum Abschluss des Tests wieder auf, indem er z.B. Werte aus der Datenbank wieder löscht oder zurücksetzt. Das steht in einem engen Zusammenhang mit den Punkt Isolated.

Self-Validating: Ein Unit Test kann immer selber eineindeutig bestimmen, ob er erfolgreich war oder fehlgeschlagen ist. Für diese Beurteilung ist definitiv keine manuelle Bewertung durch einen Menschen erforderlich. Klingt blöd und eigentlich selbstverständlich. Doch finden wir immer mal wieder vor, dass erst einer der Entwickler oder Tester nochmal darauf schauen muss, um zu beurteilen, ob der Testlauf jetzt ok war oder eher nicht. Wenn das der Fall ist, dann stimmt etwas mit der Grundidee der entsprechenden Tests nicht.

Timely: Unit Tests sind nicht besonders gut geeignet, um sie irgendwann mal später zu schreiben, vielleicht Tage, Wochen oder Monate nachdem der Sourcecode geschrieben wurde. Quasi nur, um nachträglich noch eine gute Testabdeckung zu erreichen. Wie wir oben schon gesehen haben, hat alleine das Schreiben der Unit Tests einen großen Einfluss darauf, wie der Sourcecode strukturiert wird. Deshalb gehören die Erstellung von Sourcecode und Unit Tests unmittelbar zusammen. Das werden wir uns gleich noch genauer ansehen, wenn wir über die testgetriebene Entwicklung reden.

Das sind eine Menge Faktoren, die für die Erstellung guter Unit Tests zu berücksichtigen sind. Und deshalb nochmal die Frage. Warum tun wir uns das an?

Nun, der konsequente Einsatz von Unit Tests hilft uns, Software zu schreiben, die die Kriterien guter, robuster Software erfüllen (siehe Schritt 2).

Also schreiben wir doch einfach konsequent Unit Tests. Doch jetzt gibt es beim klassischen Test-After Ansatz, das heißt ich schreibe erst meinen Sourcecode und erst danach die entsprechenden Tests, ein kleines Problem mit den Unit Tests. Wie wir oben gesehen haben, hat die Erstellung eines Unit Tests großen Einfluss auf die Struktur und das Design des eigentlichen Sourcecodes. Ich werde also vermutlich oft beim Schreiben eines Unit Tests feststellen, dass ich diesen Test so gar nicht schreiben kann, weil die Implementierung des Sourcecodes es nicht zulässt. Also zurück in den Sourcecode und diesen ändern. Beim nächsten Unit Test wieder und wieder und wieder. Das kann schnell nervig werden und die Motivation reduzieren, weitere Unit Tests zu schreiben.

Wenn jedoch meine Unit Tests mir sowieso schon vorgeben wollen, wie ich den Sourcecode zu schreiben habe, warum dann nicht direkt die ganze Idee einfach umdrehen? Also erst den Test schreiben und dann dazu passend implementieren? Und schon sind wir mitten in der testgetriebenen Entwicklung gelandet, auf Neudeutsch auch Test Driven Development oder TDD genannt.

Schritt Vier: Test Driven Development Grundprinzip verstehen

Also ist TDD einfach nur zuerst den Test zu schreiben und dann den Sourcecode?

Ja … und nein. Tatsächlich ist die einfache Umkehrung der Reihenfolge die Grundidee des TDDs. Schon alleine das wäre oft eine große Verbesserung im Hinblick auf Testqualität und -abdeckung. Doch die TDD-Gurus sagen immer so schön: 

„TDD is a design process, not a testing process“

Es geht, genau wie bei den Unit Tests nicht nur darum, eine gute Testqualität und -abdeckung zu erzielen, sondern wir wollen besseren Sourcecode schreiben. 

Damit wir das erreichen können, gibt es da noch ein paar weiterführende Ideen im TDD, die wir uns jetzt ansehen wollen. Die erste grundlegende Idee ist, dass der TDD-Zyklus nicht aus zwei, sondern aus drei Phasen besteht:

  1. Schreibe einen Test
  2. Implementiere gegen den Test
  3. Räume auf

Zum ersten Schritt müssen wir nicht mehr viel sagen. Obwohl … ja doch, ein paar Anmerkungen gibt es auch dazu. Die Betonung liegt auf einen Test. Wir schreiben also nur einen Test und nicht gleich alle Tests, die uns gerade einfallen. Dann starten wir den Test und dieser Test muss fehlschlagen! Das ist wichtig. Wenn dieser Test jetzt schon erfolgreich durchlaufen würde, dann hätten wir in Wirklichkeit einen Test für eine bereits bestehende Lösung geschrieben. Und das wäre wieder Test-After.

Jetzt implementieren wir gegen den Test, das heißt, wir schreiben genau den Sourcecode, der nötig ist, damit der Test erfolgreich durchläuft. Und zwar auch nur so viel, wie unbedingt nötig ist und keine Zeile mehr. Alles, was wir mehr programmieren würden, wäre nicht durch einen Test abgedeckt. In dieser Phase geht es ausschließlich darum, eine lauffähige Lösung zu finden. Und noch nicht um Schönheit.

Wenn der Test erfolgreich durchgelaufen ist, räumen wir auf, inzwischen auch in Deutschland gerne als Refactoring bezeichnet. Nachdem wir bewiesen haben, dass wir die eigentliche Lösung gefunden haben, dürfen wir das Ganze auch hübsch machen, das heißt, wir entfernen jetzt eventuell redundanten Code, machen Variablen oder Methodennamen sprechender, überprüfen, ob wir uns an alle Programmiervorgaben gehalten haben, ob alle Code Metriken erfüllt sind und was sonst noch so ansteht, damit das Ergebnis unserer Definition von gutem Sourcecode entspricht.

Einige erfahrene Entwickler behaupten hier gerne, sie können sofort richtig und sauber programmieren, der letzte Schritt wäre damit unnötig. Auf einige ganz wenige Profis trifft das auch zu. Doch die meisten von uns sind in Wirklichkeit doch noch nicht ganz so weit. Das ist keine Schande, auch wir halten uns an die Trennung von zuerst die Lösung finden und erst dann aufräumen. Wir stehen dazu!

Was hier auch noch einen besonderen Hinweis verdient ist, dass das Aufräumen sich nicht nur auf unsere Lösungsimplementierung bezieht, sondern natürlich auch auf unseren Test Code. Sie erinnern Sich an das F in FIRST? F wie Fast. Jeder einzelne Test verdient es, dass Sie ihn nochmal unter die Lupe nehmen. Kann der nicht noch schlanker und schnell werden? Und auch noch lesbarer und wartbarer? Bedenken Sie, im Laufe der Zeit wird die Anzahl der Tests auf eine drei- bis vierstellige Anzahl anwachsen. Dann müssen die alle zusammen immer noch performant und wartbar sein. Kümmern wir uns also doch einfach direkt darum, bevor es zu spät ist.

Schritt Fünf: In kurze Zyklen zu arbeiten lernen

Wo wir gerade bei Geschwindigkeit sind, sehen wir uns doch mal die zweite grundlegende Idee an, der schnelle Durchlauf des kompletten TDD-Zyklus.

Eine sehr spannende Idee ist, dass ein vollständiger TDD-Zyklus (Testscheiben, Implementierung, Refactoring) innerhalb von 90 Sekunden durchlaufen werden soll.

90 Sekunden? Warum diese Eile?

Dies zwingt uns in kleinen Lösungsschritten zu arbeiten. Wenn ich in 90 Sekunden den ganzen Zyklus durchlaufen möchte, wie groß darf der Test dann maximal sein, dass ich in einem Teil der Zeit schreiben kann. Und wie groß darf das Problem maximal sein, dass ich dann in einem Teil der verbleibenden Zeit lösen muss. Diese selbstauferlegte Herausforderung erzwingt, dass wir wirklich in sehr kleinen Einheiten unser Softwareproblem lösen. Wir müssen große Probleme in kleinere und noch kleinere Probleme zerlegen, für die wir dann auch dementsprechend kleine Lösungen finden müssen. Das nimmt die Komplexität aus dem Thema und reduzierte Komplexität führt in den meisten Fällen zu besseren und zuverlässigeren Lösungen. Das erinnert stark an das, was wir oben über Unit-Tests besprochen haben.

Jetzt sind 90 Sekunden allerdings tatsächlich ziemlich knapp für die Vorstellung, in dieser Zeit aus dem Nichts einen vollständigen lauffähigen Unit Test und die dazu passende Implementierung zu schreiben und dann noch beides hübsch zu machen. Selbst dann, wenn wir uns nur ein sehr, sehr kleines Problem vornehmen wollen.

Deswegen dürfen wir verstehen, dass ein Durchlauf des Zyklus nicht bedeutet, dass danach schon alles fertig läuft. Ein Stück Software zu schreiben, auch wenn es nur ein sehr kleines Stück ist, erfordert viele Durchläufe des TDD-Zyklus. 

Wie kann ich mir das jetzt vorstellen? Nun, das erste Testtool, das uns zur Verfügung steht, ist der Compiler. In den meisten modernen IDEs ist der bereits fest eingebunden und läuft im Hintergrund los, sobald wir Code eingeben. Und gibt uns umgehend Feedback darüber, ob wir zumindest aus Sicht des Compilers lauffähigen Sourcecode erzeugt haben oder nicht.

Nehmen wir an, wir wollen in Java z.B. mit Eclipse eine Klasse Rechner und in dieser eine Methode addieren erstellen.

Als erstes generieren wir z.B. eine JUnit Testklasse TestRechner und in dieser den Testfall TestAddierenEinfach. Und in diesem Testfall erzeugen wir uns eine Instanz von Rechner mit

Dumm nur, dass es die Klasse Rechner noch nicht gibt und der Compiler uns dementsprechend Feedback gibt. Das ist unser erster Testlauf und er schlägt fehl. Also implementieren wir jetzt so lange, bis der Test durchläuft, der Compiler zufrieden ist. Bedeutet in diesem Fall, wir implementieren eine leere Klasse Rechner. Das können wir bequem von der IDE erledigen lassen und bekommen dann zum Beispiel dieses hier als Ergebnis:

Zu refakturieren gibt es hier noch nicht viel, also ist der erste Durchlauf durch den Zyklus erledigt. Problemlos innerhalb von 90 Sekunden. Zugegeben, viel bekommen haben wir noch nicht. Aber jetzt geht es ja weiter.

Nun wollen im Test die Methode addieren mit den ganzen Zahlen 3 und 5 aufrufen und das Ergebnis in einer Variablen aufnehmen.

Auch hier bekommen wir unverzüglich Feedback vom Compiler, unserem ersten Testtool, er kennt die Methode addieren nicht. Also tun wir ihm den Gefallen und erzeugen sie, und am besten überlassen wir auch das wieder der IDE.

Der Compiler ist glücklich und der Testlauf damit bestanden, allerdings hätten wir diesmal durchaus ein paar Punkte, die wir bezüglich Lesbarkeit und Wartbarkeit verbessern können. Zum Beispiel ist die Benennung der Parameter in der Implementierung nicht so toll und wir verändern sie entsprechend:

Und auch im Testfall wollen wir vielleicht die konkrete Festlegung der Testwerte aus dem eigentlichen Aufruf herausziehen:

Schon sieht das Ganze deutlich zukunftsträchtiger aus. Wenn wir uns an die AAA-Regel erinnern, wir haben nun Arrange und Act, fehlt noch Assert:

Da der Compiler in diesem Fall nichts zu bemängeln hat, besteht der Test ab jetzt auch darin, den JUnit Test laufen zu lassen. Und der schlägt nun natürlich fehl, weil unsere aktuelle Implementierung stur 0 zurückgibt. Also implementieren wir nun soweit, dass unser Test grün läuft. In diesem Beispiel ist das ziemlich einfach:

Test neu laufen lassen und nun läuft er erfolgreich durch. Jetzt noch wieder den letzten Schritt des TDD-Zyklus, das Aufräumen. Die Implementierung sieht ok für uns aus, allerdings am Testfall ist noch was zu verbessern. Ähnlich wie bei den Summanden kann auch der Vergleichswert der Summe, der im Assert verwendet wird, in eine eigene Variable ausgelagert werden:

Wir haben jetzt in kurzen Zyklen eine Methode geschrieben, bei der wir jederzeit wiederholbar beweisen können, dass sie korrekt 3 und 5 addieren kann. Jetzt können wir in weiteren Zyklen erst mal sicherstellen, dass andere gültige Konstellationen funktionieren. Oder prüfen, wie die aktuelle Methode mit extremen Eingabewerten umgeht und das Verhalten der Methode dann entsprechend erweitern. Was passiert zum Beispiel, wenn wir 2147483647 und 1 addieren wollen. Das übersteigt den Wertebereich von int und wird zu unerwünschten Ergebnissen führen. Was uns der Test schnell zeigt und uns die Möglichkeit gibt, dass zu lösen.

Das war jetzt kein besonders schwieriges Beispiel, es zeigt jedoch sehr gut, wie in sehr kurzen Zyklen testgetrieben eine Lösung entwickelt werden kann. Es wird deutlich, dass jeder dieser Schritte mit etwas Übung innerhalb von 90 Sekunden realisiert werden kann.

Jetzt könnten wir allerdings mit diesem Verfahren fast unbegrenzt lange fortfahren und erst nach Stunden den ersten wirklich lauffähigen Test vorweisen. Was aber auch nicht der Sinn der Sache ist, denn erst wenn wir erste wirklich lauffähige Tests haben, sehen wir, dass wir auch etwas mit Wert schaffen. Also ergänzen wir unsere 90 Sekundenregel noch um eine 10 Minutenregel. Ziel ist, innerhalb von maximal 10 Minuten einen ersten/weiteren lauffähigen Test zu haben. Oder anders formuliert, wir wollen innerhalb von 10 Minuten einen durch Tests abgesicherten Mehrwert schaffen.

Damit sind wir wieder beim Fokus auf kleine Einheiten mit simplen Lösungen.

Wir sind jetzt soweit, dass wir durch Unit Test getrieben unsere Software schreiben. Oder zumindest die vielen kleinen technischen Komponenten. Das ist schon super. Aber ein Softwareprodukt besteht aus mehr nur kleinen technischen Komponenten, es besteht auch aus mehr oder weniger großen Zusammenhängen. Und genauso besteht eine gute Testabdeckung aus mehr als nur Unit Tests.

Schritt Sechs: Die Testpyramide aufbauen

Die drängende Frage lautet nun also: Kann ich mit Test Driven Development jetzt also nur Unit Tests schreiben? Das wäre ja nicht so günstig, denn wir wollen ja nicht nur die einzelnen, voneinander unabhängigen Aspekte unserer Komponenten testen, wir wollen auch sicherstellen, dass alles korrekt zusammen funktioniert. 

Auch das kann ich testgetrieben machen. Nachdem ich z.B. durch Unit Tests getrieben zwei kleine Aspekte implementiert habe, schreibe ich einen Test, der das Zusammenspiel der beiden Aspekte testet. Anschließend implementiere ich dieses Zusammenspiel. Zu beachten ist dabei, dass ich in diesem ‚Integrationstest‘ jetzt nicht mehr die grundsätzliche Funktion der jeweiligen einzelnen Units testen muss, sondern wirklich nur noch das Zusammenspiel.

Auf diese Art und Weise können wir uns nun die Testpyramide hocharbeiten. Wie bei einer echten Pyramide legen wir immer erst unten Steine hin, bevor wir die Steine der nächsten Ebene setzen. Das Bedeutet im Bereich des Testens von Software: 

  • Erst ein paar Unit Tests
  • dann die Modultests, die die getesteten Units verbinden
  • dann die Komponententests, die z.B. die bereits getesteten Module verbinden
  • dann evtl. schon der Integrationstest, bei dem unser bereits erstelltes und bis dorthin getestetes Stück Software jetzt mit Drittsystemkomponenten zusammen getestet wird 
  • und dann weiter bis hin zum End-to-End-Test oder Akzeptanztest.

Dabei haben wir jetzt mit Ausnahme des Unit Tests die Begriffe der Teststufen nach unserem Ermessen gewählt.

Wie die jeweiligen Teststufen in Ihrem Unternehmen benannt werden, ist auch weiterhin Ihre Entscheidung.

Nur zur Wiederholung nochmal: Auch bei den Teststufen oberhalb der Unit Tests schreiben wir immer zuerst den Test und erst dann Implementieren wir den Sourcecode, der bereits getestete Einheiten zu einem größeren Ganzen zusammenfügt.

Ein einfaches Beispiel: Wir haben testgetrieben bereits eine sum Methode geschrieben, die zwei ganze Zahlen entgegennimmt und diese aufaddiert zurückgibt. Durch Unit Tests haben wir ausgiebig sichergestellt, dass das auch funktioniert. Auch haben wir bereits eine checksum Methode geschrieben, der wir eine ganze Zahl übergeben können und von dieser die Quersumme zurückbekommen. Auch das ist hinreichend durch Unit Tests sichergestellt. Nun wollen wir einen Handler schreiben, der zwei ganze Zahlen übergeben bekommt, sich diese durch die bereits vorhandenen Methode sum aufaddieren und das Ergebnis davon von checksum bearbeiten lässt.

Alles, was der Handler braucht, um sicherzustellen, dass er auch wirklich zwei ganze Zahlen bekommen hat, stellen wir durch Unit Tests sicher, denn das ist vermutlich eine neu zu erstellende Funktionalität. Wenn wir jetzt jedoch die Tests für den Ablauf „zwei ganze Zahlen durch sum aufaddieren und dann durch checksum bearbeiten lassen“ schreiben, dann müssen wir nun nicht mehr in diesem Test sicherstellen, dass sum oder checksum wirklich mit allen möglichen Eingaben klarkommen und die Ergebnisse wirklich stimmen. Das haben wir bereits in den entsprechenden Unit Tests sichergestellt.

Der Prozess des Test Driven Development führt uns auf diese Art von unten nach oben durch die verschiedenen Testebenen und wir wissen das wir mit dem Entwickeln fertig sind, wenn wir die Tests auf der obersten Ebene geschrieben haben und diese erfolgreich durchlaufen.

Warum nennen wir das Testpyramide, das sieht doch in der Abbildung oben gar nicht aus wie eine Pyramide? Dem können wir abhelfen.

In der ersten Abbildung haben wir den Scope der einzelnen Tests auf den verschiedenen Testebenen abgebildet. In der zweiten Graphik zeigen wir die typische Verteilung der Menge an Tests, die bei einer sauber umgesetzten Teststruktur entsteht.

Was auffällt ist, dass die Menge an Tests/Testfällen von unten nach oben hin abnimmt. Das ist das Ergebnis daraus, dass wir schon auf der Ebene der Unit Tests einen Großteil der Funktionalität mit Tests abdecken und dann von Ebene zu Ebene quasi nur noch den Mehrwert testen, der aus der Kombination kleinerer Einheiten entsteht.

Das hat direkt mehrere Vorteile:

a. Da jeder Test nur noch einen begrenzten Scope hat, den es an Funktionalität wirklich sicherzustellen hat, sind alles Tests relativ klein, leicht verständlich und gut wartbar

b. Je weiter oben in der Testhierarchie ein Test steht, umso schwieriger und aufwändiger ist es, Funktionalität weiter unten im System zu testen. Das ist das typische Problem von Black Box Tests. Je weiter unten in der Testhierarchie wir einen Test schreiben, umso einfacher ist es eine bestimmte technische Funktionalität testen zu können. Wir bewegen uns im Bereich der White Box Tests. Durch den Aufbau einer sauberen Testpyramide reduzieren oder vermeiden wir es sogar komplett die Notwendigkeit, von höheren Testebenen aus technische Detailfunktionen testen zu müssen, Tests auf höheren Ebenen sind damit einfacher (siehe Punkt a.)

c. Wenn zu einem späteren Zeitpunkt sich Verhalten im System unerwünscht ändert, also ein Fehler auftritt, dann zeigen uns die fehlschlagenden Tests in der Testpyramide konkret, an welcher Stelle das Verhalten des Systems nicht mehr stimmt. Bei der Fehleranalyse und Fehlerbehebung fangen wir dann immer mit den fehlschlagenden Tests auf der niedrigsten Ebene an. Die Fehleranalyse und Fehlerbehebung werden dadurch deutlich beschleunigt.

Diese Verteilung der Testfälle wird einige etwas überraschen, weil es sich nicht mit ihrer Erfahrung deckt. In vielen Unternehmen besteht die große Menge an Tests aus GUI-, Integrations- und Akzeptanztests. Und je weiter nach unten man in der Testhierarchie schaut, umso weniger Testfälle werden es. Wir reden hier auch von der sogenannten Eistüte.

Was in diesem Modell viele Projekte an die Grenzen oder sogar darüber hinausbringt ist, dass sehr viel auch der fundamentalsten technischen Basisfunktionalitäten über die oberste der Testebenen abgesichert werden muss. Wie wir gerade besprochen haben, ist genau das der ungünstigste, weil schwierigste und damit zeitaufwändigste Ort dafür. Die Konsequenz ist oft eine unzureichende Testabdeckung, weil das Schreiben dieser Tests in der notwendigen Menge zu aufwändig ist.

Nutzen Sie also lieber TDD und formen Sie Ihre Eistüte in eine Pyramide um.

Fazit

Richtig verstanden und angewendet ist TDD ein sehr mächtiges Vorgehensmodell, um funktionierende Software auf einem sehr hohen Qualitätsniveau zu erstellen. Allerdings stellt TDD keine Abkürzung dar, mit der man mal so auf die Schnelle ein Stück Software aus dem Boden stanzen kann und dann läuft es schon. TDD setzt voraus, das von Anfang an kompromisslos auf Qualität gesetzt und damit die dafür notwendige Zeit investiert wird. Im Nachhinein kann auch TDD die Qualität einer Software nicht mehr retten.

„Qualität ist nicht verhandelbar“

Den einen oder anderen Entscheider mag diese konsequente Investition in Qualität erst mal erschrecken, weil damit Quick-n-Dirty Schnellschüsse nicht mehr möglich sind. Aber sobald Sie TDD erst mal wirklich in Ihrem Unternehmen eingesetzt haben, werden Sie schnell merken, dass sich diese Investition doppelt und dreifach rechnet.

Was wir bekommen, ist eine Software, bei der von Anfang an die Funktionsfähigkeit bewiesen ist und die aufgrund des ständigen Refactorings während des Entwicklungsprozesses und der hohen Abdeckung durch automatisierte Tests jederzeit problemlos gewartet und weiterentwickelt werden kann.

Damit Sie allerdings das Beste aus TDD herausholen können, ist neben der guten Absicht auch eine gute Ausbildung Ihrer Softwareentwickler nicht nur im Umgang mit TDD, sondern auch in den Prinzipien guten Softwaredesigns sinnvoll.

Investieren Sie hier, um auch in Zukunft hervorragende Produkte zu bauen.