Gute Unit-Tests und Testgetriebene Entwicklung (TDD)
Als Berater in der Softwareentwicklung sind sowohl Unit-Tests wie auch Testgetriebene Entwicklung bzw. TDD Begriffe, denen wir in unserer täglichen Praxis ständig begegnen. Die meisten Beteiligten in der Branche der Softwareentwicklung scheinen diese Begriffe auch zu kennen. Fragen wir jedoch ein bisschen genauer nach oder sehen uns die Umsetzung der Konzepte hinter diesen Begriffen an, treffen wir immer wieder auf ein sehr breites Spektrum an unterschiedlichen Auslegungen. Scheinbar verstehen wir nicht alle dasselbe unter diesen Begriffen.
Und wenn uns mal wieder erklärt wird, warum Unit-Tests oder TDD für das jeweilige Unternehmen nicht geeignet sind, dann hat das bisher fast immer mit der Interpretation davon, was denn ein Unit-Test ist oder wie TDD funktioniert, zu tun.
Aus diesem Grund möchten wir in diesem Artikel unser Verständnis von Unit-Tests und Testgetriebener Entwicklung darlegen und den einen oder anderen damit dann doch davon überzeugen, warum diese Konzepte sehr wohl funktionieren.
Wir verstehen nicht alle dasselbe unter diesen Begriffen!
Wenn wir über Unit-Tests reden möchten, dann sollten wir uns erst mal allgemeiner über Tests unterhalten. Und da speziell über den Grund, warum wir Tests schreiben bzw. testen. „Was müssen wir denn über den Grund für Testen reden, der ist doch klar. Ich will wissen, ob mein Programm auch funktioniert!“ mag sich da so mancher nun denken. Und so haben es viele von uns auch mal gelernt. Wir schreiben ein Stück Software und dann überprüfen wir, ob es auch funktioniert. Fertig!
Doch die Realität ist dann doch ein bisschen komplexer. In unserer Wahrnehmung gibt es drei Gründe zum Testen bzw. zum Schreiben von Tests:
- Wir suchen die Ursache für einen Fehler und testen deshalb das Verhalten eines gegebenen Systems.
- Wir haben ein System oder einen Teil eines Systems erstellt und wollen nun die einwandfreie Funktionsweise unserer Schöpfung beweisen.
- Wir haben vor, ein System oder einen Teil eines Systems zu erstellen und definieren über Tests bereits im Vorfeld unsere Erwartungshaltung, damit wir genau das Richtige implementieren.
Für jede dieser Intentionen gibt es verschiedene Arten von Tests, die den angestrebten Zweck befrieden können. Und Unit-Tests sind für die Intentionen 2 und 3 ein geeignetes Mittel. Um diese Behauptung überprüfen zu können, betrachten wir doch mal, was denn ein Unit-Test eigentlich ist. 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.
Sehen wir uns 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:
@Test Public void testPlayerHitBallSuccessfully() { BaseballPlayer player = new BaseballPlayer(„Mike“); Baseball baseball = new Pitcher(„Tom“).throw(„average“); assertTrue(„Ball verfehlt“, player.hitBaseball(baseball)); }
Ist das ein Unit-Test gemäß unserer Definition oben? Ganz klares Nein! Neben der Methode hitBaseball, dem Verhalten, dass wir eigentlich testen wollen, testen wir hier auch direkt noch throw(x) von Pitcher mit und auch noch die interne Implementierung von Baseball. Wenn dieser Test fehlschlägt, dann ist die Frage groß, woran das liegen könnte. Probieren wir es mal anders:
@Test Public void testPlayerHitBallSuccessfully() { BaseballPlayer player = new BaseballPlayer(„Mike“); Baseball baseball = Mockito.mock(Baseball.class); Baseball.when(baseball.getSpeed()).thenReturn(90); assertTrue(„Ball verfehlt“, player.hitBaseball(baseball)); }
Was ist jetzt anders? Nun, 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 Mockframeworks, hier Mockito, einen Mock, der so aussieht wie ein Baseball. Nur dass wir diesmal keinen Pitcher mehr brauchen und das Verhalten des Baseballs komplett von außen steuern können. In diesem Beispiel definieren wir für das Verhalten des gemockten Baseballs, dass, wenn er nach seiner Geschwindigkeit gefragt wird, er 90 (mph) antwortet. Und schon testen wir exakt nur das Verhalten von hitBaseball(x).
Ein Unit-Test testet einen einzelnen Aspekt einer Komponente
Das Ergebnis dieser Art Tests zu schreiben ist, dass wir direkt eine ganze Menge kleiner Tests bekommen, die jeweils nur ein ganz bestimmtes Verhalten einer Komponente testen. Wir sagen manchmal auch, ein Unit-Test testet einen einzelnen Aspekt einer Komponente. Das verfeinert schon unsere Definition.
Aber warum tun wir uns das an? Das ist ja schon eine ganze Menge Arbeit, so viele kleine Tests zu schreiben. Und es ist ja nicht nur die Menge der Tests, die nun zu schreiben ist. Auch die Implementierung selbst ist ja nun so zu halten, dass diese einzelnen Aspekte überhaupt isoliert testbar sind. Wir könnten doch auch einfach wenige große Tests schreiben, die direkt mehrere oder sogar alle Aspekte einer Komponente und das Zusammenspiel mit ihren abhängigen Komponenten testen? Dazu können wir gleich drei Motivation aufführen:
- Wenn meine „großen“, komplexen Tests grün laufen, also kein Fehler auftritt, dann ist alles in Ordnung. Aber wenn jetzt doch etwas nicht in Ordnung ist, was genau stimmt dann nicht? Mein „großer“ Test verrät es im schlimmsten Fall überhaupt nicht. In diesem Fall wäre die Testaussage „Es stimmt etwas nicht!“ Und selbst bei einem besser strukturierten Test bleibt noch eine Reihe von potentiellen Fehlergründen offen, die ich nun manuell durchtesten muss. Bis ich dann weiß, was wirklich das Problem ist und es lösen kann. Habe ich viele einzelne Tests, die unabhängig voneinander einzelne Aspekte testen, zeigt mir der fehlschlagende Test direkt die Fehlerursache an. Fehlerfinden und -beheben wird damit zum Kinderspiel.
- 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 ich nur eine repräsentative Auswahl an realistischen Szenarien mit Tests abbilden möchte, 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? Die Reduzierung des Testkontextes auf den einzelnen Aspekt einer Komponente reduziert damit auch die Komplexität der Tests. Und weil diese Tests unabhängig voneinander sind, entsteht für die Testabdeckung nun auch nicht mehr das Kreuzprodukt aller möglichen Kombinationen. Und 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. Und wir reden über diese Art von Tests später noch.
- 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 eine wirklich modulare Architektur bis in den letzten Winkel und ein hohes Maß an loser Kopplung. Unit-Tests zwingen uns also zum besseren Design.
Ein guter Unit-Test besteht nur aus einem Aufruf
Es gibt ein paar Faustformeln dafür, 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 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. Also so simple und vielleicht auch nervig, wie die Einhaltung der AAA-Regel auch scheinen mag, sie hilft, echte Unit-Tests zu identifizieren.
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: Ein Unit-Test soll schnell in der Ausführung sein. 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 werden das mehrere Hundert Unit-Tests werden. Und der große Vorteil einer guten Codeabdeckung durch automatisierte Tests ist, dass ich bei Änderungen quasi durch einen Mausklick überprüfen kann, ob noch alles funktioniert. Wenn aber der Durchlauf aller Tests ein oder zwei Stunden dauert oder vielleicht noch länger, dann wird die Motivation abnehmen, diese Testläufe in kurzen Intervallen durchzuführen. Und das geht wieder auf Kosten der Qualität. Deshalb muss jeder einzelne Unit-Test schnell sein, 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 selber dass 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. einen anderen Test) die im ersten erzeugten Zustände zurückgesetzt werden müssen. Anders formuliert: Ein Unit-Test hinterlässt keine persistenten Zustandsänderungen im System. Entweder erzeugt er erst gar keine solchen Zustandsänderungen oder er räumt sie selbst 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 dem Punkt Isolated.
- Self-Validating: Ein Unit-Test kann immer selbst eineindeutig bestimmen, ob er erfolgreich war oder fehlgeschlagen ist. Für diese Beurteilung ist definitiv keine manuelle Bewertung durch einen Menschen erforderlich. Und doch finden wir das immer mal wieder vor, dass erst einer der Entwickler oder Tester nochmal darauf schauen muss, um zu beurteilen, ob der Testlauf jetzt o.k. war oder eher nicht. Wenn das der Fall ist, dann stimmt mit der Grundidee der entsprechenden Tests etwas nicht.
- Timely: Unit-Tests sind nicht besonders gut geeignet, um sie irgendwann mal später zu schreiben, 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 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? Die Antwort besteht aus unserem Anspruch an die Qualität unseres Sourcecodes. Robuste Softwarekomponenten zeichnen sich durch folgende vier Eigenschaften aus:
- Lauffähigkeit
- Einfachheit
- Relevanz
- Redundanzfreiheit
Wir wollen in erster Linie, dass unsere Software fehlerfrei funktioniert. Dann möchten wir aus Gründen der Wartbarkeit, dass sie möglichst einfach ist bzw. das alle einzelnen Komponenten möglichst simple und leicht verständlich sind. Und eine gute Idee ist, 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. Und auch die Freiheit von Redundanzen, also doppeltem Code, erhöht die Wartbarkeit.
Und der konsequente Einsatz von Unit-Tests hilft uns, genau diese Ziele zu erreichen.
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. Und 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 die Unit-Tests sowieso schon vorgeben wollen, wie der Sourcecode zu schreiben ist, 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 (TDD) genannt.
TDD is a design process, not a testing process
Tatsächlich ist die einfache Umkehrung der Reihenfolge die Grundidee des TDDs. Und 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 also nicht nur darum, eine gute Testqualität und -abdeckung zu erzielen, sondern auch darum, besseren Sourcecode zu schreiben.
Also gibt es da noch ein paar weiterführende Ideen im TDD, von denen wir uns zwei hier ansehen wollen. Die erste grundlegende Idee ist, dass der TDD-Zyklus nicht aus zwei, sondern aus drei Phasen besteht:
- Schreibe einen Test
- Implementiere gegen den Test
- Räume auf
Beim ersten Punkt liegt die Betonung liegt auf einen Test. Also als erstes schreibe ich nur einen Test und nicht alle Tests, die mir gerade einfallen. Und dann starte ich den Test und er muss fehlschlagen! Das ist durchaus wichtig, weil wenn dieser Test jetzt schon erfolgreich durchläuft, dann habe ich in Wirklichkeit einen Test für eine bereits bestehende Lösung geschrieben. Und das wäre wieder Test-After.
Jetzt implementiere ich gegen den Test, d. h. ich schreibe genau den Sourcecode, der nötig ist, damit der Test erfolgreich durchläuft. Und zwar auch wirklich nur soviel, wie nötig und keine Zeile mehr. Alles was ich mehr programmieren würde, wäre nicht durch einen Test abgedeckt. Und es geht in dieser Phase nur darum, eine lauffähige Lösung zu finden. Und noch nicht um Schönheit.
Und wenn der Test erfolgreich durchläuft, räume ich auf – inzwischen auch in Deutschland gerne als Refactoring bezeichnet. Nachdem ich bewiesen habe, dass ich die Lösung gefunden habe, darf ich das Ganze auch hübsch machen.
Das Aufräumen bezieht sich nicht nur auf unsere Lösungsimplementierung, sondern natürlich auch auf unseren Testcode. Jeder einzelne Test verdient es, nochmal unter die Lupe genommen zu werden. Oftmals kann er schlanker und schneller, lesbarer und wartbarer werden. Im Laufe der Zeit wird die Anzahl der Tests auf eine drei- bis vierstellige Anzahl anwachsen. Und auch dann müssen alle zusammen immer noch performant und wartbar sein.
Reduzierte Komplexität führt in den meisten Fällen zu besseren und zuverlässigeren Lösungen
Wo wir gerade bei Geschwindigkeit sind, sehen wir uns doch mal die zweite grundlegende Idee an, der schnelle Durchlauf des kompletten TDD-Zyklus. Robert C. Martin fordert, dass ein vollständiger TDD-Zyklus (Testscheiben, Implementierung, Refactoring) innerhalb von 90 Sekunden durchlaufen werden soll.
Dies zwingt uns, in kleinen Lösungsschritten zu arbeiten. Wenn der ganze Zyklus in 90 Sekunden durchlaufen werden soll, wie groß darf der Test dann maximal sein, dass ich ihn 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 fordert, 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.
Kann ich mit TDD jetzt also nur Unit-Tests schreiben? Das wäre ja nicht so günstig, weil wir wollen ja nicht nur die einzelnen, voneinander unabhängigen Aspekte unserer Komponenten testen, wir wollen auch sicherstellen, dass alles korrekt zusammen funktioniert. Und das kann ich natürlich auch 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. Und dann 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.
Fazit
Gute Unit-Tests macht aus, dass sie durch einen klaren und engen Fokus die Komplexität von Test und Implementation reduzieren und damit deren Qualität erhöhen. Und das Schreiben von guten Unit-Tests führt fast zwangsläufig zur testgetriebenen Entwicklung.