Testgetriebene Entwicklung
Eingehen auf Veränderung
Wie würden wir programmieren, wenn wir tatsächlich nicht wüssten, wohin unser Kunde die Entwicklung steuern wird? Wie müssten wir programmieren, wenn wir späte Anforderungsänderungen als Chance oder Wettbewerbsvorteil unserer Kunden und nicht als Risiko der Softwareentwicklung auffassen wollten? In dieser Situation würden wir unser Design inkrementell erstellen und in kleinen Schritten anpassen, während neue Anforderungen auf uns zukommen. Unser Designprozess wäre dem organischen Anpassungs- und Wachstumsprozess ganz ähnlich. Design wäre eine ständige Aktivität, die wir Minute um Minute wahrnehmen würden.
Interessanterweise beschreibt dies zu einem großen Teil unsere tagtägliche Situation in der Softwareentwicklung in heutigen schnelllebigen Märkten, wo viele Projekte in der Tat explorativer Natur sind. Ein Design lässt sich in diesen Fällen nicht vorab planen, sondern kann sich erst mit wachsendem Verständnis der Anforderungen entwickeln. Noch mehr ähnelt die Situation jedoch unseren Aufwänden in der Pflege, das heißt Weiterentwicklung, vorhandener Software. Wie würde sich demnach unsere Perspektive ändern, wenn wir davon ausgehen, dass wir schon die Softwareentwicklung so betreiben können wie Wartungsprojekte? Was müssten wir leisten, damit wir inkrementell und unbegrenzt lange immer neue Funktionen in unsere Programme integrieren können?
Um diesen Gedankensprung in die Realität zu retten, benötigen wir eine qualitätsbewusste Alternative zum vorab geplanten Design. Wir suchen nach einer Strategie, mit der wir verhindern, dass die Codequalität mit wachsender Programmgröße in Mitleidenschaft gezogen wird. Eine vielversprechende Wiederentdeckung ist die testgetriebene Entwicklung, oft auch testgetriebene Programmierung oder gar testgetriebenes Design genannt, wenn auch andere Autoren mit dem Namen zum Teil bestimmte Schwerpunkte der Technik ansprechen. Im Englischen spricht man von Test-Driven Development oder Test-First-Design.
Just-in-time-Design
Testgetriebenes Programmieren ist eine Just-in-time-Designtechnik, um auf Just-in-time-Anforderungen einzugehen. Wir schreiben dabei Unit Tests, noch bevor wir den zu testenden Programmcode schreiben. Idealerweise wird jede funktionale Programmänderung zuvor durch das Schreiben eines weiteren Tests motiviert. Wir entwerfen diesen Test so, dass er zunächst fehlschlägt, weil das Programm die gewünschte Funktionalität noch nicht besitzt. Erst anschließend schreiben wir den Code, der den Test zum Laufen bringt. Auf diese Weise wird die gesamte Programmentwicklung inkrementell durch das unmittelbare Feedback konkreter Tests angetrieben.
Sie fragen sich vielleicht, was wir denn testen sollen, wenn wir noch überhaupt keinen Code geschrieben haben? Doch diese Frage lässt sich umdrehen. Woher wissen wir denn, was wir programmieren sollen, wenn wir noch nicht wissen, was denn überhaupt erforderlich ist? Zuerst die Tests zu schreiben, ist eine Möglichkeit, um herauszufinden, was wir programmieren müssen und was nicht, und wie wir auch sicherstellen können, dass wir tatsächlich programmieren werden, was wir programmieren wollten. Analysieren Sie dazu, wie die erforderliche Klasse funktionieren und sich verhalten sollte. Dokumentieren Sie dann Ihr Verständnis in einem ausführbaren Unit Test. Stellen Sie sich dabei vor, der zu testende Code wäre einfach schon realisiert. Entwerfen Sie die Tests aus der Verwendungsperspektive, so, wie Sie sich die Schnittstelle der zu testenden Klasse wünschen, und verdrängen Sie die Gedanken an die Implementierung für einen kurzen Moment.
Wir sprechen von einer Designtechnik, weil das frühe Testen eine ganze Reihe positiver Auswirkungen auf das resultierende Design hat. Ein Vorteil dieser Technik wird hier schon unmittelbar deutlich. Wenn Sie Ihre Tests zuerst schreiben, wird Ihr Code auch testbar sein. Den Test haben Sie ja schließlich gerade schon geschrieben. Code dagegen, der nicht unter Gesichtspunkten der einfachen Testbarkeit entworfen wurde, läßt sich auch nachträglich meist nur noch schwer testen. Tatsächlich handelt es sich beim Test-First-Ansatz noch stärker um eine Designstrategie als eine Teststrategie. Ich werde zum Ende des Artikels noch auf die Langzeiteffekte dieser Technik zurückkommen.
Testgetriebenes Design ist der linke Fuß unserer evolutionären Designstrategie. Der rechte Fuß ist das Refactoring. Um den inkrementellen Designansatz zu gewährleisten, ist unbedingt eine höchstsäuberlich strukturierte Codebasis erforderlich. Wir müssen sicherstellen, dass wir uns durch kurzfristig getroffene Entwurfsentscheidungen nicht zunehmend in die Ecke malen. Um die innere Qualität des Programms dabei nicht zu opfern, sondern über sehr weite Zeiträume aufrechtzuerhalten, müssen wir das Design durch fortlaufende überarbeitung in Stand halten. Wir verbessern die Struktur des Code durch unzählige kleine Refactorings und führen nach jedem Schritt alle gesammelten Tests aus, um sicherzugehen, dass wir nicht ungewollt das Verhalten des Programms verändert haben. Miteinander kombiniert ermöglichen testgetriebenes Programmieren und anschließendes Refactoring, dass wir entwickeln können, was wir brauchen, wenn wir es brauchen.
Iteratives Testen und Programmieren
Die testgetriebene Entwicklung zwingt uns in einen stark iterativen Prozess, in dem jeder Schritt die Möglichkeit zum Lernen bietet. Jeder Test, den wir schreiben und erfüllen, kann uns konkretes Feedback darüber liefern, welchen Test wir als nächstes schreiben könnten oder sollten. Je kleiner wir dabei unsere Programmieretappen wählen, desto schneller können wir lernen, wohin uns der Weg führt.
Der Zyklus des Testens und Programmierens schaut so aus:
- Wir entwerfen einen Test, der zunächst fehlschlagen sollte.
- Wir schreiben gerade soviel Code, dass der Test tatsächlich fehlschlägt.
- Wir schreiben gerade soviel Code, dass tatsächlich alle Tests durchlaufen.
Diesen Prozess wiederholen wir, solange uns weitere Tests einfallen, die unter Umständen fehlschlagen könnten, bis der Code schließlich seine durch die Tests spezifizierten Anforderungen erfüllt. Anschließend refaktorisieren wir den Code in die einfachste Form, oft jedoch schon früher, um "Platz" für weiteren Code zu schaffen und neue Tests dadurch einfacher erfüllen zu können. Dazu später mehr.
Machen wir mal ein kleines Beispiel.
Angenommen, wir möchten uns die Namen der Kunden eines Videoladens merken.
Um diese Funktionalität zu erreichen, schreiben wir im ersten Schritt einen
neuen Test.
Dieser Test schlägt zunächst fehl, weil sich ein Customer
bisher noch keinen Namen merken kann.
Die Klasse besitzt weder einen geeigneten Konstruktor, noch eine passende
Accessor-Funktion, um den Namen wieder abzufragen.
Um die nötigen änderungen einzufügen, definieren wir den folgenden Testfall.
public class CustomerTest...
public void testCustomerName() {
Customer customer = new Customer("Bent Keck");
assertEquals("Bent Keck", customer.getName());
}
}
Damit dieser Testfall überhaupt durchkompiliert, besteht der zweite
Schritt darin, wenigstens die minimalen Schnittstellenrümpfe in unsere
Customer
Klasse einzufügen.
Das überraschende ist jetzt, dass wir die Klasse nicht sofort
ausimplementieren, sondern den Testfall erst einmal mutwillig
fehlschlagen lassen.
Warum wir die Klasse leer lassen, wird Ihnen klarer werden, wenn Sie
auf die Farbübergänge unseres JUnit-Balkens Acht geben.
Noch ist er schön grün...
public class Customer...
public Customer(String name) {
}
public String getName() {
return null;
}
}
Unser Code sollte jetzt fehlerfrei durchkompilieren. Um den zweiten Schritt abzuschließen, machen wir den Test. Unser Testfall sollte fehlschlagen und so tut er es dann auch. Der abgedruckte rote Balken signalisiert, wo immer er auftaucht, dass wir die Tests zu diesem Zeitpunkt ausführen mit dem protokollierten Fehler als Resultat.
junit.framework.AssertionFailedError:
expected:<Bent Keck> but was:<null>
at CustomerTest.testCustomerName(CustomerTest.java:14)
Im dritten Schritt schreiben wir jetzt endlich den Code, der unseren Testfall erfüllt. Das überraschende hier wiederum ist, dass wir nur gerade soviel Programmcode schreiben, wie unbedingt nötig ist, um alle unsere gesammelten Testfälle zu erfüllen. Der grüne Balken drückt aus, dass der abgedruckte Code unsere Tests passiert.
public class Customer...
public String getName() {
return "Bent Keck";
}
}
Grüner Balken! Fertig! Nach nur 10 Sekunden, ist das nicht was?
Rollen Sie jetzt bitte nicht mit den Augen, wir gehen ja noch ein Stückchen weiter. Als Nächstes extrahieren wir den Namen in ein eigenes Feld. Moderne IDEs unterstützen dieses Refactoring auf Knopfdruck.
public class Customer...
private String name = "Bent Keck";
public String getName() {
return name;
}
}
Wieder ein grüner Balken! Alle Tests laufen! Was machen wir als Nächstes?
Hmm, den Namen des Kunden im Konstruktor im Feld merken?
public class Customer...
private String name;
public Customer(String name) {
this.name = name;
}
}
Erneut bleibt der Balken grün. Sind wir fertig?
Ich würde sagen, unsere kleine Aufgabe ist abgeschlossen, bis zu dem Zeitpunkt, dass wir einen weiteren Test schreiben, der den JUnit-Balken wieder rot färbt.
Was haben wir erreicht? Wir haben eine kleine neue Funktion in unser bestehendes Programm integriert. Wir haben zunächst die einfachste Lösung programmiert, die unseren Test besteht, und haben dann die Struktur des Code umgeformt, nachdem die Tests schon liefen. Wir sind dabei in ganz kleinen Schritten vorgegangen und haben nach jedem Schritt die Tests ausgeführt, um sicherzugehen, dass wir keine bestehende Programmfunktionalität beeinträchtigen. Bemerkenswert ist dabei, dass wir uns solange sicher wiegen können, solange wir genau wissen, aufgrund welcher Zeilen Code sich der Testbalken von grün auf rot oder zurück von rot auf grün verfärbt. Es ist dieser stetige Wechsel der beiden Farben, der uns minütlich über unseren letzten Schritt informiert.
- Arbeiten Sie von einem grünen Testbalken aus immer zunächst auf einen roten Balken hin. Arbeiten Sie dann vom roten Balken aus auf dem kürzesten Weg zurück auf den grünen JUnit-Balken. Beim grünen Balken angekommen nehmen Sie die Möglichkeit zum Refactoring wahr.
Mit diesem Vorgehen verzahnen wir Analyse, Design, Implementierung und Test zeitnah in einem iterativ-inkrementellen Prozess. Wir analysieren zuerst, was genau eigentlich von uns verlangt wird und was wir dafür zu tun haben. In einem ausführbaren Test spezifizieren wir, was wir dazu benötigen und was wir als Erfolgskriterium betrachten. Dann implementieren wir die neue Funktion. Abschließend testen wir, ob wir tatsächlich erreicht haben, was wir ursprünglich vor Augen hatten. Sie können sich sicher vorstellen, dass Sie viel stressfreier arbeiten, wenn jede kleinste Programmänderung, und mag sie noch so mutig gewesen sein, jederzeit binnen Sekunden durch einen automatischen Test auf erwünschte Wirksamkeit und unerwünschte Nebeneffekte geprüft werden kann.
Wir verfolgen mit dieser Arbeitsweise die Idee, das konkrete Feedback, das uns nur geschriebener Code liefern kann, unmittelbar als Basis für unseren nächsten Programmierzug verwenden zu können. Erfolgreich angewendet mündet der Fluss testgetriebenen Programmierens in eine Serie kleiner Erfolge positiven Feedbacks. Sie testen ein wenig, lassen sich von einem fehlschlagenden Test demonstrieren, wo änderungen notwendig sind, programmieren dort ein wenig, testen erneut und feiern einen kleinen Erfolg. High Five und dann von vorne.
Der Trick hier ist, eine Idee des kleinstmöglichen Inkrementschritts zu haben und diesen möglichst schnell und möglichst einfach in einer kurzen Programmieriteration in ausführbaren Code zu verwandeln. Das Prinzip dabei ist, sich auf dem Weg durch unmittelbares Feedback abzusichern.
Eine Testepisode
Ich möchte das Prinzip des unmittelbaren Feedbacks an einer kleinen
Geschichte verdeutlichen.
Starten wir dazu mit einer neuen Testepisode.
Unsere Aufgabe sei es, eine neue Preiskategorieklasse für Filme mit
regulärem Preis zu entwickeln.
Nennen wir sie RegularPrice
.
Wenn ich mitgezählt hätte, wie viele Male ich das Klassengerüst eines JUnit-Testfalls schon eingetippt habe, wäre es mir vermutlich früher langweilig geworden. Eines Tages wurde es mir dann wirklich leid und die Klassengenerierung automatisiert. Gute Erfahrungen konnte ich mit einer Klassenschablone wie der folgenden machen. Die meisten Entwicklungsumgebungen bieten die Codegenerierung derlei Klassengerüste für verschiedenste Zwecke an.
import junit.framework.*;
public class RegularPriceTest extends TestCase {
public RegularPriceTest(String name) {
super(name);
}
public void testFirst() {
fail("write a test first");
}
}
Der Clou an dieser leeren Klasse sind zwei Dinge.
Zum einen erkennt JUnit eine Klasse erst als Testfallklasse an,
wenn sie mindestens eine test...
Methode enthält.
Zum anderen hätte ich vielleicht auch mal mitzählen sollen,
wie oft ich eine neue Testklasse angelegt und vergessen habe,
diese in die passende Testsuite einzuhängen.
Gerade wenn Sie im Team entwickeln, bemerken Sie den Fortschritt in der Anzahl von Testfällen in Ihrem Projekt nicht mehr so einfach. Sie finden sich dann unter Umständen in der Situation wieder, dass Sie testen, was das Zeug hält, bis Ihnen irgendwann schlagartig bewusst wird, dass der Testbalken grün bleibt, was immer Sie auch für einen Unsinn hineintippen.
Nachdem ich mich auf diese Weise mehrmals zum Dummkopf gemacht hatte, dachte ich darüber nach, mir das Feedbackprinzip zunutze zu machen. Schließlich kam mir die Einsicht, jede neue Testfallklasse mittels eines roten Balkens einzuläuten.
Und so erwarte ich heute die Erinnerungsstütze durch den roten Balken und zwinge mich damit dazu, die neuen Tests in meine aktive Suite einzubinden.
public class AllTests...
public static Test suite() {
TestSuite suite = new TestSuite();
suite.addTestSuite(CustomerTest.class);
suite.addTestSuite(EuroTest.class);
suite.addTestSuite(MovieTest.class);
suite.addTestSuite(RegularPriceTest.class);
return suite;
}
}
Ein kurzer Test bestätigt unser Handeln.
junit.framework.AssertionFailedError: write a test first
at RegularPriceTest.testFirst(RegularPriceTest.java:10)
Eine Lösung, die das Problem von der anderen Seite packt,
ist eine AllTests
Testsuiteklasse, die automatisch
alle Testfälle ausführt, die sich im Klassenpfad finden lassen.
Sie finden eine solche Klasse neben vielen anderen JUnit-Erweiterungen
in der Dateiablage der JUnit-Yahoogroup unter
http://groups.yahoo.com/group/junit/files/.
Kommen wir jetzt auf unser Testproblem zurück...
1. Wir entwerfen einen Test, der zunächst fehlschlagen sollte
Eine bewährte Idee ist, dass wir uns zu Beginn einer Programmierepisode zunächst die notwendigen Testfälle überlegen und sie auf einer Karteikarte notieren. Das ist Analyse.
In der Regel werden Sie jedoch nicht alle Anforderungen im Kopf haben, sondern jemanden zu Rate ziehen müssen, der sich mit der Fachlichkeit besser auskennt. Schnappen Sie sich dazu einfach einen Kunden und verhandeln Sie mit ihm die genauen Anforderungen Ihrer Aufgabe. Wenn Sie sich noch näher beraten müssen, können Sie zu einer Wandtafel wandern und das Problem dort im kleineren oder grösseren Kreis analysieren und dafür Lösungen entwerfen.
Unsere Aufgabe ist eine neue Preiskategorie. Filme kosten regulär die ersten drei entliehenen Tage insgesamt 1.50 Euro. Für jeden weiteren Ausleihtag kommen weitere 1.50 Euro hinzu. Wir malen kurz ein Bild und benennen unsere ersten Testfälle.
Da erschöpfendes Testen unmöglich ist, ist eine nützliche Strategie, entlang von äquivalenzklassen und ihren Grenzbedingungen zu testen. äquivalente Tests testen in der Regel das gleiche Verhalten. Das heißt, dass für einen gegebenen Programmierfehler entweder alle Tests innerhalb einer äquivalenzklasse gemeinsam fehlschlagen oder alle Tests gemeinsam durchlaufen. Sehr häufig können wir durch diese Betrachtung die interessanten Tests finden und viele Fälle bereits ausklammern, die wir uns unter Umständen sparen können, weil sie uns keine neuen Informationen liefern.
Unsinnige Eingaben wie Null und negative Zahlen, erfahren wir auf Rückfrage von unserem Kunden, liegen außerhalb unseres aktuellen Problems. Die entsprechenden Vorbedingungen für robuste Eingaben wird demnach eine andere Klasse treffen.
Die interessanten Testfälle liegen für uns bei einem, drei, vier und fünf Ausleihtagen. Sie selbst mögen manchmal ganz andere Fälle testen wollen, als Ihr Programmierpartner vorschlägt. Das ist kein Problem, solange die Tests das Design antreiben.
In vielen Fällen werden wir unsere initial gewählte Liste von Testfällen ohnehin während der Entwicklung anpassen. Neue Testfälle mögen hinzukommen und geplante fallen wieder weg. Während der Entwicklung zu lernen ist ein gutes Zeichen. Und tatsächlich ist es sogar ganz interessant, eine andere Route einzuschlagen, als wir hier gehen werden.
Für den Einstieg picken wir uns einen Testfall aus unserer Liste
heraus, der ein möglichst kleines Inkrement für unsere bestehende
Codebasis darstellt.
Manchmal kann der erste Test gegen eine neue Klasse
auf die Erzeugung der Instanz selbst gerichtet sein sowie dem Sicherstellen
eines definierten Grundzustandes.
Dieser Testfall entfällt in diesem Beispiel jedoch, weil ein
RegularPrice
Objekt nur Verhalten und keinen Zustand hat.
Nehmen wir deshalb beispielsweise den Testfall für den ersten Ausleihtag.
Sie erinnern sich, wir wollen uns der gewünschten Funktionalität schrittweise annähern und die Feedbackschleife währenddessen möglichst häufig schließen. Wir wollen die Entwicklung eines evolutionären Designs antreiben, indem wir den Testfall so schreiben, dass er zunächst einmal fehlschlägt. Aus Sicht der hier und jetzt zu treffenden Entscheidungen kann dieser Schritt manchmal eine ernste Herausforderung darstellen.
Ferner entwerfen wir den Test so, wie wir uns die Verwendung der zu testenden Klasse wirklich wünschen. Das ist Design.
Wir müssen uns erst fragen, was die Klasse überhaupt tun soll, und uns anschließend überlegen, wie sie es denn tun soll. Den Blickwinkel auf diese Weise abzulenken ist deshalb praktisch, weil die Schnittstelle einer Klasse wichtiger ist als ihre Implementierung. Ignorieren Sie deshalb stets noch für einen Moment alle Implementierungsdetails, die sich schon in Ihrem Kopf zu Antwort melden, und schreiben Sie die Klassenschnittstelle einfach erst einmal so hin, wie sie im gewünschten Zusammenhang ideal verwendbar wäre. Ron Jeffries nennt diesen Gedankengang sprechend "Programming by Intention".
- Versetzen Sie sich geistig in den Programmierer, der Ihre Klasse verwendet, und entwerfen Sie die Klasse so, wie Sie sie intuitiv verwenden wollen würden.
Legen wir los! Die Ausleihgebühr beträgt für einen Tag 1.50 Euro. Wir benennen die generierte Testfallmethode passend um und schreiben mal hin, was wir vorhaben.
public class RegularPriceTest...
public void testChargingOneDayRental() {
RegularPrice price = new RegularPrice();
assertEquals(new Euro(1.50), price.getCharge(1));
}
}
Durch diese zwei Zeilen Testcode haben wir bereits einige Entwurfsentscheidungen getroffen.
- Unsere neue Klasse trägt den Namen
RegularPrice
. - Exemplare der Klasse erzeugen wir durch den Default-Konstruktor.
- Die Ausleihgebühr berechnen wir mit Hilfe einer Instanzmethode
getCharge
, die dazu die Anzahl von Ausleihtagen vom Typint
benötigt. - Als Resultat der Operation erwarten wir ein Objekt vom Typ
Euro
zurück.
Wir benutzen hier die Euro
Klasse aus dem vorherigen Artikel
"Unit Testing mit JUnit".
Falls Sie JUnit noch nicht kennen, finden Sie dort auch eine Einführung dazu.
2. Wir schreiben gerade soviel Code, dass der Test tatsächlich fehlschlägt
Wir haben soeben eine Testfallmethode geschrieben und versuchen zu kompilieren. In dem Fall, dass wir durch den Test eine neue Klasse oder eine neue Methode motivieren, erwarten wir, dass die übersetzung der Testklasse, grob gesagt, schief geht, obwohl es auch äußerst interessant wäre falls nicht.
Typischerweise wollen wir auf diesem Weg in kleinen Schritten Fehlermeldungen der Art provozieren, dass eine Klasse oder Methode, die wir in unserem Test bereits benutzen, in der geforderten Form noch gar nicht existiert. Wir prüfen dadurch, ob unsere neue Klasse oder unsere neue Methode nicht mit einer bestehenden Klasse oder Methode im System kollidiert. Die neue Klasse oder Methode existiert schließlich noch nicht. Durch Polymorphie können aber durch Vererbung sehr schwer identifizierbare Seiteneffekte auftreten und fast nichts ist schlimmer, als sich darin zu täuschen, welches Stückchen Code ausgeführt wird. Wenn Sie etwas mehr Glück haben, hat ein Kollege die Klasse schon implementiert.
In statisch typisierten Programmiersprachen wie Java macht der Compiler also den ersten Test.
Class RegularPrice not found.
Die Klasse wird nicht gefunden, so dass wir gezwungen sind, eine leere Hülse anzulegen.
public class RegularPrice {
}
Dann versuchen wir erneut zu kompilieren und bekommen wieder einen Fehler.
Method getCharge(int) not found in class RegularPrice.
Typisch für diesen Entwicklungsstil ist, dass wir uns von übersetzungsfehler zu übersetzungsfehler hangeln. Wir schreiben dabei nur gerade soviel Code, dass sich die Testklasse überhaupt übersetzen lässt. Unser Ziel sind dabei kleine schnelle Feedbackschleifen. Sehr interaktive Entwicklungsumgebungen und inkrementelle Compiler unterstützen diese extrem iterative Arbeitsweise sehr gut.
- Lassen Sie sich bei der Fehlerbehebung ruhig von der Entwicklungsumgebung führen. Beheben Sie nur gerade, was der aktuelle übersetzungsfehler von Ihnen verlangt.
Abschließend definieren wir die benötigte Methode.
public class RegularPrice {
public Euro getCharge(int daysRented) {
return null;
}
}
Da unser Test als Rückgabewert ein Euro
Objekt erwartet,
liefern wir vorerst einfach null
zurück.
Dieser Schritt mag zwar seltsam erscheinen, ist aber notwendig,
um den Test zunächst fehlschlagen zu sehen.
Wir rekompilieren. Unser Compiler ist zufrieden. Dann sind wir es auch. Schlägt unser neuer Test aber auch tatsächlich fehl?
Ein neuer Test, und zwar nur ein fehlschlagender, sollte Anlass sein zum Schreiben neuer Programmfunktionalität. Normalerweise stellen wir mit jedem neuen Test neue Behauptungen auf, an denen wir unsere bestehende Codebasis elegant scheitern lassen. Der einzige Weg, um den Test zu erfüllen, ist somit, das Programm zu ändern und die tatsächlich geforderte Funktionalität zu implementieren.
Wir wollen dabei immer vom letzten grünen Balken ausgehend auf einen roten Balken hinarbeiten. Indem wir den Test zunächst fehlschlagen sehen, testen wir den Test selbst. Wenn der Test fehlschlägt, war der Test tatsächlich erfolgreich. Ein solcher Test gibt uns Vertrauen, dass wir einen nützlichen Test geschrieben haben. Läuft der Test aber unvermutet durch, haben sich unsere Annahmen als fehlerhaft entpuppt. Entweder haben wir dann nicht den Test geschrieben, den wir zum Antreiben unseres weiteren Designs schreiben wollten, oder unser Code implementiert die neue Funktion wirklich bereits. Beides stellt für uns nützliches Feedback dar.
Machen wir also den Test für den Test.
junit.framework.AssertionFailedError:
expected:<1.5> but was:<null>
at RegularPriceTest.testChargingOneDayRental
(RegularPriceTest.java:11)
Es ist zu einfach, diesen kleinen Zwischenschritt aus falscher Bequemlichkeit auszulassen. Ich bekomme oft die Frage gestellt, ob ich wirklich so "umständlich und offensichtlich langsam" arbeite. Dabei kann uns der rote Balken und seine Fehlermeldung(en) immer sagen, was der nächste Programmierzug ist. Und das Prinzip ist so einfach. Arbeiten Sie nur vom roten auf den grünen Balken hin.
3. Wir schreiben gerade soviel Code, dass tatsächlich alle Tests durchlaufen
Von vielen verschiedenen Wegen, neue Funktionalität in das Programm zu implementieren, schlagen wir immer den Weg ein, der für uns am einfachsten erscheint. Wir programmieren zu jedem Zeitpunkt wirklich nur, was zur Erfüllung des aktuellen Testfalls absolut notwendig ist, und kein wenig mehr.
In einigen Fällen kann die einfachste Lösung sein, aus einer Methode einen festkodierten Wert zurückzuliefern. Unfassbar, aber wahr.
public class RegularPrice {
public Euro getCharge(int daysRented) {
return new Euro(1.50);
}
}
Sowas kann natürlich nur eine übergangslösung sein bis zu dem Zeitpunkt, da uns ein weiterer Test beweist, dass die aktuelle Lösung wirklich zu einfach ist. In dem Fall sind wir dann gezwungen, für die fehlende Funktionalität weiteren Code zu schreiben. Auf diesem Weg treiben die Tests schrittweise die Entwicklung fortgeschrittener Logik an, die nicht mehr allein mit festkodierten Werten auskommt.
Eine Warnung sei hier ausgesprochen. Wenn Sie mehr Code schreiben, als Ihr Testfall in Wirklichkeit verlangt, schreiben Sie immer zu wenige Tests. Sie schreiben dann Code, der unter Umständen von keinem Test gesichert wird. Die Codeabdeckung Ihrer Tests wird in diesem Fall geringer ausfallen und das Sicherheitsnetz, das durch die feingranularen Unit Tests für das Refactoring entsteht, wird dadurch grobmaschiger. Versuchen Sie jedoch trotzdem unbedingt herauszufinden, mit wie wenigen Tests Sie in Ihrer Realität auskommen könnten. Der ultimative Test für Ihre Testsuite ist dabei, dass ein grüner Balken Ihnen Vertrauen in Ihre Programmänderung schenken soll. Ein grüner Balken soll Sie praktisch Glauben machen, das Programm enthielte keine Fehler. Sobald Ihnen jedoch welche durchs Netz gehen, sollten Sie unbedingt weitere Tests schreiben. Das Beste, was Sie tun können, ist experimentieren, messen und reflektieren. Erwarten Sie jedoch nicht, dass das, was nicht getestet ist, auch funktioniert.
Während Sie so programmieren und über der Implementation brüten, werden Ihnen oft weitere interessante Grenzfälle auffallen, die Sie ebenfalls unbedingt noch testen wollen. Denken Sie in Momenten wie diesen an die änderungsfreundliche Karteikarte, auf der Sie schon die anderen Testfälle notiert haben. In vielen solcher Fälle werden Sie ohnehin erst einmal Rücksprache mit Ihrem Kunden halten müssen, was eigentlich passieren soll, wenn der und der Fall eintritt.
Wir sind am Ende einer kurzen Testetappe angekommen. Jetzt erfolgt der wirkliche Test, ob wir erreicht haben, was wir erhofft hatten, zu erreichen. Die Tests sagen uns, wann wir fertig sind: wenn alle Tests laufen.
Wenn der Testbalken wie jetzt grün wird, wissen wir, dass es der gerade geschriebene Code war, der den Unterschied gemacht hat. Es ist diese Art der Entwicklung, die uns ein kurzes Glücksgefühl schenken kann, das Ihnen vorenthalten bleibt, solange Sie ohne diese kurzen, konkreten Feedbackzyklen programmieren. Der kleine Erfolg gibt uns zu Recht einen kurzen Moment zum Durchatmen und Freimachen ("Okay, geschafft!") und bietet einen natürlichen Abschluss für den gegenwärtigen Gedankengang ("Was ist der nächste Testfall?"). Die Tests reduzieren so Ihren Stress.
Wenn der Testbalken dagegen rot bleibt, wissen wir, dass es ebenfalls der gerade geschriebene Code gewesen sein muss, der den Fehler provoziert hat. Unterscheiden müssen wir dabei, ob nur der zuletzt geschriebene Test betroffen ist oder gar ältere Tests wieder zerbrochen sind, die schon einmal liefen. Da wir immer nur ein paar Zeilen Code und selten eine ganze Methode am Stück schreiben, lohnt es in den meisten Fällen jedoch kaum, dafür einen Debugger zu öffnen. Es sei denn, Sie sind Smalltalker. Dort findet ein beträchtlicher Teil der testgetriebenen Programmierung tatsächlich nur im Debugger statt. Es ist deshalb vorstellbar, dass Java-Umgebungen eines Tages ähnlichen Luxus bieten werden.
Hier schließt sich dann auch der Kreislauf der testgetriebenen Entwicklung.
Tests und Programmcode im Wechselspiel
Wenn wir dem Prozess aufmerksam folgen, fällt uns ein enges Wechselspiel zwischen den Tests und dem Programmcode auf.
- Der fehlschlagende Test entscheidet, welchen Code wir als nächstes schreiben, um die Entwicklung der Programmlogik voranzutreiben.
- Wir entscheiden anhand des bisher geschriebenen Code, welchen Test wir als nächstes schreiben, um die Entwicklung des Designs weiter anzutreiben.
Der zweite Punkt erfordert im Vergleich wesentlich mehr Kreativität, weil Tests oft schwieriger zu formulieren als zu erfüllen sind.
Aufgrund der gegenseitigen Rückwirkung zwischen Tests und Programmcode ist es nicht ratsam, mehrere fehlschlagende Testfälle gleichzeitig im Rennen zu haben oder gar erst alle möglichen Testfälle zu schreiben und sie anschließend Stück für Stück zu implementieren. Stellen wir während der Implementierung nämlich fest, dass unser Design in eine ganz andere Richtung gehen möchte, müssten wir alle unsere geschriebenen Tests noch mal ändern.
Manchmal können wir gerade erst während der Programmierung erkennen, welchen Test wir eigentlich hätten schreiben müssen. Oft werden dann getroffene Entwurfsentscheidungen invalidiert und in vielen Fällen ändert sich die Klassenschnittstelle noch einmal. Aus diesem Grund ist es angebracht, nur einen Test auf einmal zu schreiben und sofort zu implementieren. Nur so lernen wir schrittweise, welche Tests uns wirklich weiterbringen. Die Buchführung darüber, welche Tests wir noch schreiben wollen, können wir auf einer einfachen Karteikarte vornehmen.
- Schreiben Sie einen Testfall zurzeit und bringen Sie ihn zum Laufen, bevor Sie die Arbeit am nächsten Testfall beginnen. Wenn Sie mehrere Testfälle schreiben müssen, sammeln Sie sie auf einer Karteikarte.
Ausnahmebehandlung
Der erste Durchlauf sollte Ihnen zeigen, wie sich die testgetriebene Entwicklung anfühlen sollte. Gewissermaßen habe ich ein Idealbild skizziert, dessen Realität häufig von Ausnahmen begleitet sein wird. Genau genommen handelt es sich ausdrücklich um Ausnahmen in meiner Beschreibung, jedoch nicht im Prozess selbst. Während der Programmierung werden diese "Bad Day" Szenarien ebenso häufig auftreten wie das zuvor beschriebene "Good Day" Szenario.
Die drei häufigsten Ausnahmefälle treten während der Schritte 2 und 3 auf.
- Der Test läuft, obwohl er eigentlich fehlschlagen sollte.
- Der Test schlägt fehl, obwohl er eigentlich laufen sollte.
- Die Implementierung des Tests erfordert neue Methoden und damit weitere Tests.
2a. Der Test läuft, obwohl er eigentlich fehlschlagen sollte
Die natürliche Triebfeder des testgetriebenen Designs ist, dass wir Tests schreiben, die neue Funktionalität einfordern und unseren bestehenden Code auf intelligente Weise zerbrechen. Solche Tests schlagen fehl, bis wir den Code wieder in Ordnung gebracht und um die neue Funktion erweitert haben.
Manchmal lässt sich das Design jedoch nicht so elegant durch neue Tests erzwingen. Manchmal schreiben wir einfach Tests, die schon auf Anhieb laufen. Wieso das?
Hier sehen Sie ein Beispiel. Unser nächster Test ist die Ausleihgebühr für drei Tage.
Für den zweiten Test benötigen wir erneut ein price
Objekt,
weshalb es günstig erscheint, es zunächst in die Test-Fixture herauszuziehen.
public class RegularPriceTest...
private RegularPrice price;
protected void setUp() {
price = new RegularPrice();
}
public void testChargingOneDayRental() {
assertEquals(new Euro(1.50), price.getCharge(1));
}
}
Durch dieses Refactoring können wir gleich viel einfacher den zweiten Test hinschreiben und müssen nicht unnötig Code kopieren. Vorher lassen wir jedoch zur Sicherheit die Tests durchlaufen.
Dann schreiben wir den neuen Test für drei Ausleihtage.
public class RegularPriceTest...
public void testChargingThreeDayRental() {
assertEquals(new Euro(1.50), price.getCharge(3));
}
}
Keine überraschung, unser neuer Test läuft auf Anhieb, weil er keine neuen Forderungen stellt.
In diesem Fall hätten wir zwar nur eine andere Testreihenfolge einschlagen müssen, doch nicht immer lässt sich so leicht der Testfall finden, mit dem wir wie gewünscht den roten Testbalken erzwingen.
Tests dieser Art sind für die testgetriebene Entwicklung zunächst einmal wertlos, weil sie uns mit dem Design nicht weiterbringen. Diese Tests erzählen uns jedoch eine Geschichte über unseren Testprozess selbst, aus der wir für die Zukunft lernen können. Aber wie sind wir hier überhaupt hingekommen?
Ein grüner Balken könnte doch allerhöchstens bedeuten, dass entweder unser Code die neue Funktion schon realisiert oder unser Test bereits einen Fehler enthält. Unter Umständen sind ja wirklich die Behauptungen fraglich, die wir im letzten Test aufgestellt haben. Die Wahrscheinlichkeit dafür ist zwar meist relativ gering, wenn wir uns aber vor Augen halten, dass sich der Fehler nur in den Code eingeschlichen haben kann, den wir gerade getippt haben, geht eine genaue Nachprüfung schnell vonstatten.
Wann immer ich dem grünen Balken jedoch nicht ganz und gar traue, streue ich oft extra kleine Fehler in Tests oder Code ein. Die Geschichte vom nicht ausgeführten Test kennen Sie ja bereits.
Manchmal kann es auch ratsam sein, den getippten Unfug der vergangenen wenigen Minuten einfach auf den Haufen zu werfen und in kleineren Schritten mit häufigerem Testen nochmals von vorne zu starten. Ehrlich. Fassen Sie Mut und gehen Sie zum letzten grünen oder auch roten Balken zurück, mit dem die Welt noch in Ordnung war. Probieren Sie's wenigstens einmal aus. Sie könnten sich damit unter Umständen viel Zeit und Mühe ersparen.
Der andere Grund für einen unerwarteten grünen Balken ist, dass die vermeintlich neue Funktion wirklich schon implementiert und getestet ist. Dieser Umstand spricht dann auch tatsächlich über unseren Testprozess und bietet eine gute Gelegenheit zum Reflektieren.
- Haben wir für einen der vergangenen Testfälle geringfügig mehr Code geschrieben, als unbedingt notwendig gewesen wäre?
- Ist dieser Fall schon anderswo mitgetestet? Vielleicht nur indirekt? Wie stark unterscheiden sich die Tests? Und können wir sie geeignet zusammenfassen?
- Duplizieren wir gerade Testcode und wissen es vielleicht nicht?
- Haben die Tests, die wir schreiben, zuwenig "Kick" dafür, dass sie interessante Entwurfsentscheidungen aufwerfen? Können wir solche Tests zukünftig ans Ende der Episode stellen? Ist es dann überhaupt noch notwendig, sie zu schreiben?
In diesem Fall hatten wir einfach nur Pech mit der Wahl unseres Inkrements. Falls es ein nächstes Mal gibt, sollten wir vorher zum Beispiel vier Ausleihtage testen.
Ernsthaft problematisch wird es, wenn es schwieriger ist, den Testcode korrekt zu schreiben als den Programmcode. Ihre Tests sollten deswegen keine eigene Logik enthalten, sonst müssten Sie für sie selbst wiederum Tests schreiben. Die ganze Arbeitsweise beruht auf der stillen Annahme, dass die Tests einfacher zu schreiben sind als der zu testende Code. Sobald sich dieses Verhältnis bei Ihnen permanent umdreht, gibt es nichts, als einfachere Tests zu schreiben. Da Tests und Code sehr stark miteinander verzahnt sind, werden Sie dabei jedoch immer auch Einsicht in ein einfacheres und besseres Design finden.
3a. Der Test schlägt fehl, obwohl er eigentlich laufen sollte
Die Eleganz des testgetriebenen Programmieransatzes liegt in seinen schnellen und konkreten Feedbackzyklen. Zwischen dem Zeitpunkt einer getroffenen Entwurfsentscheidung und dem bewiesenen Erfolg oder Misserfolg dieser Idee liegen nur wenige Minuten und immer nur ein paar Zeilen Code. Wenn wir in sehr kleinen Schritten vorgehen und uns stets am einfachsten Design orientieren, vergehen zwischen zwei grünen Testbalken gerade einmal 1-3 Minuten.
Manchmal jedoch sind unsere Inkremente zu groß gewählt und wir verzetteln uns einfach. Manchmal machen wir an sich vermeidbare Programmierfehler. Manchmal schreiben wir Code, der gar nicht funktioniert wie erwartet.
Hier ist ein Beispiel, wie es sich ganz tatsächlich zugetragen hat. Wir testen die Ausleihe von vier Tagen.
public class RegularPriceTest...
public void testChargingFourDayRental() {
assertEquals(new Euro(3.00), price.getCharge(4));
}
}
Wir prüfen noch, ob der Test auch wirklich fehlschlägt...
junit.framework.AssertionFailedError:
expected:<3.0> but was:<1.5>
at RegularPriceTest.testChargingFourDayRental
(RegularPriceTest.java:24)
Dann implementieren wir, was uns als erste Lösung in den Kopf kommt...
public class RegularPrice {
public Euro getCharge(int daysRented) {
Euro result = new Euro(1.50);
if (daysRented == 4) {
result.add(new Euro(1.50));
}
return result;
}
}
Doch unser Test sagt "Stoppt mal!"
junit.framework.AssertionFailedError:
expected:<3.0> but was:<1.5>
at RegularPriceTest.testChargingFourDayRental
(RegularPriceTest.java:24)
Oops, wir haben wohl vergessen, zu rekompilieren. Wir kompilieren also neu...aber ohne Resultat.
Wie sind wir hierher gekommen? Die Regel ist, dass der Fehler nur in dem Code stecken kann, den wir gerade getippt haben. Sehen Sie den Fehler?
Wir können uns aus dem Geschehen gerade keinen Reim machen und verwerfen deshalb unsere letzte änderung. Zeitlich verlieren wir durch den Rollback ungefähr eine Minute. Wer weiß, wieviele unnötige Minuten wir uns dadurch tatsächlich ersparen...
public class RegularPrice {
public Euro getCharge(int daysRented) {
return new Euro(1.50);
}
}
Wenn wir nur gerade unseren aktuellen Testfall erfüllen wollen, wäre folgende Lösung eigentlich noch viel einfacher gewesen, müssen wir uns schließlich eingestehen.
public class RegularPrice {
public Euro getCharge(int daysRented) {
if (daysRented <= 3) return new Euro(1.50);
return new Euro(3.00);
}
}
Okay, dieser Code belohnt unseren Test mit grüner Farbe. Trotzdem sind wir mit der Lösung noch nicht zufrieden. In unserem ersten Versuch wollten wir bereits ausdrücken, dass der Preis aus einem fixen und einem variablen Anteil besteht. Machen wir das mal.
public class RegularPrice {
public Euro getCharge(int daysRented) {
if (daysRented <= 3) return new Euro(1.50);
return new Euro(1.50).add(new Euro(1.50));
}
}
Brrr...
Bei diesem Anblick läuft uns ein kleiner Schauer über den Rücken.
Zum einen: Warum tauchen hier drei new Euro(1.50)
Schnipsel auf?
Zum anderen: Was bedeuten diese drei identischen Schnipsel?
Oder: Sind sie überhaupt identisch?
Aber: Moment, Moment, Moment.
Wieso funktioniert das add
Konstrukt plötzlich an dieser
Stelle, nicht aber so vor einer Minute in unserem ersten Versuch?
Lassen Sie uns jetzt doch noch einmal der Sache auf den Grund gehen.
Die Tests lügen nicht.
Ein Blick auf den zugehörigen Testfall zeigt ein Beispiel zur korrekten
Verwendung der Euro
Klasse.
Schlagen Sie bitte im vorherigen Artikel
"Unit Testing mit JUnit"
nach, wenn Sie die Testfallklasse als Ganzes lesen möchten.
public class EuroTest...
private Euro two;
protected void setUp() {
two = new Euro(2.00);
}
public void testAdding() {
Euro three = two.add(new Euro(1.00));
assertEquals("sum", 3.00, three.getAmount(), 0.001);
assertEquals("two", 2.00, two.getAmount(), 0.001);
}
}
Okay, Objekte der Klasse Euro
sind also nicht veränderbar.
Stattdessen wird ein neues Objekt mit dem neuen Wert zurückgegeben.
Wir beschließen, dass plus
ein besserer Name für die
add
Operation wäre und dass eine entsprechende Umbenennung
fällig ist, sobald wir mit den Tests abgeschlossen haben.
Mit einem grünen Balken im Rücken machen wir uns endlich ans Refactoring. Zur Erinnerung: Die unzähligen "ein Euro fuffzig" Objekte störten uns. Guter Code braucht gute Namen. Ein Refactoring sollte immer durchgeführt werden, wenn wir etwas gelernt haben und wir dieses Wissen Bestandteil des Programms selbst machen möchten.
public class RegularPrice {
static final Euro BASE_PRICE = new Euro(1.50);
public Euro getCharge(int daysRented) {
if (daysRented <= 3) return BASE_PRICE;
return BASE_PRICE.add(new Euro(1.50));
}
}
Ein kurzer Testlauf und weiter geht's mit den anderen "150 Cents".
public class RegularPrice {
static final Euro BASE_PRICE = new Euro(1.50);
static final Euro PRICE_PER_DAY = new Euro(1.50);
public Euro getCharge(int daysRented) {
if (daysRented <= 3) return BASE_PRICE;
return BASE_PRICE.add(PRICE_PER_DAY);
}
}
Ebenso gehen wir mit der Anzahl Tage um, die der reduzierte Preis gelten soll.
public class RegularPrice {
static final Euro BASE_PRICE = new Euro(1.50);
static final Euro PRICE_PER_DAY = new Euro(1.50);
static final int DAYS_DISCOUNTED = 3;
public Euro getCharge(int daysRented) {
if (daysRented <= DAYS_DISCOUNTED) return BASE_PRICE;
return BASE_PRICE.add(PRICE_PER_DAY);
}
}
Durch den letzten Testfall und anschließendes Refactoring hat unser Code erste Form angenommen. Der Punkt aber war, dass es immer lohnt, kleinere Schritte zu wählen. Fehler der Art dieses Beispiels sind leider menschlich. Deshalb ist auch mein einziger Rat, den ich hier geben kann, in ganz kleinen Schritte vorzugehen, gescheit mit einem Partner zu programmieren und aus den Fehlern der Vergangenheit für die Zukunft zu lernen.
Wenn Sie jedoch ins Stocken geraten und viele Minuten allein zur Fehlersuche benötigen, dann schreiben Sie entweder zu wenige, zu große oder die falsche Art von Tests. Vielleicht machen Ihnen aber auch unerwünschte Seiteneffekte zu schaffen, dann sollten die Unit Tests relativ nahe der Fehlerursache Alarm schlagen und sofort den Fehler finden lassen. Wenn sehr viele Tests gleichzeitig zerbrechen, kann dies häufig dafür sprechen, dass Ihr Design unter Umständen nicht ausreichend entkoppelt ist.
3b. Die Implementierung des Tests erfordert neue Methoden und damit weitere Tests
Seine schnellen Feedbackzyklen erfährt der Testansatz dadurch, dass jeder Test zunächst auf sehr einfache Weise erfüllt wird und keine unnötige Komplexität entsteht. Sinnvolle Generalisierungen und Abstraktionen bilden sich erst nach Richtungsgabe weiterer Tests heraus. Test für Test entwickeln wir so, was unser Design für heute wirklich benötigt.
Manchmal können wir einen Test jedoch beim besten Willen nicht einfach mal so schnell erfüllen. Manchmal wäre zunächst ein vereinfachendes Refactoring angebracht. Manchmal entdecken wir, dass uns zur intuitiven Implementierung eigentlich noch Methoden oder Objekte fehlen. Manchmal ist der Schritt, den unser Testfall nimmt, auch einfach nur zu groß und wir bohren uns immer weiter in die Tiefe, bis wir schließlich das Tageslicht verlieren und kapitulieren müssen.
Auch hierzu betrachten wir ein konkretes Beispiel. Unser letzter Testfall testet die Fortschreibung der Progressionsrampe für die Ausleihe von mehr als vier Tagen.
public class RegularPriceTest...
public void testChargingFiveDayRental() {
assertEquals(new Euro(4.50), price.getCharge(5));
}
}
Erneut lassen wir uns von unserer Testsuite führen.
junit.framework.AssertionFailedError:
expected:<4.5> but was:<3.0>
at RegularPriceTest.testChargingFiveDayRental
(RegularPriceTest.java:28)
Für diesen Testfall müssen wir für alle Zeiträume von mehr als drei Tagen den tagesabhängigen Euro-Betrag mit den Tagen multiplizieren, die der Kunde drüberliegt.
public class RegularPrice...
public Euro getCharge(int daysRented) {
if (daysRented <= DAYS_DISCOUNTED) return BASE_PRICE;
int additionalDays = daysRented - DAYS_DISCOUNTED;
return BASE_PRICE.add(new Euro(PRICE_PER_DAY.getAmount()
* additionalDays));
}
}
Whoa, diese Lösung ist hübsch hässlich.
Um den Test möglichst einfach zu erfüllen, haben wir unser
Euro
Objekt PRICE_PER_DAY
erst
wieder in den primitiven Typ double
wandeln
müssen, um es dort mit der Anzahl von Tagen zu multiplizieren,
um dann sofort wieder ein Euro
Objekt zu bauen.
Die Berechnung des Resultats lässt sich kaum noch darüber aus,
was eigentlich passiert.
Wann immer ein Programm so aus der Form gerät wie jetzt gerade,
will es uns etwas mitteilen.
Hier sieht es danach aus, als fehle der Euro
Klasse
die Möglichkeit, Geldbeträge mit ganzen Zahlen zu multiplizieren.
Wir müssen dieses Verständnis darüber, wie der Code strukturiert
werden will, in das Programm selbst zurückführen.
Definitiv richtig ist jedoch die Entscheidung, erst die einfache, aber hässliche Lösung hinzuschreiben, damit den Test zu erfüllen und dann zu refaktorisieren. Nachdem der Test grün ist, haben wir eine viel bessere Ausgangsbasis für eine funktionserhaltende Verbesserung des Designs. Wir refaktorisieren nur mit einem grünen Balken.
Die Disziplin dafür aufzubringen und durchzuhalten, zu verschieben, was Sie schon vor Ihrem geistigen Auge sehen, wenn auch nur für wenige Momente, ist ein Schlüsselaspekt dieses Prozesses. Bleiben Sie immer in der Nähe des grünen Lichts und versuchen Sie nicht, zuviele Bälle gleichzeitig in der Luft zu jonglieren.
- Arbeiten Sie nicht an verschiedenen Enden zur gleichen Zeit. Versuchen Sie Probleme nacheinander zu lösen, nicht gleichzeitig. Brechen Sie die Probleme stattdessen kleiner und holen Sie sich öfters Feedback ein.
Unser letzter Test läuft und damit haben wir jetzt die Verantwortung, den Code so zu verbessern, dass er unsere Intention besser für andere Programmierer kommuniziert.
Was wir eigentlich ausdrücken wollen ist, dass die Euro
Klasse die Verantwortlichkeit übernimmt, einen Geldbetrag mit einer
ganzen Zahl zu multiplizieren.
Zeit also, einen weiteren Test für die Klasse zu schreiben.
Wir verwenden erneut das "zwei Euro" Objekt aus der Fixture.
public class EuroTest...
private Euro two;
protected void setUp() {
two = new Euro(2.00);
}
public void testMultiplying() {
assertEquals(new Euro(14.00), two.times(7));
}
}
Den weiteren Testprozess sollten Sie soweit schon kennengelernt haben.
Wir kompilieren, nur um zu sehen, ob in der Zwischenzeit nicht schon
jemand aus unserem Team eine times
Methode benötigt hat.
Das scheint heute jedoch nicht der Fall zu sein.
Vielleicht haben wir morgen mehr Glück...
Method times(int) not found in class Euro.
Solange sich der Code nicht fehlerfrei übersetzen lässt, können wir nicht die Tests ausführen, und solange sich die Tests nicht ausführen lassen, können wir nicht herausfinden, was unser nächster Programmierzug sein will. Ein leerer Methodenrumpf muss her.
public class Euro...
public Euro times(int factor) {
return null;
}
}
Sofort beschwert sich JUnit über die leere Implementierung.
junit.framework.AssertionFailedError:
expected:<14.0> but was:<null>
at EuroTest.testMultiplying(EuroTest.java:34)
Die Lösung dazu ist einfach.
public class Euro...
public Euro times(int factor) {
return new Euro(cents * factor);
}
}
Damit können wir die neue times
Methode in unserer
Preisberechnung mitbenutzen, wodurch sich der Code ganz erheblich
vereinfacht.
public class RegularPrice...
public Euro getCharge(int daysRented) {
if (daysRented <= DAYS_DISCOUNTED) return BASE_PRICE;
int additionalDays = daysRented - DAYS_DISCOUNTED;
return BASE_PRICE.add(PRICE_PER_DAY.times(additionalDays));
}
}
Meine Erfahrung sagt, dass es immer möglich ist, wie in diesem Beispiel zunächst die einfache, aber dreckige Lösung einzuschlagen und hinterher sofort zu refaktorisieren.
Wenn wir den aktuellen Testfall jedoch tatsächlich nur mit einer neuen Methode oder einer neuen Klasse möglichst einfach zum Laufen zu kriegen meinen, dann müssen wir uns an den langsameren iterativen Abstieg machen. Dazu fangen wir eine neue Testfallmethode an und implementieren die neue Methode oder die neue Klasse auf die gewohnte testgetriebene Art und Weise. Das hätten wir in diesem Beispiel sogar auch machen können. Wir müssen dabei nur Acht geben, dass wir nicht länger tauchen gehen, als uns der Sauerstoff reicht. Ständiges Feedback erhalten wir bei der Bottom-up-Implementierung nur, wenn wir häufig genug auftauchen und die Top-level-Tests ausführen können.
Wenn wir zur Erfüllung des Testfalls zum Beispiel sofort die
times
Methode aufgerufen und mitentwickelt hätten,
wäre diese Methode schlussendlich nur indirekt durch einen
Testfall der RegularPriceTest
Klasse mitgetestet.
Wenn wir gleichzeitig einen neuen Testfall testMultiplying
geöffnet hätten, würden wir uns mit zwei fehlschlagenden
Testfällen gleichzeitig rumschlagen.
Das wäre Murks.
Offene Tests, die wegen "inkompatibler Umstellungen"
auskommentiert wurden, sich für eine Zeit lang nicht übersetzen lassen
oder aus anderen Gründen fehlschlagen, deuten in diesem Prozess oft
auf eine ungeschickte Zerlegung der Programmieraufgabe in Teilaufgaben
und Testfälle.
Ich werde in meinem nächsten Artikel noch mehr auf diese Problematik
eingehen.
Berührungspunkt zum einfachen Design
Das Feedbackprinzip erfordert, dass der Weg zur Implementierung des aktuellen Testfalls extrem kurz gehalten wird. Ansonsten zeigt die Erfahrung, dass sich manch einer beim Top-down-Entwurf tiefer und tiefer in die Innereien des Systems bohrt. Der schlimmste Fall wäre, dass unser aktueller Testfall eine Methode benötigt, die noch eine weitere Methode benötigt, die wiederum zusätzliche Methoden benötigt und so weiter. Das Ergebnis eines solchen Top-down-Abstiegs wäre während der gesamten Bottom-up-Implementierung ungewiss, bis alle unsere Tests wieder laufen.
Es ist diese Problematik, die es rechtfertigt, die Dinge beim einfachen Namen zu nennen und wörtlich die einfachste Lösung zu suchen, die möglicherweise funktionieren könnte. Nehmen Sie an, dass Sie momentan nicht mehr benötigen, als Ihr Testfall verlangt. Wenn Ihr Testfall schon zuviel auf einmal erfordert, zerlegen Sie ihn einfach in mehrere kleinere oder verwerfen Sie ihn und schreiben Sie den einfachsten Testfall, der Ihnen einfällt.
Der nötige Vereinfachungsschritt, um herauszufinden, wie wir den Code in kleinen Babyschritten und in möglichst einfacher Weise ins Leben testen können, kann extrem herausfordernd sein. Den Test dann hinterher zu erfüllen, ist dagegen oft der leichtere Schritt von beiden.
Berührungspunkte zum Refactoring
Neue Tests stets nur auf die einfache, hässliche Art zum Leben zu bringen, würde zwangsläufig zu einem Design-Fiasko führen und die Weiterentwicklung des Programms letztlich zum Kollaps bringen. Um die Struktur des Programms aber aufrechtzuerhalten und fortlaufend zu verbessern, ist ein regelmäßiges Refactoring im Prozess der testgetriebenen Entwicklung absolut unerlässlich.
Wann ein Refactoring notwendig wird, müssen Sie noch selbst entscheiden, doch es gibt zum Glück nur zwei Momente, zu denen sich ein Refactoring aufdrängen kann.
- Um einen Test möglichst einfach zu erfüllen, können wir unseren Code vorher in Form refaktorisieren.
- Unmittelbar nachdem die Tests laufen, müssen wir unseren Code in die einfachste Form refaktorisieren.
Ein vorausschauendes Refactoring ist dabei als Kürteil anzusehen und ein ausgelassenes Refactoring ist immer durch ein nachgezogenes kompensierbar, wenn auch die Kosten dafür mit der Zeit steigen. Ein abschließendes Refactoring dagegen gehört zum Pflichtteil und tritt dadurch mit Abstand häufiger auf. Kent Beck hat deshalb das Mantra "Make it run, make it right, make it fast." geprägt.
Das bedeutet, dass wir zunächst unsere Tests erfüllen, obwohl wir genau wissen, dass es insgesamt vielleicht noch einfachere Lösungen gibt und dass ein geschicktes Design weit mehr erfordert, als nur einen grünen Testbalken hervorzuprogrammieren. Sobald die Tests jedoch laufen, haben wir wenigstens die Versicherung, dass wir die Nuss überhaupt knacken können, dass wir den richtigen Weg eingeschlagen haben und dass wir mit den Tests als Sicherheit unbekümmert mit verschiedenen Refactoringzügen experimentieren könnten. Programmierung und Refaktorisierung sollten sich dabei überhaupt nicht an geläufigen Vorstellungen von laufzeitoptimiertem Programmcode orientieren. Performance-Fragen werden stattdessen später durch den Einsatz eines Profilers beantwortet. Das Programm selbst soll uns informieren, wo es an Geschwindigkeit mangelt. Immerhin erlaubt überhaupt erst gut faktorisierter Code ein effektives Performance-Tuning an den benötigten Stellen. Außerdem können wir so anschließend mit dem Werkzeug messen, was die Optimierung wirklich gebracht hat, außer schlechter lesbarem Code, und können so unnötige Tricksereien wieder rückgängig machen.
Eine wichtige Frage ist, wann der richtige Zeitpunkt zum Refactoring gekommen ist. Wieviel Unrat dürfen wir anhäufen, bevor wir hinter unserer Testsuite aufzuräumen beginnen? Die wirkliche Frage, die sich dahinter jedoch versteckt, lautet anders. Wann haben wir genug gelernt, um das Refactoring zielgerichtet durchführen zu können?
Zu früh zu refaktorisieren, kann bedeuten, das Design aufgrund von Spekulationen in die falsche Richtung zu drängen. Zu spät zu refaktorisieren, kann dagegen darin resultieren, erst nach vielen, unter Umständen auch größeren Refactorings die Richtungsgabe zu erkennen, in die der Code gelenkt werden will. Im Unglücksfall kann jedes Refactoring jedoch immer durch ein inverses rückgängig gemacht werden. Starten Sie mit dem Refactoring deshalb besser früher denn später.
- Refaktorisieren Sie, wenn möglich, sofort, wenn Sie die Einsicht in ein insgesamt einfacheres Design gefunden haben, nicht jedoch inmitten der Programmierung.
Wann immer Ihnen während der Implementierung eine Möglichkeit zur Vereinfachung des Designs auffällt, notieren Sie die Idee, bevor sie verloren geht. Kümmern Sie sich später darum, wenn der JUnit-Balken wieder auf grün ist. Vermischen Sie jedoch nicht Programmierung und Refaktorisierung, sonst besteht die Gefahr, dass Sie sich fürchterlich vertüdern.
Testen als Design- und Analysetechnik
Ward Cunningham behauptet, dass testgetriebene Programmierung keine Testtechnik ist. Was meint er damit? Er drückt damit aus, dass dieser Programmieransatz vielmehr Design- und Analysetechnik als Testtechnik ausmacht. Sind die Tests demnach nur ein willkommenes Geschenk des Prozesses?
Tatsächlich zwingt uns die Arbeitsweise viel stärker in die Analyse des geforderten Programmverhaltens und den Entwurf einer geeigneten Klassenschnittstelle, als vorzeitig schon über Implementierungsdetails zu entscheiden.
- Was ist unsere Aufgabe?
- Wie passen die Anforderungen ins Gesamtbild?
- Welche Systemteile werden wir berühren und anpassen müssen?
- Wo liegen die interessanten Grenzfälle?
- Welche Klassen und Methoden benötigen wir?
- Wie soll die Klasse benutzt werden?
- Wie testen wir das?
Der Prozess setzt voraus, dass wir schon genau wissen, was wir programmieren müssen. Die Idee dahinter ist, dass wir unsere Anforderungen systematisch und sehr zeitnah zur Programmierung analysieren und auf sehr niedrigem Abstraktionsniveau in einem ausführbaren Test formulieren müssen, bevor wir losprogrammieren können. Die Unit Tests stellen eine gute Möglichkeit dafür dar, konkret und unmissverständlich zu definieren, was unser Programm leisten soll. Haben wir dieses Verständnis erst einmal in einem Test festgehalten, ist die Chance auch groß, dass unser Code das Richtige tun wird. Manchmal stoßen wir bei dem Versuch, die Anforderungen klar und deutlich in einem Test festzunageln, auf Widersprüche, Missverständnisse und Fragen, die uns helfen, das richtige Programm zu entwickeln. Deshalb ist es fast unumgänglich, dass Sie Ihren Kunden intensiv in die Entwicklung einbinden. Sind die Ansprechpartner auf Kundenseite ständig direkt verfügbar, können Ihre Fragen während der Entwicklung sofort geklärt werden.
Mit den Tests gewinnen wir eine ausführbare Spezifikation, mit der wir fortan automatisch unseren Programmcode exerzieren können. Wir gewinnen außerdem eine umfassende Dokumentation darüber, was unser Code wie leistet. Es sind die Tests, die wir uns als Erstes anschauen, wenn wir verstehen wollen, wie sich der Code verhält. Die Testsuite kann uns anhand konkreter Anwendungsbeispiele demonstrieren, wie eine Klasse oder Methode verwendet werden soll. Da Spezifikation und Dokumentation direkt in der Programmiersprache selbst formuliert sind, sorgt der Compiler in statisch typisierten Sprachen dafür, dass wir nicht vergessen, unsere Tests anzupassen, wenn sich getestete Klassenschnittstellen geändert haben. Spezifikation und Dokumentation haben so eher die Chance, aktuell zur Codebasis zu bleiben.
Testgetrieben zu programmieren impliziert, dass die Tests schon Klassen verwenden, bevor wir überhaupt den Code für die Klasse geschrieben haben. Wir versetzen uns mit den Tests regulär in die Rolle eines frühen Verwenders unserer Klasse. Es ist exakt diese frische Perspektive, die uns lehren kann, wie der Code eigentlich geschrieben und entworfen werden soll. Schließlich können wir oft erst durch die Benutzung einer Schnittstelle herausfinden, wie wir die Klassenverwendung gestalten hätten sollen. Nicht selten schlägt ein testgetriebenes Design auf diesem Weg eine vollkommen andere Richtung ein, als im voraus erahnt werden könnte.
Wenn wir Unit Tests schreiben, müssen wir die Implementation an ihrer Schnittstelle isolieren. über Implementierungsdetails entscheiden wir zu diesem Zeitpunkt noch überhaupt nicht. Weil Klassen öfters verwendet als geschrieben werden, sollten Klassenschnittstellen ohnehin immer aus der Perspektive bequemer Benutzbarkeit entworfen werden, nicht zur möglichst bequemen Programmierung. Außerdem ist die Wahrscheinlichkeit hoch, dass sich die Implementierung der Klasse noch einmal ändert. Oder zweimal...
Unit Testing erfordert, dass wir genau wissen, was eigentlich die "Unit" ist, die wir testen wollen. Dazu müssen wir das Programm in kleine, unabhängig voneinander testbare Einheiten isolieren. Diese Entkopplung wiederum erfordert ebenso Schnittstellen in unserem Code. Gerade dadurch, dass wir den Code schon in unseren Tests verwenden, kann uns der Unit Test oft zeigen, wie der Code strukturiert werden will, damit er möglichst einfach testbar ist. Das Resultat davon ist häufig ein Design, das in Richtung vieler kleiner Klassen strebt und untereinander durch schmale Schnittstellen entkoppelt ist, so dass über den Unit Test hinweg andere Systemteile zu Testzwecken substituiert werden können. Allein schon indem Sie darauf Wert legen, welche Konstruktoren Sie schreiben, können Sie oft die Testumgebung für ein Objekt minimieren.
Im Gegensatz dazu ist es häufig schlecht bis gar nicht möglich, eine bestehende Codebasis nachträglich noch auf effektive Weise zu testen, wenn diese nicht ursprünglich mit dem Anspruch auf Testbarkeit entworfen wurde. Oft sind die gegenseitigen Abhängigkeiten in solch einem Code, der nicht testgetrieben entwickelt wurde, so groß, dass es praktisch fast unmöglich ist, diese Module in Isolation zu testen. In einer solchen Umgebung degenerieren die Unit Tests meist zu Mikrointegrationstests, da häufig ein größerer Anwendungskontext für die Testumgebung aufgebaut und verwaltet werden muss. Die Anzahl der zu schreibenden Testfälle explodiert häufig aufgrund der kombinatorischen Vielfaltigkeit, in der die Module zusammenhängen. Hier hilft es eben nur, die einzelnen Module und Klassen besser zu entkoppeln. Dann jedoch laufen wir in ein bekanntes Henne-Ei-Dilemma. Ohne eine umfassende Testsuite sollten wir nicht refaktorisieren. Ohne Refactoring können wir keine einfachen Tests schreiben.
Viele unserer heutigen Testprobleme entstehen wirklich nur dadurch, dass der Code, mit dem wir arbeiten müssen und zu dem unser Code Schnittstellen bildet, selbst nicht testgetrieben entwickelt wurde.
Danksagungen
In der Reihenfolge, in der wertvolle Anmerkungen bei mir eintrudelten: Tammo Freese (der kritischste Leser, den ich vermutlich kenne, hat mich überzeugt, den Test-First-Zyklus auf drei Schritte runterzubrechen und mir als Sparringspartner für viele gemeinsame Programmierepisoden gedient), Martin Müller-Rohde, Hans Wegener, Dierk König ("offene Tests" als Test-Smell war der entscheidende Hinweis, der mich dazu bewegte, das Beispiel 3b komplett umzuschreiben), Stefan Roock, Antonín Andert, Bastiaan Harmsen (mit einem unbestechlichen "Handle" für die menschlichen Faktoren), Johannes Link, Olaf Kock, Stefan Schmiedl (kam mit der grossen Refaktorplanierraupe und schrieb meinen Kram gleich so um, dass er es besser verstehen konnte, was ich, wie sein Kumpel Armin Roehrl, schamlos ausgenutzt hab), Martin Lippert, Michael Schürig, Manfred Lange, Etienne Studer und Ilja Preuß.
Weiterführende Literatur
- Johannes Link: Softwaretests mit JUnit
- Kent Beck: Test-Driven Development
- Frank Westphal: Testgetriebene Entwicklung mit JUnit und FIT
Schlagwörter: junit testgetriebeneentwicklung unittests
blättern: Toyota als Leitbild für die Softwareentwicklung
zurück: Extreme Programming