Schlagwort
testgetriebeneentwicklung
23.6.2012
Wie die Zeit vergeht…
Testgetriebene Entwicklung mit JUnit und FIT ist nunmehr sieben Jahre alt.
Ich habe damals versucht, die TDD-Techniken so zeitlos wie möglich zu beschreiben.
Doch natürlich haben sich seitdem eine Reihe neuer Werkzeuge und Ideen hinzugesellt.
Im Ruby-Umfeld beispielsweise sind BDD und rSpec mehr oder weniger die Norm.
Open Source ohne Unit Tests ist selten, wenn nicht undenkbar geworden.
Wie zu erwarten war, sind jedoch nicht alle unsere Ideen kleben geblieben.
Aber ein paar schon.
Vielleicht die wichtigsten.
Auch sieben Jahre später bin ich mit meinem Text immer noch überraschend einverstanden.
Einzig wünschte ich mir, ich hätte den Kontext der Techniken im Buch damals besser herausgestellt.
Test-Driven Development sind Best Practices.
Best Practices existieren in einem wohl definierten Kontext.
Der Untertitel "Wie Software änderbar bleibt" und insbesondere das letzte Buchkapitel "Änderbare Software" geben einen Hinweis,
aber der meiner Meinung nach wichtigste Trade-off bleibt, langlebige Software zu entwickeln.
In Open Source ist diese Voraussetzung stets gegeben.
Im Startup dagegen kann der Kontext ein gänzlich anderer sein.
Bei Rivva sind meine Tests zum Beispiel viel mehr risikogetrieben.
Und natürlich hätte das Buch für eine andere Programmiersprache auch eine andere Gestalt angenommen.
In Ruby entwickle ich fast ausschließlich REPL-getrieben.
Das Buch ist mittlerweile vergriffen und nicht mehr im Druck.
Deshalb freue ich mich, dass der dpunkt.verlag mir jetzt sein Einverständnis gegeben hat, das Buch als kostenloses eBook veröffentlichen zu können.
Mein besonderer Dank dafür gilt meiner Lektorin Christa Preisendanz.
Download: Testgetriebene Entwicklung mit JUnit & FIT (PDF)
Happy 100th Birthday, Mr. Alan Turing (1912-1954).
17.11.2005
Frank Westphal
Testgetriebene Entwicklung
mit JUnit & FIT:
Wie Software änderbar bleibt
dpunkt.verlag
346 Seiten
November 2005
ISBN 3-89864-220-8
Bei Amazon ansehen
Beschreibung
Testgetriebene Entwicklung geht von einem fehlschlagenden Test aus.
Software wird in kleinen sicheren Schritten entwickelt, die abwechselnd darauf abzielen, eine neue Anforderung zu implementieren (den fehlschlagenden Test also zu erfüllen) und das Design zu verbessern (und dabei weiterhin alle Tests zu bestehen).
-
Wenn frühes und häufiges Testen wichtig ist, warum schreiben wir nicht für jedes neue Feature zuerst einen automatisierten Test?
So können wir während der Entwicklung jederzeit unsere Tests ausführen und lernen, ob unser Code wie gewünscht funktioniert.
-
Wenn Design wichtig ist, warum investieren wir dann nicht Tag für Tag darin?
So können wir dafür sorgen, dass es möglichst einfach bleibt und nicht mit der Zeit zunehmend degeneriert.
-
Wenn Anforderungsdefinition wichtig ist, warum ermöglichen wir unseren Kunden dann nicht, in einem ausführbaren Anforderungsdokument Testfälle für konkrete Anwendungsbeispiele zu spezifizieren?
So können wir dokumentieren, welche Funktionalität tatsächlich gefordert ist, und anschließend verifizieren, ob die Erwartungen des Kunden erfüllt werden.
Das Buch führt mit praktischen Beispielen in die Testgetriebene Entwicklung mit den Open-Source-Werkzeugen JUnit und FIT ein.
Frank Westphal has turned his extensive experience in using and teaching developer testing with JUnit and FIT into a thorough and usable guide for programmers and testers, including the first published guide to JUnit 4.
What I was most struck by in reading this book was the combination of philosophical background and detailed, practical advice.
– Kent Beck, Three Rivers Institute
Amazon-Rezensionen
ganz aus dem häuschen
– Thomas Steinbach
Warum sind wir darauf nicht schon eher gekommen?
– H. Mausolf
Grandios !!
– Bernd Will
Lockere Lektüre für flotte Erfolge
– Gernot Starke
Motivation für die Praxis
– Frankmartin Wiethüchter
Seit langem kein Buch mehr so schnell durchgearbeitet!
– Ulrich Storck
Rundum gelungen
– Jens Uwe Pipka
Übers Entwickeln und Entwerfen - auf testgetriebene Art
– Christoph Steindl
Ideal für professionelle Softwareentwickler
– Dierk König
Testen als Mittel zum Zweck
– Stefan Roock
Buchbesprechungen
Inhaltsverzeichnis
- Was ist Testgetriebene Entwicklung?
- Warum Testgetriebene Entwicklung?
- Über dieses Buch
- Merci beaucoup
Kapitel 2: Testgetriebene Entwicklung, über die Schulter geschaut
- Eine Programmierepisode
- Testgetriebenes Programmieren
- Möglichst einfaches Design
- Ein wenig Testen, ein wenig Programmieren ...
- Evolutionäres Design
- Natürlicher Abschluss einer Programmierepisode
- Refactoring
- Abschließende Reflexion
- Häufige Integration
- Rückblende
- "Aus dem Bauch" von Sabine Embacher
- Download und Installation
- Ein erstes Beispiel
- Anatomie eines Testfalls
- Test-First
- JUnit in Eclipse
- Das JUnit-Framework von innen
- »Assert«
- »AssertionFailedError«
- »TestCase«
- Lebenszyklus eines Testfalls
- »TestSuite«
- »TestRunner«
- Zwei Methoden, die das Testen vereinfachen
- Testen von Exceptions
- Unerwartete Exceptions
- "Woran erkennt man, dass etwas testgetrieben entwickelt wurde?" von Johannes Link
- JUnit 4
Kapitel 4: Testgetriebene Programmierung
- Die erste Direktive
- Der Testgetriebene Entwicklungszyklus
- Die Programmierzüge
- Beginn einer Testepisode
- Ein einfacher Testplan
- Erst ein neuer Test ...
- ... dann den Test fehlschlagen sehen
- ... schließlich den Test erfüllen
- Zusammenspiel von Test- und Programmcode
- Ausnahmebehandlung
- Ein unerwarteter Erfolg
- Ein unerwarteter Fehlschlag
- "Rückschritt für den Fortschritt" von Tammo Freese
- Vorprogrammierte Schwierigkeiten
- "Zwei offene Tests sind einer zu viel" von Tammo Freese
- Kleine Schritte gehen
- "Halten Sie Ihre Füße trocken" von Michael Feathers
Kapitel 5: Refactoring
- Die zweite Direktive
- Die Refactoringzüge
- Von übel riechendem Code ...
- ... über den Refactoringkatalog
- ... zur Einfachen Form
- Überlegungen zur Refactoringroute
- Substitution einer Implementierung
- Evolution einer Schnittstelle
- "Coding Standards bewusst verletzen" von Tammo Freese
- Teilen von Klassen
- Verschieben von Tests
- Abstraktion statt Duplikation
- Die letzte Durchsicht
- Ist Design tot?
- "Durch zerbrochene Fenster dringen Gerüche ein" von Dave Thomas & Andy Hunt
- Richtungswechsel ...
- ... und der wegweisende Test
- Fake it ('til you make it)
- Vom Bekannten zum Unbekannten
- Retrospektive
- Tour de Design évolutionnaire
- Durchbrüche erleben
Kapitel 6: Häufige Integration
- Die dritte Direktive
- Die Integrationszüge
- Änderungen mehrmals täglich zusammenführen ...
- "Taxi implements Throwable" von Olaf Kock
- ... das System von Grund auf neu bauen
- ... und ausliefern
- Versionsverwaltung (mit CVS oder Subversion)
- Build-Skript mit Ant
- Build-Prozess-Tuning
- Integrationsserver mit CruiseControl
- Aufbau einer Staging-Umgebung
- Teamübergreifende Integration
- Gesund bleiben
- "Eine Geschichte über die Häufige Integration" von Lasse Koskela
Kapitel 7: Testfälle schreiben, von A bis Z
- Aufbau von Testfällen
- Benennung von Testfällen
- Buchführung auf dem Notizblock
- Der erste Testfall
- Der nächste Testfall
- Erinnerungstests
- Ergebnisse im Test festschreiben, nicht berechnen
- Erst die Zusicherung schreiben
- Features testen, nicht Methoden
- Finden von Testfällen
- Generierung von Testdaten
- Implementierungsunabhängige Tests
- Kostspielige Setups
- Lange Assert-Ketten oder mehrere Testfälle?
- Lerntests
- Minimale Fixture!
- "Einfache Tests - einfaches Design" von Dierk König
- Negativtests
- Organisation von Testfällen
- Orthogonale Testfälle
- Parameterisierbare Testfälle
- Qualität der Testsuite
- Refactoring von Testcode
- Reihenfolgeunabhängigkeit der Tests
- Selbsterklärende Testfälle
- String-Parameter von Zusicherungen
- Szenarientests
- Testexemplare
- Testsprachen
- Umgang mit Defekten
- "Eine Frage der (Test-)Kultur" von Christian Junghans & Olaf Kock
- Umgang mit externem Code
- Was wird getestet? Was nicht?
- Zufälle und Zeitabhängigkeiten
- "Der zeitlose Weg des Testens" von Lasse Koskela
Kapitel 8: Isoliertes Testen, durch Stubs und Mocks
- Verflixte Abhängigkeiten!
- Was ist die Unit im Unit Test?
- Mikrointegrationstest versus strikter Unit Test
- Vertrauenswürdige Komponenten
- "Vertrauen - und Tests" von Bastiaan Harmsen
- Austauschbarkeit von Objekten
- Stub-Objekte
- Größere Unabhängigkeit
- Testen durch Indirektion
- Stub-Variationen
- Testen von Mittelsmännern
- Self-Shunt
- Testen von innen
- Möglichst frühzeitiger Fehlschlag
- Erwartungen entwickeln
- Gebrauchsfertige Erwartungsklassen
- Testen von Protokollen
- Mock-Objekte
- Wann verwende ich welches Testmuster?
- "Wo Mock-Objekte herkommen" von Tim Mackinnon & Ivan Moore & Steve Freeman
- Crashtest-Dummies
- Dynamische Mocks mit EasyMock
- Stubs via Record/Replay
- Überspezifizierte Tests
- Überstrapazierte Mocks
- Systemgrenzen im Test
- "Mock-Objekte machen glücklich" von Moritz Petersen
Kapitel 9: Entwicklung mit Mock-Objekten
- Tell, don't ask
- Von außen nach innen
- Wer verifiziert wen?
- Schnittstellen finden auf natürlichem Weg
- Komponierte Methoden
- Vom Mock lernen für die Implementierung
- Viele schmale Schnittstellen
- Kleine fokussierte Klassen
- Tell und Ask unterscheiden
- nereimmargorP sträwkcüR
- Schüchterner Code und das Gesetz von Demeter
- Fassaden und Mediatoren als Abstraktionsebene
- Rekonstruktion
- "Meister, ..." von Dierk König
Kapitel 10: Akzeptanztests mit FIT (Framework for Integrated Test)
- Von einer ausführbaren Spezifikation ...
- Download Now
- Schritt für Schritt für Schritt
- ... zum ausführbaren Anforderungsdokument
- Die drei Basis-Fixtures
- »ActionFixture«
- Richtung peilen, Fortschritt erzielen
- Fixture wachsen lassen, dann Struktur extrahieren
- Nichts als Fassade
- Die Fixture als zusätzlicher Klient
- Aktion: Neue Aktion
- »ColumnFixture«
- Fixture-Interkommunikation
- Negativbeispiele
- Transformation: Action -> Column
- »RowFixture«
- Einfacher Schlüssel
- Mehrfacher Schlüssel
- Abfragemethoden einspannen
- »Summary«
- "Warum drei Arten von Fixtures?" von Ward Cunningham
- »ExampleTests«
- »AllFiles«
- Setup- und Teardown-Fixtures
- Das FIT-Framework von innen
- »FileRunner«
- »Parse«
- »Fixture«
- Annotationsmöglichkeiten in Dokumenten
- »TypeAdapter«
- »ScientificDouble«
- Domänenspezifische Grammatiken
- »ArrayAdapter«
- »PrimitiveFixture«
- Domänenspezifische Fixtures
- Anschluss finden
- Stichproben reiner Geschäftslogik
- Integrationstests gegen Fassaden und Services
- "Survival of the FIT Test" von Steffen Künzel & Tammo Freese
- Oberflächentests
- Kundenfreundliche Namen
- FitNesse
- FitLibrary
- Akzeptanztesten aus Projektsicht
Kapitel 11: Änderbare Software
- "Harte Prozesse führen zu harten Produkten" von Dierk König
- Konstantes Entwicklungstempo
- "Alten Code testgetrieben weiterentwickeln" von Juan Altmayer Pizzorno & Robert Wenner
- "Die Latte liegt jetzt höher" von Michael Feathers
- Kurze Zykluszeiten
- Neue Geschäftsmodelle
- "Bug-Trap-Linien" von Michael Hill
Behandelte Werkzeuge
Quellcode
Die Beispiele aus den Kapiteln 2,
3,
4,
5,
6,
7,
8 können Sie sich auch hier herunterladen.
6.1.2002
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 Typ
int
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
29.4.2005
von Frank Westphal und Johannes Link
erschienen im OBJEKTspektrum 3/2005
"From Stoplight to Spotlight": Vormals zu einer Phase im Entwicklungsprozess erklärt und oft genug als Schlusslicht des Projekts betrachtet, spielt sich das Testen heute als Aktivität ab und geht bei testgetriebener Entwicklung sogar allem anderen mit rotgrünem Scheinwerferlicht voraus.
In der Softwarebranche breitet sich eine Kluft aus. Auf der einen Seite sehen wir Entwicklerteams, die ihre Software schnell und zuverlässig ändern können. Sie schreiben automatisierte Testfälle für alles, was womöglich schief gehen könnte, und halten ihren Code durch ständiges Refactoring sauber und flexibel. Auf der anderen Seite sehen wir Teams, deren Entwicklungsgeschwindigkeit mit fortschreitender Projektdauer immer weiter sinkt. Sie testen ihr System überwiegend manuell und ihr Code von gestern steht den Anforderungen von heute nicht selten im Weg. Während erstere ihren Kunden dabei helfen, neue Geschäftsideen möglichst schnell in hoher Qualität umzusetzen, stehen letztere unter einem immer höheren Druck und der Drohung, durch "billige" Teams in Offshoring-Ländern ersetzt zu werden.
Entwickler, die das Testen lieben gelernt haben?
Eingeläutet wurde der Wandel beim Testen vor nunmehr sieben Jahren durch Extreme Programming (XP) und JUnit. Testen war auf einmal cool. JUnits grüner Balken machte süchtig, sorgte er doch für ein lang vermisstes Gefühl: das Vertrauen, dass die produzierte Software tatsächlich wie gewünscht funktionierte. Doch schnellen Testerfolgen folgte erste Ernüchterung. Bestimmte Ecken ließen sich nicht so einfach automatisiert testen, so z. B. grafische Benutzungsschnittstellen, Enterprise Java Beans (EJBs), Datenbanken, Threads und Legacy Code. Hier zeigte sich dann auch, wer wirklich testgetrieben entwickelte und wer nicht. Denn Testprobleme weisen zumeist auf eine Design- oder Prozessschwäche hin.
Heute gelten diese Herausforderungen als gemeistert. Was sich programmieren lässt, lässt sich auch testen. Der Weg zu dieser Erkenntnis führt über einige Hürden; erfolgreiches Testen verlangt eben mehr als die bloße Beherrschung des Test-Frameworks. Insbesondere zeigt sich meist recht schnell, dass zeitlich nachgeordnetes Testen wesentlich ineffektiver ist als das Schreiben der Tests vor der Implementierung: Zum einen lässt sich Testbarkeit im Nachhinein nur mit Mühe erzwingen, zum anderen können vorab geschriebene Tests das Design lenken. Gerade dieser Vorteil testgetriebener Entwicklung ist jedoch – auch in Projekten mit hoher Testkultur – in vielen Köpfen noch nicht gefestigt.
Wohin geht die testgetriebene Entwicklung?
Nachdem sich die meisten Projekte bisher nur auf Unit-Tests gestützt haben, entdecken viele den Nutzen von System- und Akzeptanztests. Wenn diese Tests aus der Feder des Kunden bzw. der Fachabteilung kommen, kann so eine wichtige Kommunikationslücke zwischen den Domänenexperten, die die Anforderungen kennen, und den Entwicklern, die diese realisieren, geschlossen werden. Hat sich JUnit als De-facto-Standard für Entwicklertests etabliert, so schickt sich derzeit Ward Cunninghams Framework for Integrated Test (FIT) an, das kundenfreundlichste Akzeptanztest-Framework zu werden. Der angezeigte Trend geht hier eindeutig in Richtung des ausführbaren Anforderungsdokuments. Man verbindet effektiv die Anforderung mit ihrem Abnahmetest. Geht die Formulierung dieser Tests der eigentlichen Entwicklung voraus, so besteht durchaus die Möglichkeit, das traditionelle Anforderungsmanagement auf den Kopf zu stellen. Dabei verändert sich die Rolle des Testers. Anstatt den Entwicklern wegen tausender Kleinigkeiten auf die Finger zu klopfen, kann er nun mit dem Kunden dessen Anforderungen in testbare und eindeutige Beispiele überführen. Auch auf das Projektmanagement wirken sich die feature-basierten Akzeptanztests positiv aus, stellt diese Testart doch ein untrügliches Mittel dar, um den tatsächlichen Projektfortschritt messen und damit "managen" zu können.
Wie gut ein Prozess wirklich "sitzt", zeigt sich, wenn der Stresspegel zunimmt. Noch sehen wir häufig, dass Tests weggelassen werden, wenn die Zeit knapp wird. Nur wer seine Reflexe soweit verändert hat, dass er trotz Zeitdruck weiterhin an seinen guten Gewohnheiten festhält, hat sich überzeugt: Testen spart Zeit und Geld. Zum einen führen intensive Unit-Tests zu deutlich reduzierten Fehlerraten. Von einigen XP-Teams wird mittlerweile berichtet, dass ihre Software praktisch fehlerfrei sei. So hätten die Anwender, nachdem sich die Software länger als ein Jahr in Produktion befände, noch keinerlei Fehler entdeckt. Zum anderen ermöglichen erst automatisierte Tests eine schnelle, evolutionäre Anpassung des Designs an neue Anforderungen. Die Testautomatisierung erweist sich damit als eine Kosten sparende und Effizienz steigernde Maßnahme, sobald die initiale Lernkurve überwunden ist. Wie nicht zuletzt durch Toyota im Automobilbau demonstriert: Produktivität und Qualität sind keine widersprüchlichen Ziele.
Die Grundannahme, dass Software nun mal "buggy" ist, wird sich sicher noch eine Zeit lang halten, jedoch mehr und mehr entkräftet werden. Wer effektiv testet, wird seine Software jederzeit ausliefern können, ohne dass die Qualität einknickt. Die Zykluszeiten von einer neuen Anforderung zum produktiven Code werden zukünftig drastisch kürzer werden und dadurch neue Geschäftsmodelle ermöglichen. Entwickelt wird in Zukunft nur noch geschäftswertorientiert: Die Anforderung mit der größten Wertschöpfung kommt zuerst. Inkrementelle Entwicklung wird zum Synonym für inkrementelle Finanzierung. Möglich wird dieser Wandel unter anderem durch automatisierte Tests.
9.2.2005
Zum Auftakt des Tonabnehmers, meiner neuen Audiokolumne zur agilen Softwareentwicklung, sprach ich mit Johannes Link über sein neuestes Buch Softwaretests mit JUnit.
Das Telefoninterview können Sie als MP3 herunterladen oder direkt als Podcast empfangen.
(Was ist Podcasting?)
Aus dem Interview erfahren Sie alles Wissenswerte rundum die Testgetriebene Softwareentwicklung.
Auf meine Frage, welche Herausforderungen auf uns zukommen, antwortete Johannes:
"Das Entscheidende, was wir in den letzten Jahren erlebt haben, ist ein großer Kostendruck auf die Softwareindustrie. [...]
Wir müssen herausstellen, dass Testgetriebene Entwicklung nur zu einem kleinen Teil mit Testen und Programmierung zu tun hat, sondern große Auswirkungen auf den Prozess und das Management hat.
Denn im Prozess und im Management liegen eben die größten Optimierungspotenziale der Softwareentwicklung.
Und dann können wir, wenn wir das rüberbringen, auch vielen, die völlig auf Outsourcing und Offshoring schwören, den Wind aus den Segeln nehmen."
"Wenn ich als Kunde mir einen Dienstleister aussuche, würde ich einen auswählen, der von sich aus automatisierte Testfälle erstellt und die Anforderungen mit mir zusammen in automatisierten Akzeptanztests festhalten will."
26.8.2001
Extreme Programming (XP) ist ein agiler Softwareentwicklungsprozess
für kleine Teams.
Der Prozess ermöglicht, langlebige Software zu erstellen und
während der Entwicklung auf vage und sich rasch ändernde
Anforderungen zu reagieren.
XP-Projekte schaffen ab Tag eins Geschäftswert für den
Kunden und lassen sich fortlaufend und außergewöhnlich
stark durch den Kunden steuern.
Im Kern beruht XP auf den Werten Kommunikation, Einfachheit,
Feedback, Mut, Lernen, Qualität und Respekt.
XP erfordert Disziplin und Prinzipientreue.
Die beschriebenen XP-Techniken sind jedoch wirklich nur die Startlinie.
Das Ziel ist es, den Entwicklungsprozess an die örtlichen
Begebenheiten anzupassen und fortlaufend zu verbessern.
Techniken für ein XP-Team
Ein XP-Team besteht aus zwei bis etwa zwölf Programmierern,
einem Kunden oder mehreren direkten Anprechpartnern auf Kundenseite
und dem Management.
Ferner erfordert der Prozess die Rollen des Trainers und Verfolgers.
Der Trainer bespricht mit dem Team die diszipliniert einzuhaltenden
Techniken und erinnert das Team, wenn es die selbstgewählten Regeln
verletzt.
Der Verfolger nimmt regelmässig den aktuellen Status und die
geleisteten Programmieraufwände auf, um so zuverlässige
Geschichtsdaten über das Projekt zu erhalten.
Zu beachten ist, daß der Kunde in der Regel weder den Geldgeber
noch den wirklichen Endanwender darstellt.
Offene Arbeitsumgebung
Das Team arbeitet zusammen in einem größeren Raum oder eng
aneinander grenzenden Räumen.
Typischerweise ist der "Kriegsraum" mit Wandtafeln und
unzähligen Flipcharts ausgestattet.
Die Arbeitstische stehen meist dicht beieinander im Kreis mit den
Monitoren nach außen gerichtet und sind so gestaltet, daß
zwei Programmierer zusammen bequem an einem Computer arbeiten können.
Kurze Iterationen
Die Entwicklung erfolgt in Perioden von ein bis drei Wochen.
Am Ende jeder Iteration steht ein funktionsfähiges, getestetes
System mit neuer, für den Kunden wertvoller Funktionalität.
Gemeinsame Sprache
Das Team entwickelt in seiner Arbeit ein gemeinsames Vokabular,
um über die Arbeitsweisen und das zu erstellende System diskutieren
zu können.
Die Kommunikation im Team erfolgt stets offen und ehrlich.
Retrospektiven
Jede Iteration endet damit, in einem Rückblick über die eigenen
Arbeitsweisen kritisch zu reflektieren und im Team zu diskutieren,
was gut lief und was in Zukunft anders angegangen werden muß.
Typischerweise werden aus den Dingen, die während dieser
Team-Reviews zur Oberfläche kommen, Regeln generiert,
vom Team akzeptiert, auf Poster geschrieben und im Projektraum zur
Erinnerung an die Wand geheftet.
Ein- oder zweimal jährlich macht das Team für zwei Tage einen
gemeinsamen Ausflug, um in einem Offsite-Meeting formal vor- und
zurückzublicken.
Tägliches Standup-Meeting
Der Tag beginnt mit einem Meeting, das im Stehen gehalten wird,
damit es kurz und lebendig bleibt.
Jedes Teammitglied berichtet reihum, an welcher Aufgabe er gestern
gearbeitet hat und was er heute machen wird.
Probleme werden genannt aber nicht gelöst.
Die meisten Teams treffen sich vor der Wandtafel ihrer Iterationsplanung.
Techniken für die Kunden
Benutzergeschichten
Die Kunden halten ihre Anforderungen in Form einfacher Geschichten auf
gewöhnlichen Karteikarten fest.
Jeder geschriebenen Story-Karte kommt das Versprechen nach,
den genauen Funktionsumfang zum rechten Zeitpunkt im Dialog mit den
Programmierern zu verfeinern und zu verhandeln.
Iterationsplanung
Jede Iteration beginnt mit einem Planungsmeeting, in dem das Kundenteam
seine Geschichten erzählt und mit dem Programmierteam diskutiert.
Die Programmierer schätzen den Aufwand grob ab, den sie zur
Entwicklung jeder einzelnen Geschichte benötigen werden.
Die Kunden wählen in Abhängigkeit der Aufwandsschätzungen
den Kartenumfang für die Iteration aus, der ihren
Geschäftsgegenwert maximieren würde.
Die Programmierer zerlegen die geplanten Geschichten am Flipchart in
technische Aufgaben, übernehmen Verantwortung für einzelne
Aufgaben und schätzen deren Aufwände vergleichend zu
früher erledigten Aufgaben.
Aufgrund der genaueren Schätzung der kleinen Aufgaben verpflichten
sich die Programmierer auf genau soviele Geschichten, wie sie in der
vorhergehenden Iteration entwickeln konnten.
Diese Planungsspiele schaffen eine sichere Umgebung, in welcher
geschäftliche und technische Verantwortung zuverlässig
voneinander getrennt werden.
Anforderungsdefinition im Dialog
Das für die anstehenden Programmieraufgaben nötige
Verständnis der Anforderungen wird fortlaufend in der
Konversation mit den Kunden geprüft und vertieft.
In kurzen Designsessions wird unter Umständen auf eine der
Wandtafeln ein wenig UML gemalt oder es werden Szenarien
mit Hilfe von CRC-Karten durchgespielt.
Während der gesamten Entwicklung dienen die Kunden als
direkte Ansprechpartner zur Bewältigung fachlicher Fragen.
Die verbleibende Zeit verbringen die Kunden mit dem Schreiben
und Ergründen neuer Benutzergeschichten und Akzeptanztests.
Akzeptanztests
Die Kunden spezifizieren während der Iteration funktionale
Abnahmekriterien.
Typischerweise entwickeln die Programmierer ein kleines Werkzeug,
um diese Tests zu kodieren und automatisch auszuführen.
Spätestens zum Ende der Iteration müssen die Tests
erfüllt sein, um die gewünschte Funktion des Systems zu sichern.
Kurze Releasezyklen
Nach ein bis drei Monaten wird das System an die wirklichen Endanwender
ausgeliefert, damit das Kundenteam wichtiges Feedback für die
Weiterentwicklung erhält.
Techniken für die Entwicklung
Programmieren in Paaren
Die Programmierer arbeiten stets zu zweit am Code und diskutieren
während der Entwicklung intensiv über Entwurfsalternativen.
Sie wechseln sich minütlich an der Tastatur ab und rotieren
stündlich ihre Programmierpartner.
Das Ergebnis ist eine höhere Codequalität, grössere
Produktivität und bessere Wissensverbreitung.
Gemeinsame Verantwortlichkeit
Der gesamte Code gehört dem Team.
Jedes Paar soll jede Möglichkeit zur Codeverbesserung jederzeit
wahrnehmen.
Das ist kein Recht sondern eine Pflicht.
Erst Testen
Gewöhnlich wird jede Zeile Code durch einen Testfall motiviert,
der zunächst fehlschlägt.
Die Unit Tests werden gesammelt, gepflegt und nach jedem Kompilieren
ausgeführt.
Design für heute
Jeder Testfall wird auf die einfachst denkbare Weise erfüllt.
Es wird keine unnötig komplexe Funktionalität programmiert,
die momentan nicht gefordert ist.
Refactoring
Das Design des Systems wird fortlaufend in kleinen, funktionserhaltenden
Schritten verbessert.
Finden zwei Programmierer Codeteile, die schwer verständlich sind oder
unnötig kompliziert erscheinen, verbessern und vereinfachen sie den Code.
Sie tun dies in disziplinierter Weise und führen nach jedem Schritt
die Unit Tests aus, um keine bestehende Funktion zu zerstören.
Fortlaufende Integration
Das System wird mehrmals täglich durch einen automatisierten
Build-Prozess neu gebaut.
Der entwickelte Code wird in kleinen Inkrementen und spätestens
am Ende des Tages in die Versionsverwaltung eingecheckt und ins
bestehende System integriert.
Die Unit Tests müssen zur erfolgreichen Integration zu 100% laufen.
Techniken für das Management
Akzeptierte Verantwortung
Das Management schreibt einem XP-Team niemals vor, was es zu tun hat.
Stattdessen zeigt der Manager lediglich Probleme auf und läßt
die Kunden und Programmierer selbst entscheiden, was zu tun gilt.
Dies ist eine große, neue Herausforderung für das Management.
Information durch Metriken
Eine der Hauptaufgaben des Managements ist es, dem Team den Spiegel
vorzuhalten und zu zeigen, wo es steht.
Dazu gehört unter anderem das Erstellen einfacher Metriken,
die den Fortschritt des Teams oder zu lösende Probleme aufzeigen.
Es gehört auch dazu, den Teammitgliedern regelmässig in die
Augen zu schauen und herauszufinden, wo Hilfe von Nöten ist.
Ausdauerndes Tempo
Softwareprojekte gleichen mehr einem Marathon als einem Sprint.
Viele Teams werden immer langsamer bei dem Versuch, schneller zu
entwickeln.
Überstunden sind keine Lösung für zuviel Arbeit.
Wenn Refactorings und Akzeptanztests aufgeschoben werden, muß
der Manager dem Team stärker den Rücken freihalten.
Wenn Teammitglieder müde und zerschlagen sind, muß
der Manager sie nach Hause schicken.
Links
Bücher
Mailinglisten
User's Groups
24.6.2001
Automatisierte Unit Tests in Java
JUnit ist ein kleines, mächtiges Java-Framework zum Schreiben und
Ausführen automatischer Unit Tests.
Da die Tests direkt in Java programmiert werden, ist das Testen mit JUnit so
einfach wie das Kompilieren.
Die Testfälle sind selbstüberprüfend und damit wiederholbar.
Unit Testing ist der Test von Programmeinheiten in Isolation von anderen
im Zusammenhang eines Programms benötigten, mitwirkenden
Programmeinheiten.
Die Größe der unabhängig getesteten Einheit kann dabei von
einzelnen Methoden über Klassen bis hin zu Komponenten reichen.
- Download JUnit 3.8.1
Download und Installation
JUnit ist als Open Source Software unter der IBM Public License
veröffentlicht.
Die aktuelle Version (momentan JUnit 3.8) können Sie von SourceForge
beziehen:
http://sourceforge.net/projects/junit/.
Entsprechende Frameworks sind für nahezu alle gängigen
Programmiersprachen frei erhältlich:
http://www.xprogramming.com/software.htm.
Das JUnit-Framework kommt in einem JAR-Archiv namens junit.jar
verpackt.
Die vollständige Distribution besteht gegenwärtig aus einem
ZIP-Archiv, in dem neben junit.jar
auch dessen Quelltexte,
dessen Tests, einige Beispiele, die JavaDoc-Dokumentation, die FAQ,
ein Kochbuch und zwei sehr lesenswerte Artikel aus dem amerikanischen
Java Report beiliegen.
Machen Sie sich mal ruhig mit den Beigaben vertraut.
Es lohnt sich.
Zur Installation entpacken Sie bitte das ZIP-Archiv und übernehmen
Sie junit.jar
in Ihren CLASSPATH
.
Fertig!
Wie Sie JUnit mit praktisch jeder gängigen Java-Entwicklungsumgebung
verwenden, können Sie unter
http://www.junit.org/IDEs.htm
nachlesen.
Für den Anfang reicht es jedoch vollkommen aus, wenn Ihr Compiler sich
nicht darüber beschwert, die JUnit-Klassen in seinem Klassenpfad zu
vermissen.
Den Test dafür werden wir in zwei Minuten machen.
Ein erstes Beispiel
Wir wollen eine Klasse Euro
ins Leben testen,
die Euro-Beträge akurater repräsentieren kann als der
bloße Java-Typ double
, den wir bisher
(im Artikel "XP über die Schulter geschaut")
verwendet haben.
Anhand dieses kleinen Beispiels können Sie den prinzipiellen Aufbau
eines Testfalls kennenlernen und Ihren ersten kleinen Erfolg mit JUnit
feiern.
In der JUnit-Dokumentation werden Sie ein ähnliches Beispiel finden.
Ich habe mich aus verschiedenen Gründen dafür entschieden,
auf vertrautem Terrain loszuwandern.
Michael Wein schrieb mir, unbedingt darauf hinzuweisen, daß weder
die Money
Klasse der JUnit-Distribution noch die in diesem
Beispiel entwickelte Klasse den Robustheitsanforderungen der
Finanzwirtschaft genügt.
Wenn's drauf ankommt, benutzen Sie besser java.lang.BigDecimal
oder ähnliches.
Die Klasse Euro
stellt Wertobjekte für geldliche
Beträge dar.
Das heißt, das Objekt wird eindeutig durch seinen Wert beschrieben.
Sie können ein Wertobjekt nicht verändern.
Wenn Sie ein Wertobjekt manipulieren, erhalten Sie ein anderes Objekt
mit dem neuen Wert zurück.
Einen Test für eine Klasse zu schreiben bietet immer auch eine gute
Gelegenheit, über ihre öffentliche Schnittstelle nachzudenken.
Was also erwarten wir von unserer Klasse?
Nun, zunächst möchten wir sicherlich ein Euro
Objekt
instanzieren können, indem wir dem Konstruktor einen Geldbetrag
übergeben.
Wenn wir ein Euro
Objekt zu einem anderen hinzuaddieren,
möchten wir, daß unsere Klasse mit einem neuen Euro
Objekt antwortet, das die Summe der beiden Beträge enthält.
Euro-Beträge unterliegen dabei einer besonderen Auflösung
in ihrer numerischen Repräsentation.
Zum Beispiel erwarten wir, daß auf den Cent genau gerundet wird und
daß 100 Cents einen Euro ergeben.
Aber fangen wir mit der einfachen Datenhaltung an.
Hier sehen Sie den ersten Test:
import junit.framework.*;
public class EuroTest extends TestCase {
public EuroTest(String name) {
super(name);
}
public void testAmount() {
Euro two = new Euro(2.00);
assertTrue(2.00 == two.getAmount());
}
public static void main(String[] args) {
junit.swingui.TestRunner.run(EuroTest.class);
}
}
JUnit ist wirklich einfach zu verwenden.
Es wird nicht schwerer.
Anatomie eines Testfalls
Sie erkennen, daß wir unsere Tests getrennt von der Klasse
Euro
in einer Klasse namens EuroTest
definieren.
Um unsere Testklasse in JUnit einzubinden, leiten wir sie von dessen
Framework-Basisklasse junit.framework.TestCase
ab.
Jede Testklasse erhält einen Konstruktor für den Namen des
auszuführenden Testfalls.
Das Test-Framework instanziert für jeden Testfall ein neues Exemplar
dieser Klasse, wie wir später bei der Betrachtung des Lebenszyklus
eines Testfalls sehen werden.
Unser erster Testfall verbirgt sich hinter der Methode
testAmount
.
JUnit erkennt diese Methode dadurch als Testfall, daß sie der
Konvention des Signaturmusters public void test...()
folgt.
Testfallmethoden dieses Musters kann das Framework via Java Reflection
zu einer Suite von Tests zusammenfassen und voneinander isoliert
ausführen.
In diesem Testfall erzeugen wir uns zunächst ein Objekt mit dem
Wert "zwei Euro".
Der eigentliche Test erfolgt mit dem Aufruf der assertTrue
Methode, die unsere Testklasse aus ihrer Oberklasse erbt.
Das assertTrue
Statement formuliert eine Annahme,
die JUnit automatisch für uns verifizieren wird.
Die assertTrue
Methode dient dazu, eine Bedingung zu testen.
Als Argument akzeptiert sie einen boolschen Wert bzw. einen Ausdruck,
der einen solchen liefert.
Der Test ist erfolgreich, wenn die Bedingung erfüllt ist,
d.h. der Ausdruck zu true
ausgewertet werden konnte.
Ist die Bedingung nicht erfüllt, d.h. false
,
protokolliert JUnit einen Testfehler.
In diesem Beispiel testen wir, daß unser Objekt two
als Ergebnis der getAmount
Operation die erwarteten
"zwei Euro" antwortet.
Durch Aufruf der main
Methode können wir unseren ersten
JUnit-Test ausführen.
Der junit.swingui.TestRunner
stellt eine grafische
Oberfläche auf Basis von Java Swing dar, um Unit Tests kontrolliert
ablaufen zu lassen.
Mit der JUnit-Oberfläche werden wir uns noch zu einem späteren
Zeitpunkt beschäftigen.
Momentan läßt sich unsere Testklasse noch nicht übersetzen.
Sie beschwert sich noch darüber, keine Klasse Euro
zu kennen.
Erst testen, dann programmieren
Im nächsten Artikel werden wir der Programmiertechnik
Testgetriebene Entwicklung
begegnen.
Dort werden Sie sehen, wie wir ein Programm inkrementell in kleinen
Schritten entwickeln können, indem wir grundsätzlich immer zuerst
einen Test schreiben, bevor wir die Klasse weiter schreiben, die diesen
Test dann erfüllt.
Natürlich können Sie mit JUnit aber auch Tests für schon
bestehenden Code schreiben, obwohl Klassen oft schlecht testbar sind,
wenn sie nicht von vornherein mit Testbarkeit im Hinterköpfchen
entworfen wurden.
-
-
Schreiben Sie Tests, bevor Sie den Code schreiben, der diese Tests
erfüllen soll, damit Sie sicherstellen, daß Ihr Code einfach
zu testen ist.
Den Prozess der testgetriebenen Programmierung werden Sie später
noch detailliert kennenlernen.
Zu diesem Zeitpunkt wollen wir uns allein auf das Testen mit JUnit
konzentrieren.
Lassen Sie uns deshalb direkt zur Implementation der Klasse
Euro
übergehen.
Was müssen wir hinschreiben, um den Test zu erfüllen?
public class Euro {
private double amount;
public Euro(double amount) {
this.amount = amount;
}
public double getAmount() {
return this.amount;
}
}
Wenn Sie den Code kompilieren und EuroTest
ausführen
(entweder aus Ihrer IDE heraus oder via java EuroTest
auf
der Kommandozeile), sollte ein neues Fensterchen mit JUnit's grafischer
Oberfläche und darin ein freundlicher, hellgrüner Balken erscheinen.
Erfolg!
Der Test läuft.
Klicken Sie ruhig noch einmal auf den Run
Knopf.
Dieser Knopf macht ganz klar süchtig.
Sie werden sich später ganz sicher dabei ertappen, die Tests zwei- oder
dreimal nacheinander auszuführen nur wegen des zusätzlichen
Vertrauens, das Ihnen der grüne Balken schenkt.
Es ist wirklich ein unbeschreiblich großartiges Gefühl, wenn
hunderte von Tests ablaufen und sich der Fortschrittbalken dabei von links
nach rechts mit Grün füllt.
So ein Gefühl gibt Mut, die "Softness" von Software auf's
Neue zu entdecken.
Das JUnit-Framework
Im zweiten Teil des Artikels möchte ich Ihnen den
Aufbau von JUnit detaillierter vorstellen.
Ich könnte Ihnen empfehlen, sich den Code von JUnit genau anzusehen.
Wenn Sie so sind wie ich, dann lernen Sie ein Framework am besten,
indem Sie dessen Code studieren.
JUnit ist ein gutes Beispiel für ein kleines, fokussiertes Framework
mit einer hohen Dichte säuberlich verwendeter Entwurfsmuster.
JUnit ist vor allem deshalb ein gutes Beispiel, weil es inklusive seines
eigenen Testcode kommt.
Versprechen Sie sich selbst bitte, daß Sie den Code lesen werden.
Wenigstens irgendwann...
-
-
Eines guten Tages sollten Sie sich die Zeit nehmen und den
junit.framework
Code studieren und um Ihre eigenen
Anforderungen erweitern.
Ich werde im folgenden die JUnit-Klassen vorstellen, mit denen Sie am
meisten in Berührung kommen werden.
Wenn Sie keine Mühe gescheut haben und sich tatsächlich angeschaut
haben, was Kent Beck und Erich Gamma ursprünglich auf einem Flug zur
OOPSLA-Konferenz zusammen programmiert haben, wissen Sie schon, wovon ich
sprechen werde.
Was Sie wahrscheinlich jedoch noch nicht wissen ist, wie die einzelnen
Puzzlestücke zum Ganzen zusammengelegt werden.
Dazu wenden wir uns hin und wieder unserer Euro
Klasse zu.
"Assert"
Wie testen wir mit JUnit?
JUnit erlaubt uns, Werte und Bedingungen zu testen,
die jeweils erfüllt sein müssen, damit der Test okay ist.
Die Klasse Assert
definiert dazu eine Menge von
assert
Methoden, die unsere Testklassen aus JUnit erben
und mit denen wir in unseren Testfällen eine Reihe unterschiedlicher
Behauptungen über den zu testenden Code aufstellen können:
-
assertTrue(boolean condition)
verifiziert, ob eine Bedingung wahr ist.
- Beispiele:
assertTrue(theJungleBook.isChildrensMovie());
assertTrue(40 == xpProgrammer.workingHours() * days);
-
assertEquals(Object expected, Object actual)
verifiziert, ob zwei Objekte gleich sind.
Der Vergleich der Objekte erfolgt in JUnit über die
equals
Methode.
- Beispiele:
assertEquals("foobar", "foo" + "bar");
assertEquals(new Euro(2), Movie.getCharge(1));
Der Vorteil dieser und der folgenden assertEquals
Varianten
gegenüber dem Test mit assertTrue
liegt darin,
daß JUnit Ihnen nützliche zusätzliche Informationen bieten
kann, wenn der Test tatsächlich fehlschlägt.
JUnit benutzt in diesem Fall die toString
Repräsentation
Ihres Objekts, um den erwarteten Wert auszugeben.
-
assertEquals(int expected, int actual)
verifiziert, ob zwei ganze Zahlen gleich sind.
Der Vergleich erfolgt für die primitiven Java-Typen über den
==
Operator.
- Beispiele:
assertEquals(9, customer.getFrequentRenterPoints());
assertEquals(40, xpProgrammer.workingHours() * days);
-
assertEquals(double expected, double actual, double delta)
verifiziert, ob zwei Fließkommazahlen gleich sind.
Da Fließkommazahlen nicht mit unendlicher Genauigkeit verglichen
werden können, wird zusätzlich eine Toleranz erwartet.
- Beispiele:
assertEquals(3.1415, Math.pi(), 1e-4);
-
assertNull(Object object)
verifiziert, ob eine Objektreferenz null
ist.
- Beispiele:
assertNull(hashMap.get(key));
-
assertNotNull(Object object)
verifiziert, ob eine Objektreferenz nicht null
ist.
- Beispiele:
assertNotNull(httpRequest.getParameter("action"));
-
assertSame(Object expected, Object actual)
verifiziert, ob zwei Referenzen auf das gleiche Objekt verweisen.
- Beispiele:
assertSame(bar, hashMap.put("foo", bar).get("foo"));
Die assertEquals
Methode ist neben den oben aufgeführten
Argumenttypen auch für die primitiven Datentypen float
,
long
, boolean
, byte
, char
und short
überladen.
Der Phantasie beim Testen sind also keine Grenzen gesetzt.
"AssertionFailedError"
Was passiert, wenn ein Test fehlschlägt?
Die im Testcode durch assert
Anweisungen
kodierten Behauptungen werden von der Klasse Assert
automatisch verifiziert.
Im Fehlerfall bricht JUnit den laufenden Testfall sofort mit dem Fehler
AssertionFailedError
ab.
Um den Test des centweisen Rundens nachzuholen, könnten wir
zum Beispiel folgenden Testfall schreiben:
public class EuroTest...
public void testRounding() {
Euro roundedTwo = new Euro(1.995);
assertTrue(2.00 == roundedTwo.getAmount());
}
}
Der Test läuft natürlich nicht, weil unsere Klasse noch kein
Konzept für das Runden hat.
Der Fortschrittbalken verfärbt sich rot.
Die Oberfläche teilt uns mit, daß während der
Ausführung unseres neuen Testfalls ein Fehler (Failure) aufgetreten ist,
der im unteren Textfenster zur Rückmeldung protokolliert wird:
junit.framework.AssertionFailedError
at EuroTest.testRounding(EuroTest.java:16)
"Der Testfall testRounding
schlug fehl",
sagt uns JUnit.
"Werfen Sie einen Blick auf Zeile 16 der Klasse
EuroTest
."
Wenn Ihnen diese Fehlermeldung nicht ausreichen sollte, bietet Ihnen JUnit
für alle assert
Methoden an, einen Erklärungstext
zu tippen, der im Fehlerfall mitprotokolliert wird.
Ich kann Ihnen nur raten, diese Variante zur Dokumentation der Tests
auszunutzen, ganz besonders bei langen Ketten von assert
Anweisungen.
assertTrue("amount not rounded", 2.00 == roundedTwo.getAmount());
-
-
Für die Tests gilt der gleiche Qualitätsanspruch wie für
den übrigen Code: selbstdokumentierend, kein duplizierter Code
und möglichst einfach.
Der Unterschied liegt allein im ausdrucksstärkeren Begleittext,
den JUnit im Fehlerfall zusätzlich ausgibt statt nur dem Namen
des fehlgeschlagenen Testfalls und der Zeilennummer des aufgetretenen
Fehlers:
junit.framework.AssertionFailedError:
amount not rounded
at EuroTest.testRounding(EuroTest.java:16)
Das Beste wäre in diesem Fall natürlich gewesen, wir hätten
gleich die bequemere assertEquals
Variante verwendet:
assertEquals("rounded amount",
2.00, roundedTwo.getAmount(), 0.001);
In diesem Fall kann JUnit den erwarteten gegen den tatsächlichen
Wert testen und unser Begleittext darf etwas kürzer ausfallen:
junit.framework.AssertionFailedError:
rounded amount expected:<2.0> but was:<1.995>
at EuroTest.testRounding(EuroTest.java:16)
Zum Vergleich der intern verwendeten Fließkomma-Repräsentation
tolerieren wir in diesem Beispiel ein Delta von 0.001
.
Das bedeutet, daß die verglichenen Werte sich nicht um einen
Betrag unterscheiden dürfen, der grösser ist als ein
Tausendstel Euro.
Mittlerweile finde ich den Anblick des roten Fortschrittbalkens
aber schon etwas unbefriedigend, Sie nicht auch?
Vom Testen infiziert zu sein bedeutet, nicht eher locker zu lassen,
bis die Tests wieder auf grün sind.
Die Frage ist:
Wollen Sie den Konstruktor selbst anpassen oder wollen Sie sehen,
was ich hingetippt hätte?
Voilà!
public class Euro {
private long cents;
public Euro(double euro) {
cents = Math.round(euro * 100.0);
}
public double getAmount() {
return cents / 100.0;
}
}
"TestCase"
Wie gruppieren wir Testfälle um eine gemeinsame Menge von Testobjekten?
Ein Testfall sieht in der Regel so aus, daß eine bestimmte
Konfiguration von Objekten aufgebaut wird, gegen die der Test läuft.
Diese Menge von Testobjekten wird auch als Test-Fixture bezeichnet.
Pro Testfallmethode wird meist nur eine bestimmte Operation und oft sogar
nur eine bestimmte Situation im Verhalten der Fixture getestet.
-
-
Testen Sie pro Testfallmethode nicht zuviel, nur eine ganz bestimmte Funktion
und dabei immer nur eine interessante Randbedingung zurzeit.
Schauen wir uns dazu ein Beispiel an:
public class EuroTest...
public void testAdding() {
Euro two = new Euro(2.00);
Euro three = two.add(new Euro(1.00));
assertEquals("sum", 3.00, three.getAmount(), 0.001);
assertEquals("two", 2.00, two.getAmount(), 0.001);
}
}
In diesem Testfall erzeugen wir uns ein Euro
Objekt und
addieren es zu einem anderen.
Das Resultat der Addition soll ein neues Euro
Objekt sein,
dessen Wert die Summe von "einem Euro" und "zwei Euro",
also "drei Euro" beträgt.
Unser ursprüngliches "zwei Euro" Objekt soll durch die
Addition nicht verändert werden.
Wenn wir diesen Test erfüllen sollten, würden wir vielleicht
folgendes programmieren:
public class Euro...
private Euro(long cents) {
this.cents = cents;
}
public Euro add(Euro other) {
return new Euro(this.cents + other.cents);
}
}
Der JUnit-Balken ist wieder zurück auf grün, aber was passiert
eigentlich, wenn wir ein Euro
Objekt mit negativem Betrag
definieren würden und damit die add
Operation aufriefen?
Wäre damit noch die Intention des Addierens ausgedrückt oder
sollte es dann besser subtract
heissen?
Verschieben wir die Antwort auf diese Frage für ein paar Momente.
Damit wir die Idee aber nicht verlieren, notieren wir auf einer Karteikarte
"Wie geht add mit negativen Beträgen um?"
-
-
Halten Sie Ideen sofort fest, verlieren Sie sie nicht wieder.
Wir waren dabei stehengeblieben, eine geeignete Test-Fixture herauszuziehen.
Lassen Sie uns dazu noch einmal einen kurzen Blick auf unsere bisherige
EuroTest
Klasse werfen:
import junit.framework.*;
public class EuroTest extends TestCase {
public EuroTest(String name) {
super(name);
}
public void testAmount() {
Euro two = new Euro(2.00);
assertTrue(2.00 == two.getAmount());
}
public void testRounding() {
Euro roundedTwo = new Euro(1.995);
assertEquals("rounded amount",
2.00, roundedTwo.getAmount(), 0.001);
}
public void testAdding() {
Euro two = new Euro(2.00);
Euro three = two.add(new Euro(1.00));
assertEquals("sum", 3.00, three.getAmount(), 0.001);
assertEquals("two", 2.00, two.getAmount(), 0.001);
}
public static void main(String[] args) {
junit.swingui.TestRunner.run(EuroTest.class);
}
}
Da sich der Code zum Aufbau einer bestimmten Testumgebung häufig
wiederholt, sollten Sie alle Testfälle, die gegen die gleiche
Menge von Testobjekten laufen, unter dem Dach einer Testklasse zusammenfassen.
Mit dem Code, der in den Testfällen jeweils das "zwei Euro"
Objekt kreiert, haben wir duplizierten Code dieser Art, den wir nun
in die Fixture herausziehen wollen.
Der Gewinn wird aus diesem ersten Beispiel nicht unmittelbar deutlich werden.
Später werden wir Testklassen mit komplexeren Testumgebungen begegnen,
in denen die Testfälle durch diesen Schritt stark vereinfacht werden
können.
Allgemein gilt, daß alle Testfälle einer Testklasse von der
gemeinsamen Fixture Gebrauch machen sollten.
Hat eine Testfallmethode keine Verwendung für die Fixture-Objekte, so ist
dies meist ein guter Indiz dafür, daß die Methode auf eine
andere Testklasse verschoben werden will.
Generell sollten Testklassen um die Fixture organisiert werden, nicht um
die getestete Klasse.
Somit kann es durchaus vorkommen, daß zu einer Klasse mehrere
korrespondierende Testklassen existieren, von denen jede ihre individuelle
Test-Fixture besitzt.
-
-
Organisieren Sie Testklassen um eine gemeinsame Fixture von Testobjekten,
nicht um die getestete Klasse.
Schauen wir uns unseren Testcode unter diesen Gesichtspunkten an.
Im ersten und dritten Testfall verwenden wir das "zwei Euro" Objekt.
Wir könnten damit anfangen, dieses Objekt in die Test-Fixture zu
übernehmen.
Der zweite Testfall macht eine Ausnahme.
Lassen Sie uns auch hier später entscheiden, wie wir damit umgehen
möchten.
Wir nehmen also eine weitere Karteikarte, notieren einfach
"EuroTest testRounding() refaktorisieren" und setzen uns
auf die Karte drauf.
Damit fehlerhafte Testfälle nicht andere Testfälle beeinflussen
können, wird die Test-Fixture für jeden Testfall neu initialisiert.
Dazu müssen Sie die Objekte der Test-Fixture zu Instanzvariablen Ihrer
TestCase
Unterklasse erklären.
Sie haben dann die Möglichkeit, zwei Einschubmethoden des Frameworks
wie folgt zu überschreiben:
In der Methode setUp
initialisieren Sie diese Instanzvariablen,
um so eine definierte Testumgebung zu schaffen.
In der Methode tearDown
geben Sie wertvolle Testressourcen
wie zum Beispiel Datenbank- oder Netzwerkverbindungen wieder frei, die Sie
zuvor in setUp
in Anspruch genommen haben.
JUnit behandelt Ihre Test-Fixture dann wie folgt:
Die setUp
Methode wird gerufen, bevor ein Testfall
ausgeführt wird.
Die tearDown
Methode wird gerufen, nachdem ein Testfall
ausgeführt wurde.
Hier sehen Sie unsere Testklasse mit extrahierter Test-Fixture:
import junit.framework.*;
public class EuroTest extends TestCase {
private Euro two;
public EuroTest(String name) {
super(name);
}
protected void setUp() {
two = new Euro(2.00);
}
protected void tearDown() {
}
public void testAmount() {
assertTrue(2.00 == two.getAmount());
}
public void testRounding() {
Euro roundedTwo = new Euro(1.995);
assertEquals("rounded amount",
2.00, roundedTwo.getAmount(), 0.001);
}
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);
}
public static void main(String[] args) {
junit.swingui.TestRunner.run(EuroTest.class);
}
}
Bitte beachten Sie, daß Sie Fixture-Variablen in der setUp
Phase initialisieren, nicht im Deklarationsteil noch im Konstruktor der
Testfallklasse.
Bitte beachten Sie ferner, daß wir die tearDown
Methode
in diesem Fall nicht hätten definieren müssen, da wir keine
Testressourcen freizugeben haben.
Ich habe sie hier lediglich aus Gründen der Vollständigkeit stehen
gelassen und damit Sie ihr schon einmal begegnet sind.
Lebenszyklus eines Testfalls
Was passiert, wenn JUnit die Tests dieser Klasse ausführt?
-
Das Test-Framework durchsucht die Testklasse mit Hilfe des Reflection API
nach öffentlichen Methoden, die mit
test
beginnen und
weder Parameter noch Rückgabewert besitzen.
-
JUnit sammelt diese Testfallmethoden in einer Testsuite und führt sie
voneinander isoliert aus.
Die Reihenfolge, in der Testfallmethoden vom Framework gerufen werden,
ist dabei prinzipiell undefiniert.
-
Damit zwischen einzelnen Testläufen keine Seiteneffekte entstehen,
erzeugt JUnit für jeden Testfall ein neues Exemplar der Testklasse
und damit eine frische Test-Fixture.
-
Der Lebenszyklus dieses Exemplars ist so gestaltet, daß vor der
Ausführung eines Testfalls jeweils die
setUp
Methode
aufgerufen wird, sofern diese in der Unterklasse redefiniert wurde.
-
Anschliessend wird eine der
test...
Methoden ausgeführt.
-
Nach der Ausführung des Testfalls ruft das Framework die
tearDown
Methode,
falls diese redefiniert wurde, und überläßt das
EuroTest
Objekt dann der Speicherbereinigung.
-
Dieser Zyklus wird vereinfacht erklärt ab Schritt 3 solange wiederholt,
bis alle Testfälle jeweils einmal ausgeführt wurden.
Wenn wir die Sequenz mit einem Profiler aufzeichnen würden,
in der JUnit unsere Testklasse benutzt,
ergibt sich hier beispielsweise folgende Aufrufreihenfolge:
new EuroTest("testAdding")
setUp()
testAdding()
tearDown()
new EuroTest("testAmount")
setUp()
testAmount()
tearDown()
new EuroTest("testRounding")
setUp()
testRounding()
tearDown()
-
-
Zum effektiven Testen müssen Testfälle isoliert voneinander
ausführbar sein.
Treffen Sie deshalb keine Annahmen über die Reihenfolge, in der
Testfälle ausgeführt werden.
Führen Sie voneinander abhängige Tests stattdessen gemeinsam
in einem Testfall aus.
Wir sind jetzt an einer Stelle angelangt, an der es lohnen könnte,
das grössere Bild zu betrachten.
In UML läßt sich der Sachverhalt in etwa wie folgt darstellen:
- Assert
- testet Werte und Bedingungen
- TestCase
- isoliert eine Reihe von Testfällen um eine gemeinsame Fixture von Testobjekten
- EuroTest
- testet das Verhalten unserer entwickelten
Euro
Klasse
(Mit dem Wechsel von der D-Mark zum Euro wurde auch unsere Klasse umgetauft.
Das Diagramm zeigt noch den alten Namen, sorry.)
"TestSuite"
Wie führen wir eine Reihe von Tests zusammen aus?
Unser Ziel ist es, den gesamten Testprozess so weit zu automatisieren,
daß wir den Test ohne manuellen Eingriff wiederholbar
durchführen können.
Wichtig ist schließlich, die Unit Tests möglichst häufig
auszuführen, idealerweise nach jedem Kompilieren .
Nur so erhalten wir unmittelbares Feedback darüber, wann unser Code
zu funktionieren beginnt und wann er zu funktionieren aufhört.
Wenn wir also immer nur winzig kleine Bissen vom Problemkuchen nehmen,
werden wir uns nie länger als 1-3 Minuten mit Programmieren
aufhalten, ohne grünes Licht zum Weiterprogrammieren einzuholen.
Eher selten werden wir dabei nur einzelne Tests ausführen wollen.
Meistens ist es schlau, alle gesammelten Unit Tests in einem Testlauf
auszuführen, um ungewollten Seiteneffekten frühzeitig zu begegnen.
-
-
Führen Sie möglichst nach jedem erfolgreichen Kompiliervorgang
alle gesammelten Unit Tests aus.
Mit JUnit können wir beliebig viele Tests in einer Testsuite
zusammenfassen und gemeinsam ausführen.
Dazu verlangt JUnit von uns, daß wir in einer statischen
suite
Methode definieren, welche Tests zusammen ausgeführt
werden sollen.
Eine Suite von Tests wird dabei durch ein TestSuite
Objekt
definiert, dem wir beliebig viele Tests und selbst andere Testsuiten
hinzufügen können.
Auf welcher Klasse Sie diese suite
Methode definieren ist
nebensächlich.
In den meisten Fällen werden Sie jedoch spezielle Klassen allein
dafür definieren wollen, um solche Testsuiten zu repräsentieren.
Nehmen wir beispielsweise alle bisher bestehenden Testfälle (auch jene
aus dem "XP über die Schulter geschaut" Artikel), würde unsere Testsuiteklasse folgende
Gestalt annehmen:
import junit.framework.*;
public class AllTests {
public static Test suite() {
TestSuite suite = new TestSuite();
suite.addTestSuite(CustomerTest.class);
suite.addTestSuite(EuroTest.class);
suite.addTestSuite(MovieTest.class);
return suite;
}
}
Alles, was Sie in JUnit zu tun haben, um eine Testsuite zu definieren,
ist ein TestSuite
Exemplar zu bilden und mittels der
addTestSuite
Methode verschiedene Testfallklassen
hinzuzufügen.
Jede Testfallklasse definiert implizit eine eigene suite
Methode, in der alle Testfallmethoden eingebunden werden, die in der
betreffenden Klasse definiert wurden.
JUnit erledigt diesen Teil für Sie automatisch mittels Reflection.
Sie werden vielleicht über den Typ des Rückgabewerts der
suite
Methode verwundert sein.
Warum Test
und nicht TestSuite
?
Nun, hier begegnen wir erstmalig JUnit's Test
Interface,
das sowohl von TestCase
als auch von TestSuite
implementiert wird.
Dieses Entwurfsmuster ist auch als Kompositum (engl. Composite) bekannt.
Es erlaubt genau, daß wir beliebig viele TestCase
und
TestSuite
Objekte zu einer umfassenden Testsuite-Hierarchie
kombinieren können.
In UML ausgedrückt:
- Test
- abstrahiert von Testfällen und Testsuiten
- TestSuite
- führt eine Reihe von Tests zusammen aus
Momentan liegen alle unsere Klassen noch im Default-Package.
Der Regelfall ist natürlich, daß unsere Klassen in verschiedenen
Paketen stecken.
Wir werden die Klassen später auf geeignete Klassenpakete verteilen.
In einem solchen Fall müssen wir das Klassenpaket beim Bündeln
der Testsuite entweder mitspezifizieren oder importieren:
suite.addTestSuite(common.Contract.class);
In vielen Fällen ist es praktisch, pro Package eine Testsuiteklasse zu
definieren.
Schon seit JUnit 1.0 ist es geläufig, Testsuiteklassen mit dem
Namen AllTests
zu benennen.
Nach diesem Muster können Sie Hierarchien von Hierarchien von Testsuiten
bilden:
suite.addTest(database.AllTests.suite());
Bitte beachten Sie, daß wir die main
Methode von der
EuroTest
Testfallklasse in die AllTests
Testsuiteklasse verschieben sollten, da sie hier besser aufgehoben ist:
public class AllTests...
public static void main(String[] args) {
junit.swingui.TestRunner.run(AllTests.class);
}
}
Wenn Sie die Testsuite aller Unit Tests ausführen möchten,
tippen Sie bitte java AllTests
oder
führen die Klasse innerhalb Ihrer Java IDE aus.
In jedem Fall sollte sich die bekannte JUnit-Oberfläche auftun.
Abschließend...
Im dritten und letzten Teil dieses Artikels wollen wir auf die beiden
Karteikarten zurückkommen, die wir im Zuge unserer Programmierepisode
zurückgelassen haben.
Erinnern Sie sich noch an die Karte "EuroTest testRounding()
refaktorisieren"?
Wir hatten uns vorgenommen, uns den Testfall testRounding
noch einmal vorzuknöpfen, weil dieser Testfall vom
üblichen Testmuster der Test-Fixture abzuweichen schien.
Bedeutet das nun wirklich, wir müssten extra für diesen Testfall
eine neue Testfallklasse bilden?
Nein, wir können guten Gebrauch der Test-Fixture machen, wenn wir
unseren Testfall geeignet umschreiben:
public class EuroTest...
public void testRounding() {
Euro roundedTwo = new Euro(1.995);
assertEquals("rounded amount", two, roundedTwo);
}
}
Für ein richtiges Wertobjekt gehört es sich ohnehin, daß wir
zwei Objekte mittels der equals
Methode auf Gleichheit testen
können.
Lassen Sie uns diese Intention deshalb auch in unserem Code ausdrücken:
public class EuroTest...
public void testAdding() {
Euro three = two.add(new Euro(1.00));
assertEquals("sum", new Euro(3.00), three);
assertEquals("two", new Euro(2.00), two);
}
}
Aber warten Sie!
Unsere Tests fangen an, sich zu beschweren:
junit.framework.AssertionFailedError:
sum expected:<Euro@8b456289> but was:<Euro@8b496289>
at EuroTest.testAdding(EuroTest.java:30)
junit.framework.AssertionFailedError:
rounded amount expected:<Euro@18d18634> but was:<Euro@19318634>
at EuroTest.testRounding(EuroTest.java:24)
Das ist gut, daß uns JUnit sagt, daß was nicht stimmt!
Aber was geht hier vor?
Die Ausgabe der Objektreferenzen, die Java standardmässig als Resultat
der toString
Methode liefert, hilft uns wenig weiter.
Überschreiben wir also mal das Standardverhalten mit einer
ausdrucksstärkeren String-Repräsentation:
public class Euro...
public String toString() {
return "" + getAmount();
}
}
Hey, was ist das?
Programmieren, ohne einen Test dafür zu haben?
Tja, nun, diese Methode ist so einfach, daß ein Test daran meines
Erachtens verschwendet wäre.
-
-
Beobachten Sie den Return-on-Investment, wenn Sie Tests schreiben.
Schreiben Sie nur Tests der Art, die ihren Aufwand wert sind.
Testen wir jetzt, erhalten wir folgende Rückmeldungen:
junit.framework.AssertionFailedError:
sum expected:<3.0> but was:<3.0>
at EuroTest.testAdding(EuroTest.java:30)
junit.framework.AssertionFailedError:
rounded amount expected:<2.0> but was:<2.0>
at EuroTest.testRounding(EuroTest.java:24)
Hmmm, seltsam, aber doch kristallklar:
Die assertEquals
Anweisung in Zeile 30 unseres Tests greift
intern auf die equals
Methode zurück und diese vergleicht
zwei Objekte standardgemäß anhand ihrer Referenzen.
Damit unser Euro
Objekt als wirkliches Wertobjekt und nicht
als Referenzobjekt behandelt wird, müssen wir die equals
Methode geeignet überschreiben.
Aber zunächst definieren wir für das gewünschte Verhalten
einen weiteren Test:
public class EuroTest...
public void testEquality() {
assertEquals(two, two);
assertEquals(two, new Euro(2.00));
assertEquals(new Euro(2.00), two);
assertTrue(!two.equals(new Euro(0)));
assertTrue(!two.equals(null));
assertTrue(!two.equals(new Object()));
}
}
Die Implementation der equals
Methode sieht in etwa
wie folgt aus:
public class Euro...
public boolean equals(Object o) {
if (o == null || !o.getClass().equals(this.getClass())) {
return false;
}
Euro other = (Euro) o;
return this.cents == other.cents;
}
}
Nochmal nachdenken:
Der Java-Standard erwartet von uns, daß wir mit der equals
Methode auch gleichzeitig die hashCode
Methode redefinieren:
public class EuroTest...
public void testHashCode() {
assertTrue(two.hashCode() == two.hashCode());
assertTrue(two.hashCode() == new Euro(2.00).hashCode());
}
}
Also:
public class Euro...
public int hashCode() {
return (int) cents;
}
}
Puhhh...
Sie bemerken, daß wir hier zusätzlichen Code schreiben, nur um
unsere Klassen zu testen.
Das ist interessanterweise nicht mal schlimm.
Im Gegenteil, wir werden später sehen, wie das testgetriebene
Programmieren unsere Klassen dahin lenkt, daß sie testbarer werden.
-
-
Spendieren Sie Ihren Klassen ruhig zusätzliche öffentliche Methoden,
wenn sie dadurch leichter getestet werden können.
Wow, wir haben zu diesem Zeitpunkt zehn Tests(!)
Damit ist die erste Schallmauer durchbrochen.
Sicherlich können wir immer mehr Tests schreiben...
Wichtig ist, im Auge zu behalten, welche Tests ihren Aufwand wert waren
und welche nicht.
Die Milchmädchenrechnung geht so:
-
-
Schreiben Sie nur Tests für solchen Code, der unter
Umständen fehlschlagen könnte.
Testen von Exceptions
Wie testen wir, ob eine Exception folgerichtig geworfen wird?
Die zweite Karte fragt, "Wie geht add mit negativen Beträgen
um?"
Nun, sicherlich ist es schon nicht sinnvoll, negative double
Werte in den Konstruktor der Klasse geben zu dürfen.
Wir sollten deshalb die Vorbedingung des Konstruktors spezifizieren.
Normalerweise würde ich nicht empfehlen, Tests für Vorbedingungen
zu schreiben, doch möchte
ich an dieser Stelle eine Ausnahme machen, weil Sie so mal sehen,
wie Sie mit JUnit Exceptions testen können.
public class EuroTest...
public void testNegativeAmount() {
try {
final double NEGATIVE_AMOUNT = -2.00;
new Euro(NEGATIVE_AMOUNT);
fail("Should have raised an IllegalArgumentException");
} catch (IllegalArgumentException expected) {
}
}
}
Das JUnit-Muster, um Exceptions zu testen, ist denkbar einfach,
nachdem es einmal klar geworden ist.
Wir probieren, ein Euro
Exemplar mit negativem Wert zu bilden.
Was wir im Fall einer solchen Verletzung der Vorbedingung erwarten
würden, wäre eine IllegalArgumentException
zu werfen.
Wenn der Konstruktor also diese Ausnahme auslöst, kommt der
catch
Block zum Tragen.
Der Variablenname der Exception drückt es schon aus, wir erwarten eine
IllegalArgumentException
.
Wir fangen die erwartete Exception und alles ist gut.
Wenn der Konstruktor die Ausnahme dagegen nicht auslöst, wird die
fail
Anweisung ausgeführt und der spezifizierte Fehlertext
von JUnit in einem AssertionFailedError
Fehlerobjekt
protokolliert.
Der fail
Methode sind wir bisher noch nicht begegnet.
Sie wird von der Assert
Klasse realisiert.
Sie können sie wann immer verwenden, wenn Sie einen Testfall mit einem
Fehler abbrechen möchten.
Abschliessend sehen Sie den angepassten Konstruktor:
public class Euro...
public Euro(double euro) {
if (euro < 0.0) {
throw new IllegalArgumentException("Negative amount");
}
cents = Math.round(euro * 100.0);
}
}
Schenken Sie Ihrem Testcode unter allen Umständen die gleiche
Aufmerksamkeit wie dem Programmcode, der in Produktion gehen soll.
Es ist keineswegs unüblich, daß Sie mehr
Testcode schreiben werden wie Produktionscode.
Unit Tests zu schreiben ist allem voran eine Investition in die Zukunft
ihrer Software, die sie durch regelmässiges wie sorgfältiges
Refactoring unbedingt aufrechterhalten sollten.
-
-
Refaktorisieren Sie Ihren Testcode genauso regelmässig und
sorgfältig wie ihren übrigen Code.
Nächstes Tutorial in dieser Serie:
Testgetriebene Entwicklung
Danke für Eure Verbesserungsvorschläge
Tammo Freese,
Johannes Link,
Karsten Menne,
Martin Müller-Rohde,
Stefan Roock,
Andreas Schoolmann
und
Frankmartin Wiethüchter
Weiterführende Informationen
19.2.2001
Ein erster Einblick
Wie sieht ein Tag mit Extreme Programming aus?
Nun, Sie werden wie gewohnt programmieren.
Obwohl, nicht ganz.
Sie programmieren mit einem Partner.
Sie arbeiten zu zweit an einer kleinen überschaubaren Aufgabe.
Die Zerlegung in Aufgaben haben Sie kurz zuvor im Team in einem kurzen
Design-Meeting mit dem Kunden diskutiert und geplant.
Sie haben die Verantwortlichkeit für eine Reihe von Aufgaben akzeptiert
und suchen sich für deren kurze Dauer jeweils einen Programmierpartner.
Bevor Sie gemeinsam zu programmieren beginnen, analysieren Sie und Ihr Partner
zunächst, was Ihr Auftraggeber überhaupt an Anforderungen stellt.
Dazu möchten Sie unter Umständen direkt mit Ihrem Kunden sprechen.
Er arbeitet wenige Meter von Ihnen beiden entfernt im gleichen Büro,
damit Fragen des Entwicklungsteams unmittelbar diskutiert werden können.
Jetzt wissen Sie beide, was verlangt wird, und Sie können endlich
losprogrammieren.
Einen Augenblick.
Bevor Sie losprogrammieren, überlegen Sie und Ihr Partner sich zunächst
ein Design dafür, wie die Anforderungen überhaupt umgesetzt werden sollen.
Dazu schreiben Sie eine Reihe von Testfällen.
Anschliessend schreiben Sie den Code, der diese Tests erfüllt.
Dabei streben Sie stets die einfachste Lösung an.
Testfall für Testfall erhalten Sie grünes Licht und gehen weiter.
Die Aufgabe gilt dann als erledigt, wenn Ihnen beiden keine weiteren Tests
mehr einfallen, die sich für die aktuellen Anforderungen sinnvoll schreiben
liessen.
Wenn alle Ihre Tests erfüllt sind, verlangt XP von Ihnen, den geschriebenen
Code noch möglichst zu vereinfachen und sein Design zu verbessern.
Der Code darf unter anderem keine duplizierte Logik enthalten und muß jede
Intention ausdrücken, die für das Verständnis des Programms notwendig
erscheint.
Nachdem Sie den Code in Form gebracht haben, lassen Sie Ihre Arbeit durch
die Suite aller im Team gesammelten Tests kontrollieren und integrieren
schließlich Ihren geschriebenen Code mit den Änderungen Ihrer Teamkollegen.
Anschliessend besorgen Sie sich zunächst einmal frischen Kaffee und helfen
eventuell direkt Ihrem Partner bei seiner nächsten Programmieraufgabe.
Ein XP-Tag geht schnell vorüber, da Sie den ganzen Tag lang intensiv
mit Ihren Kollegen programmiert haben.
Das bedeutet nicht etwa, daß Sie Ihrem Partner bei der Arbeit über die
Schulter schauen.
Im Gegenteil.
Im Paar zu programmieren bedeutet aufmerksam in die Programmierepisode
involviert zu sein.
Sie unterhalten sich über den Code, den sie gemeinsam schreiben.
Regelmässig übergeben Sie Ihrem Partner die Tastatur und lassen ihn
"fahren".
Am Ende eines solchen Tages sind Sie erschöpft und, glauben Sie mir,
Sie gehen pünktlich nach Hause, weil Sie acht Stunden lang fokussiert
gearbeitet haben und die geschriebenen Tests Ihnen Vertrauen in die
geleistete Arbeit geben.
Zugegeben, der Name "Extreme Programming" läßt einen extrem
einseitigen Ansatz der Softwareentwicklung vermuten.
Auf den zweiten Blick jedoch widmet sich XP sehr viel intensiver
der Analyse, dem Design und dem Test als schwergewichtige Methoden.
XP bringt uns zurück zu den Wurzeln des Handwerks guter Programmierung
und der Fragestellung, was wirklich zählt, wenn wir hochqualitative Software
erstellen wollen.
Ich möchte Sie dazu einladen, das Handwerk von XP und das Lebensgefühl eines
leichtgewichtigen Prozesses kennenzulernen.
Begleiten Sie mich auf den folgenden Seiten durch ein XP-Projekt und schauen
Sie dem Team bei der täglichen Arbeit über die Schulter und in die Karten.
Programmieren in Paaren
Die Regel lautet: Wer um Hilfe bittet, dem wird Hilfe geboten.
Tatsächlich wird keine Zeile Produktionscode geschrieben, ohne daß zwei Paar
Augen auf den Bildschirm gerichtet sind.
Das bedeutet, Sie programmieren zu zweit an einem Rechnern mit einer Tastatur
und einer Maus.
Sie sitzen nebeneinander, führen ein intensives Gespräch über den
entstehenden Code und wechseln sich regelmässig an der Tastatur ab.
Sie dürfen dabei sogar Spaß haben, denn Programmieren soll Spaß bringen.
Sie wechseln häufiger Ihre Programmierpartner und am Ende der Woche haben
Sie idealerweise mal mit jedem Ihrer Kollegen zusammengearbeitet, damit sich
das aufgebaute Wissen über das gesamte Team verbreitet.
Felix und Ulrich, Dienstag, 1.Iteration, 11.03 Uhr
Ulrich: Was ist unsere Aufgabe?
Felix: Wir müssen die Miete für ausgeliehene DVDs berechnen.
Ulrich: Wie berechnen wir das?
Felix: Der Preis ist abhängig davon, wie lange eine DVD ausgeliehen wird.
Ulrich: Und wie?
Felix: Laß mich mal sehen... Auf unserer Karte steht, reguläre Filme kosten für zwei Tage
2 Euro. Ab dem dritten Ausleihtag dann 1.5 Euro pro Tag.
Ulrich: Okay, wie machen wir das also?
Felix: Mmm, ich schlage vor, wir merken uns zunächst mal, welche Filme ein
Kunde ausleiht und berechnen später die Miete anhand der Ausleihtage.
Ulrich: Warum berechnen wir die Kosten nicht auf der Stelle?
Felix: Das ist später vielleicht notwendig, aber momentan ist das keine
Anforderung.
Ulrich: Schön, wie soll unsere erste Klasse heissen?
Felix: Spannende Frage... Die Verantwortlichkeiten klingen fast danach, als
würde sich die Klasse selbst Customer
nennen wollen.
Ulrich: Fangen wir damit mal an.
Felix: Stop mal! Laß uns gleich einen Test dafür schreiben...
Ulrich: Gut, dazu muß unsere Testklasse von TestCase
aus dem
Test-Framework ableiten.
Felix: Und dann bekommt sie noch einen Konstruktor für den Namen des
Testfalls.
public class CustomerTest extends junit.framework.TestCase {
public CustomerTest(String name) {
super(name);
}
}
Testgetriebenes Programmieren
Die anzustrebende Geisteshaltung muß sein, daß eine Funktion solange nicht
existiert, bis sie einen automatisierten Test besitzt.
Tatsächlich entsteht kein Produktionscode, bevor es nicht einen
entsprechenden Testfall gibt, der fehlschlägt.
Das bedeutet, Sie schreiben einen Test noch bevor Sie den Code schreiben,
der diesen Test erfüllt.
Sie erstellen inkrementell eine umfassende Suite von Unit Tests, die das gesamte Programm
in isolierten Einheiten auf die erwartete Funktion hin überprüft.
Sie verwenden ein Test-Framework, das Ihnen dabei hilft, automatische
Tests zu schreiben und auszuführen.
Sie sammeln und pflegen diese Tests, damit Sie nach jeder Änderung
sicherstellen können, daß die Testsuite zu 100% läuft.
Ulrich: Was ist der erste Testfall?
Felix: Das einfachste wäre, wir fangen mit dem Ausleihen eines Films an.
Ulrich: Und wie?
Felix: Es ist so, daß wir für jede Methode, die funktionieren soll,
Zusicherungen schreiben wollen, um die Funktion abzuprüfen.
Ulrich: Dafür ist die assertTrue
Methode gedacht, die wir aus
TestCase
erben.
Felix: Genau. Als Parameter erwartet sie eine Bedingung, die true
ergeben muß, damit der Testfall als erfüllt gilt. Den Rest erledigt das
Framework.
Ulrich: Und wohin schreiben wir nun den Testcode?
Felix: Am besten schreiben wir dafür eine Testfallmethode testRentingOneMovie
, die
dann die Mietkosten für den Film testet. Das Framework findet automatisch
alle Methoden, die mit test
beginnen, und führt sie aus.
Ulrich: Gut, schreiben wir mal auf, was wir bis jetzt wissen. Wir benötigen
zunächst mal ein Customer
Exemplar. Und dann muß ich so tun, als
gäbe es einfach alle Methoden schon, die ich mir wünsche.
Felix: Richtig. Wir leihen eine DVD für einen Tag aus und es soll 2 Euro
kosten.
Ulrich: Das ist einfach.
public class CustomerTest...
public void testRentingOneMovie() {
Customer customer = new Customer();
customer.rentMovie(1);
assertTrue(customer.getTotalCharge() == 2);
}
}
Möglichst einfaches Design
Die Designstrategie sieht vor, mit einem schlichten Design zu starten und
dieses fortlaufend zu verbessern.
Tatsächlich werden Designelemente, die komplizierter sind, als momentan
unbedingt notwendig wäre, aufgeschoben, selbst wenn nur für wenige Minuten.
Das bedeutet, Sie wählen von vielen verschiedenen Lösungswegen denjenigen,
der am einfachsten erscheint, um einen Testfall zu erfüllen.
Sie programmieren nur, was Sie jetzt tatsächlich benötigen, nicht, was Sie
später vielleicht benötigen.
Sie gehen sogar soweit, daß Sie unnötige Flexibilität wieder aus dem Code
entfernen.
Sie treten den Beweis dafür an, daß die aktuelle Lösung zu einfach ist,
indem Sie einen Testfall schreiben, der ein komplexeres Design rechtfertigt.
Ulrich: Also gut. Du willst, daß ich nur den Test zum Laufen bringe und
alles andere für einen Moment vergesse.
Felix: Ganz genau. Was würdest Du tun, wenn Du nur diesen einen Test
implementieren müsstest?
Ulrich: Hah, auch das ist einfach.
public class Customer {
public void rentMovie(int daysRented) {
}
public int getTotalCharge() {
return 2;
}
}
Felix: Wie extrem! Aber gut...
Ein wenig Testen, ein wenig Programmieren...
Das Zusammenspiel von testgetriebenem Programmieren und einfachem Design
ergibt den Zyklus des minutenweisen Programmierens.
Tatsächlich wird nie länger als zehn Minuten programmiert, ohne die
Feedbackschleife unmittelbar durch konkrete Tests zu schliessen.
Das bedeutet, Sie schreiben neuen Code in so winzigen Schritten, daß Ihr
Code gerade mal den aktuellen Testfall erfüllt.
Sie testen ein wenig, Sie programmieren ein wenig.
Dann testen Sie wieder und programmieren...
Minute für Minute feiern Sie einen kleinen Erfolg.
Sie schreiben keine ganze Klasse in einem Rutsch.
Vielmehr schreiben Sie nur ein paar Zeilen Code, maximal eine Methode auf
einmal.
Ulrich: Als nächstes möchte ich zwei und drei ausgeliehene DVDs testen.
Felix: Immer langsam... Schreib erst mal den Test für zwei Filme.
Der Zweite wird für zwei Tage entliehen. Die Summe soll 4 Euro betragen.
Laß uns dazu die assertEquals
Methode benutzen.
Als Parameter erhält sie den erwarteten Wert und das tatsächliche Resultat.
public class CustomerTest...
public void testRentingTwoMovies() {
Customer customer = new Customer();
customer.rentMovie(1);
customer.rentMovie(2);
assertEquals(4, customer.getTotalCharge());
}
}
Ulrich: Okay, dieser Test wird nicht laufen.
Felix: Woher weisst Du das? Schau nach, Du weisst nie!
Ulrich: Sagen wir einfach, ich bin mir ziemlich sicher.
Felix: Gut, wenn Du es also weisst und nehmen wir an, der Test zeigt grün,
würde das demnach bedeuten, daß entweder unser Test falsch ist oder aber der
Code Dinge tut, die er nicht machen darf, richtig? Mach den Test!
Ulrich: Also gut... Der Test zeigt rot.
Felix: Diesmal kommst Du auch nicht mehr so einfach davon...
public class Customer {
private int totalCharge = 0;
public void rentMovie(int daysRented) {
totalCharge += 2;
}
public int getTotalCharge() {
return totalCharge;
}
}
Evolutionäres Design
Organisches Wachstum scheint eine gute Strategie zu sein, um auf
Veränderung und Ungewissheit reagieren zu können.
Tatsächlich werden Anforderungsänderungen als Chance und nicht
als Problem betrachtet.
Das bedeutet, Sie verhalten sich im Design so, als wüssten Sie wirklich
nicht, was die nächsten Anforderungen sein würden.
Sie entwerfen stets die einfachste Lösung und bringen Ihren Code
anschliessend in die einfachste Form.
Sie vertrauen auf die Tatsache, daß sauber strukturierter Code in jede
Richtung mitziehen kann und daß der Code, den Sie gestern geschrieben haben,
Sie heute und morgen dabei unterstützen wird, weiterhin Code zu schreiben,
und Sie nicht zunehmend daran hindert.
Ulrich: Was ist der nächste Testfall?
Felix: Ein dritter Film, der drei Tage entliehen wird.
Ulrich: Wieviel kostet der Film, wenn er erst nach drei Tagen zurückgegeben wird?
Felix: Jeder weitere Tag kommt auf 1.5 Euro.
Ulrich: Also 3.5 Euro am Tag drei. Macht zusammen 7.5 Euro. Ausserdem können wir Fließkommazahlen nicht mit unendlicher Genauigkeit vergleichen.
public class CustomerTest...
public void testRentingThreeMovies() {
Customer customer = new Customer();
customer.rentMovie(1);
customer.rentMovie(2);
customer.rentMovie(3);
assertEquals(7.5, customer.getTotalCharge(), 1e-3);
}
}
Felix: Ab jetzt müssen wir 1.5 Euro draufschlagen.
Ulrich: Das bedeutet auch, daß totalCharge
ab sofort Fließkommazahl sein
möchte.
public class Customer {
private double totalCharge = 0;
public void rentMovie(int daysRented) {
totalCharge += 2;
if (daysRented > 2) {
totalCharge += 1.5;
}
}
public double getTotalCharge() {
return totalCharge;
}
}
Felix: Nun meckert aber der Compiler rum... Wir müssen wohl auch unseren vorherigen Testfall zum Vergleich von Fließkommazahlen bringen.
public class CustomerTest...
public void testRentingTwoMovies() {
Customer customer = new Customer();
customer.rentMovie(1);
customer.rentMovie(2);
assertEquals(4, customer.getTotalCharge(), 1e-3);
}
}
Natürlicher Abschluß einer Programmierepisode
Bewußt aufhören zu können ist einer der stärksten Programmierzüge.
Tatsächlich ist eine Aufgabe fertig, wenn alle Randbedingungen getestet sind,
die dazu führen können, daß etwas schief geht.
Das bedeutet, Sie schreiben nicht für jede Methode einen Test, sondern nur
für solche, die unter Umständen fehlschlagen könnten.
Sie halten Ihr Wissen über den Code fest, während Sie Tests dafür
schreiben.
Sie wissen, daß Sie erreicht haben, was Sie sich vorgenommen hatten, wenn
alle Ihre Tests erfüllt sind.
Zum Abschluß sei es Ihnen gegönnt, über Ihre Programmierepisode
zu reflektieren.
Sie wollen ja schließlich jeden Tag etwas dazulernen.
Ulrich: Ein Film, der vier Tage entliehen wird?
Felix: Kostet 5 Euro und macht dann insgesamt 12.5 Euro.
public class CustomerTest...
public void testRentingFourMovies() {
Customer customer = new Customer();
customer.rentMovie(1);
customer.rentMovie(2);
customer.rentMovie(3);
customer.rentMovie(4);
assertEquals(12.5, customer.getTotalCharge(), 1e-3);
}
}
Felix: Laß mich mal tippen!
Ulrich: Okay!
public class Customer...
public void rentMovie(int daysRented) {
totalCharge += 2;
if (daysRented > 2) {
totalCharge += (daysRented - 2) * 1.5;
}
}
}
Felix: So, das hätten wir. Haben wir irgendwelche Tests vergessen?
Ulrich: Müsste mit dem Teufel zugehen...
Refactoring
Gutes Design ist nie einfach und niemand bekommt die Dinge im ersten Versuch
in den Griff.
Tatsächlich entsteht ein Design durch schrittweises Wachstum und ständige
Überarbeitung.
Das bedeutet, daß Sie alle Erfahrungen in das Design zurückfliessen lassen
und das Design verbessern, nachdem der Code geschrieben wurde.
Sie refaktorisieren, um Ihren Code so einfach und so verständlich wie
möglich zu machen und jede Art von Redundanz zu beseitigen.
Ulrich: Wenn ich mir unseren Code ansehe, frage ich mich, was die vielen
Zahlen bedeuten. Wir sollten denen Namen geben!
Felix: Vorschläge?
public class Customer...
static final double BASE_PRICE = 2; // Euro
static final double PRICE_PER_DAY = 1.5; // Euro
static final int DAYS_DISCOUNTED = 2;
}
Ulrich: Eigentlich finde ich die Änderung ja zu klein, um nach jedem
Schritt zu testen...
Felix: Du hast Mut, aber wir haben ja noch die Tests...
public class Customer...
public void rentMovie(int daysRented) {
totalCharge += BASE_PRICE;
if (daysRented > DAYS_DISCOUNTED) {
totalCharge += (daysRented - DAYS_DISCOUNTED) * PRICE_PER_DAY;
}
}
}
Ulrich: Irgendwie passen die Konstantennamen nun aber doch nicht mehr zu der
Klasse.
Felix: Stimmt, das hat jetzt schon sehr viel mit der eigentlichen
Preisberechnung für den Film zu tun.
Ulrich: Ziehen wir eine neue Klasse Movie
raus, um dem Ausdruck zu
verleihen!?
Felix: Einverstanden, aber erst die Tests...
public class MovieTest extends junit.framework.TestCase {
public MovieTest(String name) {
super(name);
}
public void testGetCharge() {
assertEquals(2.0, Movie.getCharge(1), 1e-3);
assertEquals(2.0, Movie.getCharge(2), 1e-3);
assertEquals(3.5, Movie.getCharge(3), 1e-3);
assertEquals(5.0, Movie.getCharge(4), 1e-3);
}
}
Felix: Die Preisberechnung verschieben wir einfach auf diese Klasse...
public class Movie {
static final double BASE_PRICE = 2; // Euro
static final double PRICE_PER_DAY = 1.5; // Euro
static final int DAYS_DISCOUNTED = 2;
public static double getCharge(int daysRented) {
double result = BASE_PRICE;
if (daysRented > DAYS_DISCOUNTED) {
result += (daysRented - DAYS_DISCOUNTED) * PRICE_PER_DAY;
}
return result;
}
}
Felix: Unser Customer
wird dadurch wieder ganz schlank...
public class Customer...
public void rentMovie(int daysRented) {
totalCharge += Movie.getCharge(daysRented);
}
}
Fortlaufende Integration
Neuer Code wird schnellstmöglich in die neueste Version integriert.
Tatsächlich wird mehrmals täglich ein getesteter Build des gesamten
Programms erstellt.
Das bedeutet, daß Sie am Ende jeder Programmierepisode und wenigstens einmal
am Tag Ihren geschriebenen Code in das Programm integrieren und versionieren.
Sie laden Ihre Änderungen auf einen dedizierten Integrationsrechner,
integrieren Ihren Code und beseitigen entstehende Konflikte.
Sie führen die Tests aus und geben Ihren Code frei, sobald alle Tests
erfolgreich ausgeführt werden.
Die gesamte Integrationsprozedur dauert gerade so lange, daß Sie sich eine
frische Tasse Kaffee kochen und sie austrinken können.
Felix: Laß uns unseren Code integrieren und Mittag machen!
Ulrich: Milchkaffee oder Chinese?
Nächster Artikel in dieser Serie:
Unit Tests mit JUnit
Danksagungen
Leah Striker,
Michael Schürig,
Meike Budweg,
Tammo Freese,
Ulrike Jürgens,
Hans Wegener,
Marko Schulz,
Antonín Andert,
Manfred Lange
und Julian Mack
haben das Beispiel entstehen sehen und nützliche
Verbesserungsvorschläge gemacht.
Der Ehrentitel eines Meisterrezensenten sei an Rolf F. Katzenberger verliehen.
29.3.2008
Im Februar 2001 war das Agile Manifest geboren. In dieser Roundtable-Diskussion machen wir eine Bestandsaufnahme, Reflektion, Analyse: Was haben sieben Jahre Agile Entwicklung gebracht?
Mit dabei: Jutta Eckstein (Blog, Profil), Johannes Link (Blog, Profil), Jens Coldewey (Blog, Profil), Henning Wolf (Blog, Profil)
17.11.2005
Lange hat's gebraucht ... und nun ist es endlich da:
Testgetriebene Entwicklung mit JUnit und FIT ist soeben erschienen!
Mehr Details dazu auf meiner Buchseite, bei Amazon und dem dpunkt.verlag.