Schlagwort

junit

23.6.2012

Mein Buch zum Download

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.

Buchcover

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

Testgetriebene Entwicklung
mit JUnit und FIT

Buchcover

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

Geleitwort von Johannes Link (PDF)

Kapitel 1: Einleitung (PDF)

  • 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

Kapitel 3: Unit Tests mit JUnit (PDF)

  • 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.7.2005

JUnit 4.0

Eine kleine Sneak-Preview auf das neue JUnit, das im Sommer/Herbst erscheinen wird ... (im Vergleich zu JUnit 3.8.1)

Mit JUnit 4 haben Kent Beck und Erich Gamma nach mittlerweile sieben Jahren zum ersten Mal die Architektur ihres Test-Frameworks grundlegend geändert und alle Story-Karten auf die mit Java 1.5 eingeführten Annotationen gesetzt. Annotationen sind ein neues Ausdrucksmittel der im Trend liegenden Metaprogrammierung. Mithilfe von Annotationen können Sie Ihren Code mit frei wählbaren Anmerkungen versehen und auf die so markierten Codeelemente über den Reflection-Mechanismus später wieder zugreifen. In JUnit 4 wird dieses Sprachkonstrukt nun dazu verwendet, jede x-beliebige Methode jeder x-beliebigen Klasse als ausführbaren Testfall kennzeichnen zu können. Hier ist ein Test der neuen Schule:


import junit.framework.TestCase;

import org.junit.Test;
import static org.junit.Assert.*;

public class EuroTest extends TestCase {
@Test public void testadding() {
Euro two = new Euro(2.00);
Euro sum = two.add(two);
assertEquals("sum", new Euro(4.00), sum);
assertEquals("two", new Euro(2.00), two);
}
}

Sie erkennen, dass die Namenskonvention public void test...() wie auch das Ableiten der Klasse TestCase der Vergangenheit angehören. Sie kleben künftig einfach eine @Test Annotation an Ihre Testfälle und können Ihre Methoden nennen, wie Ihnen gerade gefällt. Doch gehen wir die Neuigkeiten doch einmal mit dem Vergrößerungsglas durch ...

Mit JUnit 4 kommt ein neuer Namensraum: Im Package org.junit steckt der neue annotationsbasierte Code. Das junit.framework Paket bleibt soweit bestehen und hat lediglich kleine Änderungen erfahren, um die Aufwärtskompatibilität herzustellen. Für Tests, die der neuen Schule folgen, benötigen wir von dem alten Zeugs jedoch nichts mehr:


import junit.framework.TestCase;

Stattdessen importieren wir jetzt die @Test Annotation und Methoden der Assert Klasse aus dem neuen JUnit-Package:


import org.junit.Test;
import static org.junit.Assert.*;

Falls Sie mit den neuen Sprachkonstrukten noch nicht vertraut sind: Ab Java 1.5 können Sie mithilfe von statischen Imports die statischen Methoden einer anderen Klasse einfach in den Namensraum Ihrer eigenen Klasse einblenden. Mit der Zeile:


import static org.junit.Assert.assertEquals;

... könnten wir beispielsweise die assertEquals Methode importieren, so als wäre diese eigentlich bei uns definiert. Der oben verwendete Joker holt einfach alle statischen Methoden auf einen Schwung.

Als Nächstes ist augenfällig, dass unsere Klasse nicht mehr von der Klasse TestCase abgeleitet ist. Ab sofort können Sie Ihre Tests nämlich in jede beliebige Klasse stecken. Die einzige Bedingung ist: Ihre Klasse muss über einen öffentlichen Default-Konstruktor instanzierbar sein:


public class EuroTest extends TestCase {

Welche Methoden als Testfälle auszuführen sind, markieren wir jetzt mithilfe der @Test Annotation. Den Klammeraffen nicht vergessen! Welche Namen Sie Ihren Methoden geben, ist egal. Den test... Präfix können Sie als Zeichen alter Tradition oder aus guter Konvention beibehalten oder es auch lassen. Einzige Bedingung: Ihre Methode muss öffentlich sein, darf keine Parameter und keinen Rückgabewert haben:


@Test public void testadding() {
Euro two = new Euro(2.00);
Euro sum = two.add(two);
assertEquals("sum", new Euro(4.00), sum);
assertEquals("two", new Euro(2.00), two);
}
}

Das ist, auf einer Seite zusammengefasst, was sich grob geändert hat.

JUnit 4 führt sechs unterschiedliche Annotationen ein:

  • @Test kennzeichnet Methoden als ausführbare Testfälle.
  • @Before und @After markieren Setup- bzw. Teardown-Aufgaben, die für jeden Testfall wiederholt werden sollen.
  • @BeforeClass und @AfterClass markieren Setup- bzw. Teardown-Aufgaben, die nur einmal pro Testklasse ausgeführt werden sollen.
  • @Ignore kennzeichnet temporär nicht auszuführende Testfälle.

@Before und @After

Setup- und Teardown-Methoden werden wie Testfälle via Annotation gekennzeichnet:

  • @Before Methoden werden vor jedem Testfall ausgeführt,
  • @After Methoden nach jedem Testfall.

Auch diese Methoden können beliebige Namen tragen, müssen nun aber öffentlich zugänglich sein, parameterlos und ohne Rückgabewert. Der Fixture-Aufbau erfolgt auf dem gewohnten Weg:


import org.junit.Before;

public class EuroTest {
private Euro two;

@Before public void setUp() {
two = new Euro(2.00);
}

@Test public void amount() {
assertEquals(2.00, two.getAmount(), 0.001);
}

@Test public void adding() {
Euro sum = two.add(two);
assertEquals("sum", new Euro(4.00), sum);
assertEquals("two", new Euro(2.00), two);
}
}

Neu ist, dass auch mehrere @Before und @After Methoden pro Klasse laufen können. Eine bestimmte Ausführungsreihenfolge wird dabei jedoch nicht zugesagt. Vererbte und nicht überschriebene Methoden werden in symmetrischer Weise geschachtelt gerufen:

  • @Before Methoden der Oberklasse vor denen der Unterklasse
  • @After Methoden der Unterklasse vor denen der Oberklasse

@BeforeClass und @AfterClass

Für kostspieligere Test-Setups, die nicht für jeden einzelnen Testfall neu aufgebaut und danach gleich wieder abgerissen werden können, existieren zwei weitere Annotationen:

  • @BeforeClass läuft für jede Testklasse nur ein einziges Mal und noch vor allen @Before Methoden,
  • @AfterClass entsprechend für jede Testklasse nur einmal und zwar nach allen @After Methoden.

Mehrere @BeforeClass und @AfterClass Annotationen pro Klasse sind zugelassen. Die so markierten Methoden müssen jedoch statisch sein:


import org.junit.BeforeClass;
import org.junit.AfterClass;

public class EuroTest...
@BeforeClass public static void veryExpensiveSetup() { ... }
@AfterClass public static void releaseAllResources() { ... }
}

Die @BeforeClass Methoden einer Oberklasse würden entsprechend noch vorher ausgeführt, alle ihre @AfterClass Methoden anschließend.

Erwartete Exceptions

Zum Testen von Exceptions können Sie der @Test Annotation über ihren optionalen Parameter expected mitteilen, dass die Ausführung Ihres Testfalls zu einer gezielten Exception führen soll:


public class EuroTest...
@Test(expected = IllegalArgumentException.class)
public void negativeAmount() {
final double NEGATIVE_AMOUNT = -2.00;
new Euro(NEGATIVE_AMOUNT); // should throw the exception
}
}

Wird keine Exception geworfen oder eine Exception anderen Typs, schlägt dieser Testfall eben genau fehl.

Wenn Sie das Exception-Objekt in dem Test noch weiter unter die Lupe nehmen wollen, um beispielsweise dessen Felder zu überprüfen, sollten Sie den altbekannten Weg über den try/catch Block nehmen. Ansonsten ist diese Annotation sehr elegant.

Timeouts

Ein für Performanztests interessanter optionaler Parameter ist timeout. Geben Sie Ihrem Testfall mit auf die Reise, in welcher Zeitspanne von Millisekunden er laufen sollte. Überschreitet er darauf sein Zeitlimit, wird er zwecks Fehlschlags abgebrochen:


public class EuroTest...
@Test(timeout = 100)
public void performanceTest() { ... }
}

@Ignore

Wenn Sie einen Test kurzzeitig außer Gefecht setzen wollen, können Sie das tun:


import org.junit.Ignore;

public class EuroTest...
@Ignore("not today")
@Test(timeout = 100)
public void performanceTest() { ... }
}

Der @Ignore Kommentar darf, sollte aber niemals fehlen! Der Test wird dann im Testlauf unter Protokollierung dieses Textes übergangen. Sorgen Sie jedoch dafür, dass ignorierte Tests schnellstens auf grün kommen und im besten Fall gar nicht erst eingecheckt werden können!

Neue Tests mit altem Runner ausführen

Damit die existierenden und zum Teil in die Entwicklungsumgebungen direkt integrierten TestRunner unsere neuen Tests ausführen können, müssen wir einen kleinen Kunstgriff unternehmen:


import junit.framework.JUnit4TestAdapter;

public class EuroTest...
public static junit.framework.Test suite() {
return new JUnit4TestAdapter(EuroTest.class);
}
}

Etwas unschön, doch nur solange notwendig, bis unsere Werkzeuge die Brücke zur neuen annotationsbasierten Form geschlagen haben, muss die gute alte suite Methode uns als Adapterfunktion herhalten.

Nachtrag: Nach Artikel und Buch hinzugekommen sind die @RunWith und @Suite Annotationen:
  • Lasse Koskela beschreibt, wie man Testsuiten mit JUnit 4 aufbaut,
  • Johannes Link erklärt, wie man Testfälle mit seiner Eclipse-Erweiterung auch projektübergreifend ausführen kann.

Alte Tests mit neuem Runner ausführen

JUnit 4 liefert nur noch einen textuellen Runner mit. Die grafische Integration wird konsequent den Werkzeugherstellern überlassen. Ebenso wandert die Organisation von Testfällen zu den Entwicklungswerkzeugen über. Der neue Runner JUnitCore akzeptiert tatsächlich nur noch ein flaches Array von Testklassen:


import org.junit.runner.JUnitCore;

public class AllTests {
public static void main(String[] args) {
JUnitCore.run(CustomerTest.class,
              EuroTest.class,
              MovieTest.class);
}
}

Testfallerzeugung und Testlauf

Erwähnenswert ist noch, dass JUnit 4 nicht mehr in zwei Phasen läuft: Es werden also nicht erst alle Testfallobjekte auf Halde erzeugt und dann ausgeführt. Die Erzeugung erfolgt just-in-time zum Testlauf.

Schlüsselwort assert

Wenn Sie mögen, können Sie in Ihren Tests ab sofort auch das Java 1.4 Schlüsselwort assert verwenden. Sie müssen lediglich daran denken, die Zusicherungen zur Laufzeit auch mit der Option -ea zu aktivieren. JUnit ist dazu intern zur Verwendung von AssertionError gewechselt, was auch dazu geführt hat, dass nicht mehr zwischen möglichen und unerwarteten Fehlschlägen, Failures und Errors, unterschieden wird. Fehler sind Fehler sind Fehler!

Subtile Unterschiede existieren zwischen altem und neuen Assert: junit.framework.Assert vs. org.junit.Assert. Unter Java 1.5 sollten Sie nur noch die neue Klasse einsetzen. Da Arrays untereinander nun auch über equals vergleichbar sind, werden Sie sogar mit einer neuen assert Methode beschenkt:


assertEquals(Object[] expected, Object[] actual)
24.6.2001

Unit Tests mit JUnit

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.

JUnit-Logo
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.

Tipp
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?


JUnit-IkonGrüner JUnit-Balken

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.

JUnit-Runner mit grünem Balken

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...

Tipp
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.

JUnit-Runner mit rotem Balken

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-IkonRoter JUnit-Balken

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());
Tipp
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-IkonRoter JUnit-Balken

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-IkonRoter JUnit-Balken

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à!


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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:


JUnit-IkonGrüner JUnit-Balken

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?"

Tipp
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.

Tipp
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:


JUnit-IkonGrüner JUnit-Balken

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?

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Anschliessend wird eine der test... Methoden ausgeführt.
  6. 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.
  7. 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:

  1. new EuroTest("testAdding")
  2. setUp()
  3. testAdding()
  4. tearDown()
  5. new EuroTest("testAmount")
  6. setUp()
  7. testAmount()
  8. tearDown()
  9. new EuroTest("testRounding")
  10. setUp()
  11. testRounding()
  12. tearDown()
Tipp
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:

TestCase-Klassenhierarchie
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.

Tipp
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:

TestSuite-Composite-Pattern
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-IkonRoter JUnit-Balken

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.

Tipp
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-IkonRoter JUnit-Balken

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:


JUnit-IkonGrüner JUnit-Balken

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:


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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:

Tipp
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:


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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

9.2.2005

Tonabnehmer #1: Johannes Link -
Softwaretests mit JUnit

Johannes Link

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."

6.1.2002

Testgetriebene Entwicklung

Eingehen auf Veränderung

Wie würden wir programmieren, wenn wir tatsächlich nicht wüssten, wohin unser Kunde die Entwicklung steuern wird? Wie müssten wir programmieren, wenn wir späte Anforderungsänderungen als Chance oder Wettbewerbsvorteil unserer Kunden und nicht als Risiko der Softwareentwicklung auffassen wollten? In dieser Situation würden wir unser Design inkrementell erstellen und in kleinen Schritten anpassen, während neue Anforderungen auf uns zukommen. Unser Designprozess wäre dem organischen Anpassungs- und Wachstumsprozess ganz ähnlich. Design wäre eine ständige Aktivität, die wir Minute um Minute wahrnehmen würden.

Interessanterweise beschreibt dies zu einem großen Teil unsere tagtägliche Situation in der Softwareentwicklung in heutigen schnelllebigen Märkten, wo viele Projekte in der Tat explorativer Natur sind. Ein Design lässt sich in diesen Fällen nicht vorab planen, sondern kann sich erst mit wachsendem Verständnis der Anforderungen entwickeln. Noch mehr ähnelt die Situation jedoch unseren Aufwänden in der Pflege, das heißt Weiterentwicklung, vorhandener Software. Wie würde sich demnach unsere Perspektive ändern, wenn wir davon ausgehen, dass wir schon die Softwareentwicklung so betreiben können wie Wartungsprojekte? Was müssten wir leisten, damit wir inkrementell und unbegrenzt lange immer neue Funktionen in unsere Programme integrieren können?

Um diesen Gedankensprung in die Realität zu retten, benötigen wir eine qualitätsbewusste Alternative zum vorab geplanten Design. Wir suchen nach einer Strategie, mit der wir verhindern, dass die Codequalität mit wachsender Programmgröße in Mitleidenschaft gezogen wird. Eine vielversprechende Wiederentdeckung ist die testgetriebene Entwicklung, oft auch testgetriebene Programmierung oder gar testgetriebenes Design genannt, wenn auch andere Autoren mit dem Namen zum Teil bestimmte Schwerpunkte der Technik ansprechen. Im Englischen spricht man von Test-Driven Development oder Test-First-Design.

Just-in-time-Design

Testgetriebenes Programmieren ist eine Just-in-time-Designtechnik, um auf Just-in-time-Anforderungen einzugehen. Wir schreiben dabei Unit Tests, noch bevor wir den zu testenden Programmcode schreiben. Idealerweise wird jede funktionale Programmänderung zuvor durch das Schreiben eines weiteren Tests motiviert. Wir entwerfen diesen Test so, dass er zunächst fehlschlägt, weil das Programm die gewünschte Funktionalität noch nicht besitzt. Erst anschließend schreiben wir den Code, der den Test zum Laufen bringt. Auf diese Weise wird die gesamte Programmentwicklung inkrementell durch das unmittelbare Feedback konkreter Tests angetrieben.

Sie fragen sich vielleicht, was wir denn testen sollen, wenn wir noch überhaupt keinen Code geschrieben haben? Doch diese Frage lässt sich umdrehen. Woher wissen wir denn, was wir programmieren sollen, wenn wir noch nicht wissen, was denn überhaupt erforderlich ist? Zuerst die Tests zu schreiben, ist eine Möglichkeit, um herauszufinden, was wir programmieren müssen und was nicht, und wie wir auch sicherstellen können, dass wir tatsächlich programmieren werden, was wir programmieren wollten. Analysieren Sie dazu, wie die erforderliche Klasse funktionieren und sich verhalten sollte. Dokumentieren Sie dann Ihr Verständnis in einem ausführbaren Unit Test. Stellen Sie sich dabei vor, der zu testende Code wäre einfach schon realisiert. Entwerfen Sie die Tests aus der Verwendungsperspektive, so, wie Sie sich die Schnittstelle der zu testenden Klasse wünschen, und verdrängen Sie die Gedanken an die Implementierung für einen kurzen Moment.

Wir sprechen von einer Designtechnik, weil das frühe Testen eine ganze Reihe positiver Auswirkungen auf das resultierende Design hat. Ein Vorteil dieser Technik wird hier schon unmittelbar deutlich. Wenn Sie Ihre Tests zuerst schreiben, wird Ihr Code auch testbar sein. Den Test haben Sie ja schließlich gerade schon geschrieben. Code dagegen, der nicht unter Gesichtspunkten der einfachen Testbarkeit entworfen wurde, läßt sich auch nachträglich meist nur noch schwer testen. Tatsächlich handelt es sich beim Test-First-Ansatz noch stärker um eine Designstrategie als eine Teststrategie. Ich werde zum Ende des Artikels noch auf die Langzeiteffekte dieser Technik zurückkommen.

Testgetriebenes Design ist der linke Fuß unserer evolutionären Designstrategie. Der rechte Fuß ist das Refactoring. Um den inkrementellen Designansatz zu gewährleisten, ist unbedingt eine höchstsäuberlich strukturierte Codebasis erforderlich. Wir müssen sicherstellen, dass wir uns durch kurzfristig getroffene Entwurfsentscheidungen nicht zunehmend in die Ecke malen. Um die innere Qualität des Programms dabei nicht zu opfern, sondern über sehr weite Zeiträume aufrechtzuerhalten, müssen wir das Design durch fortlaufende überarbeitung in Stand halten. Wir verbessern die Struktur des Code durch unzählige kleine Refactorings und führen nach jedem Schritt alle gesammelten Tests aus, um sicherzugehen, dass wir nicht ungewollt das Verhalten des Programms verändert haben. Miteinander kombiniert ermöglichen testgetriebenes Programmieren und anschließendes Refactoring, dass wir entwickeln können, was wir brauchen, wenn wir es brauchen.

Iteratives Testen und Programmieren

Die testgetriebene Entwicklung zwingt uns in einen stark iterativen Prozess, in dem jeder Schritt die Möglichkeit zum Lernen bietet. Jeder Test, den wir schreiben und erfüllen, kann uns konkretes Feedback darüber liefern, welchen Test wir als nächstes schreiben könnten oder sollten. Je kleiner wir dabei unsere Programmieretappen wählen, desto schneller können wir lernen, wohin uns der Weg führt.

Der Zyklus des Testens und Programmierens schaut so aus:

  1. Wir entwerfen einen Test, der zunächst fehlschlagen sollte.
  2. Wir schreiben gerade soviel Code, dass der Test tatsächlich fehlschlägt.
  3. 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-IkonRoter JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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?


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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-IkonRoter JUnit-Balken

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.

Preiskurve

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".

Tipp
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.

Tipp
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-IkonRoter JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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.


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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-IkonRoter JUnit-Balken

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-IkonRoter JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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".


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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-IkonRoter JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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-IkonRoter JUnit-Balken

junit.framework.AssertionFailedError:
  expected:<14.0> but was:<null>
at EuroTest.testMultiplying(EuroTest.java:34)

Die Lösung dazu ist einfach.


JUnit-IkonGrüner JUnit-Balken

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.


JUnit-IkonGrüner JUnit-Balken

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.

Tipp
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

Frühes und
häufiges Testen

OBJEKTspektrum 3/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.