Anton Afanasjew
Wenige Aspekte der Software-Entwicklung sind so umstritten wie Modultests, auch Unit-Tests genannt. Während Lehrbücher sie als unabdingbaren Schritt jeder professionellen Software-Entwicklung anpreisen, genießen sie unter den meisten Entwicklern eher einen schlechten Ruf. Modultests werden oft mit Langeweile, totem Code und unnötigem Aufwand assoziiert. Gleichzeitig schwärmen Theoretiker von den Vorteilen, die ein vernünftiges Modultestkonzept mit sich bringt.
Wer hat nun Recht? Sind Modultests das Allheilmittel gegen alle Qualitätsprobleme in der entwickelten Software? Oder werden sie in Zeiten von Agilität maßlos überschätzt? Dieses Buch setzt sich zum Ziel, die gängigen Vorurteile gegen Modultests aufzubrechen und auch die übertriebenen Erwartungen an diese Technik etwas zu erden. Wir wollen zeigen: Die Vorteile von Modultests liegen nicht unbedingt da, wo sie oft vermutet werden. Sie sind aber nichtsdestotrotz immens. Nicht weil Modultests absolute Qualität garantieren. Sondern weil sie den Entwicklern Zeit sparen und Änderungen am Code vereinfachen.
Auf der anderen Seite wollen wir auch Fälle anschauen, bei denen Modultests eher kontraproduktiv sein können. Denn es muss klar sein: Falsch eingesetzte Modultests bringen unnötigen Entwicklung- und Wartungsaufwand mit sich, ohne einen echten Mehrwert zu bieten.
Es gibt viele Bücher, die beschreiben, wie Modultests funktionieren. Sie schildern meist die Details diverser Modultest-Frameworks und ihrer Komponenten, erörtern die Nutzung von Mocking-Tools, erklären Capturing-Mechanismen und listen alle möglichen Arten von Zusicherungen auf. In diesem Buch wollen wir den Fokus auf etwas anderes legen. Wir werden praktische Beispiele aus dem Entwickler-Alltag betrachten, uns auf konkrete Probleme der Programmierer konzentrieren und Lösungen in Form von Modultest-Rezepten anbieten. Ein Modultest-Framework, wie z.B. Javas JUnit, wird sich innerhalb von kurzer Zeit schon weiterentwickelt haben. Ein Buch, das akribisch seine Funktionen durchgeht, wird in einem Jahr nicht das Papier wert sein, auf dem es geschrieben wurde. Grundlegende Konzepte, die wir hier erörtern wollen, bleiben jedoch auch nach Jahrzehnten gültig. Natürlich werden wir für unsere Beispiele real existierende Test-Frameworks nutzen. Allerdings werden wir nicht zu sehr in ihre Details einsteigen. Dafür ist eine Online-Referenz viel geeigneter.
Schließlich werden wir zeigen, dass es nicht genug ist, Modultests als einen isolierten Aspekt der Softwareentwicklung zu betrachten. Damit sie ihre größte Wirkung entfalten können, bedingen Modultests ein vernünftiges Design des zu testenden Quellcodes. Die Testbarkeit als Qualitätskriterium unserer Software wird deswegen eines der Themen dieses Buches sein.
Lassen Sie uns zuerst mit den gängigen Mythen über Modultests aufräumen. Im Folgenden listen wir einige typische Irrglauben über den Sinn und Zweck dieser Testtechnik auf. Dabei ordnen wir Modultests auf dem Spektrum zwischen dem Allheilmittel gegen alle Qualitätsproblemen und der reinen Zeitverschwendung ein. Wir verwenden typische Aussagen von Entwicklern, um jeden Myth anschaulich zu machen, und fangen mit dem folgenden Satz an:
Mein Feature ist implementiert, aber ich brauche noch Zeit, um Unit-Tests zu schreiben.
Mit dieser Aussage impliziert die Entwicklerin, dass sie erst dann anfängt, Modultests zu entwickeln, wenn der zu testende Quellcode bereits fertig vorliegt. Leider ist es eine gängige Praxis, Modultests als einen Nachbearbeitungsschritt an eine Programmieraufgabe anzuhängen. Dieser Schritt wird mit einer langweiligen Pflichtaufgabe assoziiert, die nun mal erledigt werden muss, um die Testabdeckung nicht zu sehr in den Keller zu treiben. Wir werden zeigen, dass bei dieser Vorgehensweise der größte Vorteil von Modultests außer Kraft gesetzt wird: Die Zeitersparnis beim Programmieren. Als einen Alternativansatz betrachten wir die testgetriebene Entwicklung, bei der Tests vor der eigentlichen Programmierung erstellt werden. Was sich zuerst kontraintuitiv anhört, wird sich dabei als ein natürlicher und sinnvoller Prozess entpuppen.
Eine Behauptung, die man in einem Softwareentwicklungsprozess oft hört, klingt so:
Mein Code ist fehlerfrei. Er ist zu 100% von Unit-Tests abgedeckt
Der selbstbewusste Entwickler überschätzt hier die Macht von Modultests. Denn ein Modultest heißt so, weil er ein einziges Modul testet, einen Baustein von vielen. Ein Programm kann aus Dutzenden oder aber auch aus Tausenden solcher Bausteine bestehen. Unser Entwickler mag jeden einzelnen von ihnen mit Modultests abgedeckt haben. Allerdings hängen die Bausteine nicht lose in der Luft, sondern kommunizieren miteinander. Sie rufen einander auf, tauschen Nachrichten aus usw. Die Beziehungen zwischen den Modulen gehören genauso zum Programm wie die Bausteine selbst, sie können ebenfalls fehlerhaft sein. Modultests beschäftigen sich aber per Definition nicht mit solchen Beziehungen.
Betrachten wir ein kleines Programm, das aus der Gesamtliste der Firmenmitarbeiter alle Entwickler herauszieht und diese nach dem Einstellungsdatum sortiert. Das Programm besteht aus zwei Bausteinen, oder Modulen. Das erste Modul ist eine Filter-Funktion auf die Liste der Mitarbeiter. Das zweite Modul ist der Sortieralgorythmus. Man könnte nun mit zwei Modultests validieren, dass beide Bausteine korrekt funktionieren. Es ist aber nicht die Aufgabe eines Modultests, nachzuweisen, dass die gefilterten Mitarbeiter in dem Format an den Sortieralgorythmus übergeben werden, welches dieses erwartet. Es könnte zum Beispiel sein, dass der Sortieralgorythmus Zeichenketten als Eingabe erwartet, während die Filterfunktion eine Liste von komplexen Mitarbeiter-Strukturen zurückgibt. Je nach Programmiersprache kommt es bei der Integration der beiden Komponenten entweder beim Bauen oder zur Laufzeit zu einem Fehler im Programm, den Modultests nicht erkennen können. Um diese Beziehung zwischen den beiden Modulen zu testen, ist ein Integrationtest notwendig. Ein solcher Integrationtest ist viel komplexer und aufwändiger als ein Modultest. Er testet einen ganzen Anwendungsfall eines Programms und ist entsprechend viel schwieriger zu implementieren und zu pflegen.
Auch wenn Modultests 100% des Codes abdecken, ist das nicht genug, um die korrekte Funktionalität des Programms nachzuweisen. Das Ganze einer Funktionalität ist dabei buchstäblich mehr als die Summe seiner Teile.
Angesichts dieser Tatsache könnte eine Entwicklerin zu der folgenden Erkenntnis gelangen:
Ich brauche keine Unit-Tests. Mein Code wird von Integrationstests validiert.
Wenn Sie jetzt eine Widerlegung dieser Aussage erwarten, müssen wir Sie enttäuschen. Die Entwicklerin hat Recht. Wie im vorherigen Beispiel gezeigt, stoßen Modultests schnell auf ihre Grenzen. Erst ein Integrationstest, der alle Module einer Funktionalität umfasst, kann nachweisen, dass diese korrekt ist. Eine hundertprozentige Integrationstestabdeckung gibt uns die beste Garantie, dass unser Programm korrekt funktioniert. In diesem Fall sind Modultests nicht erforderlich.
Warum brauchen wir dann Modultests überhaupt? Ganz einfach: Weil das von der Entwicklerin dargestellte Szenario unpraktikabel ist. Integrationstests operieren auf kompletten Funktionalitäten, die aus dutzenden Modulen bestehen können. Der Code jedes dieser Module kann seinerseits aus mehreren Verzweigungen und Schleifen bestehen. Nehmen wir konservativ an, das wir es mit fünf Modulen zu tun haben, von denen jedes zwei verschachtelte if/else-Abfragen implementiert. Das bedeutet vier mögliche Ausführungspfade pro Modul. Für eine vollständige Abdeckung müssten wir also vier hoch fünf, also 1024 Integrationstests entwickeln.
Da ein solcher Aufwand nicht zu rechtfertigen ist, staffelt man die Tests entsprechend ihrem Aufwand. Modultests werden dafür verwendet, einzelne Module möglichst ausgiebig zu testen. (In unserem Fall wären es vier Testfälle pro Modul, also zwanzig insgesamt.) Der Integrationstest seinerseits testet bloß grob die Zusammenarbeit der einzelnen Module, ohne auf ihre internen Details einzugehen. Ein-zwei Testfälle sind dafür ausreichend. Im Vergleich zu der vollen Testabdeckung durch Integrationtests hält sich der Aufwand in Grenzen.
Es gibt weitere praktische Vorteile von Modultests gegenüber Integrationstests. Sie sind leichtgewichtig und portabel. Sie sind sehr schnell in der Ausführung. Sie haben keine Abhängigkeiten. Sie können viel früher entwickelt werden, auch dann, wenn die Gesamtfunktionalität noch nicht komplett formuliert ist. Und sie weisen bei Problemen genauer auf die Fehler hin, während fehlgeschlagene Integrationstests sehr vage sind. Vergleiche zum Beispiel die Aussage eines Integrationstestfehlers "Bei der Berechnung der monatlichen Kosten ist ein Fehler aufgetreten" mit der Fehlermeldung eines Modultests "Die Funktion average() lieferte den Wert 4.0001 zurück, erwartet wurde aber der Wert 4.0." Die letztere Meldung erlaubt es der Entwicklerin viel einfacher, den eigentlichen Fehler zu identifizieren und zu beheben.
Dieser Abschnitt soll die Erstellung eines Modultests demonstrieren. Wir programmieren dazu eine kleine Java-Methode und versehen sie mit dem nötigen Testcode. Wir wollen dabei zeigen, warum ein testgetriebener Ansatz sinnvoll ist, bei dem wir den Test vor der Implementierung der Methode erstellen.
Bei dem klassischen Ansatz würde der Entwickler eine Programmieraufgabe vermutlich in der folgenden Reihenfolge angehen:
Was sich zuerst intuitiv und einfach anhört, fällt allerdings in der Praxis etwas schwieriger aus. Der echte Lebenszyklus der Programmieraufgabe wird demnach ungefähr so aussehen:
Zwei Punkte sind hier von Bedeutung.
Erstens können wir davon ausgehen, dass die manuelle Ausführung des Programms zwecks Überprüfung seiner Korrektheit eine langweilige und zeitaufwändige Aufgabe ist, die wir mehrmals während des Entwicklungszyklus wiederholen müssen. Stellen Sie sich eine Anforderung vor, bei der wir die Gesamtsumme der monatlich ausgezahlten Gehälter in einer großen Java Enterprise-Anwendung implementieren sollen. Nach jeder Änderung des Quellcodes würden wir während der Implementierung den Code bauen, Bibliotheken erstellen, sie in dem Anwendungsserver deployen, den Server starten und dann manuell die geänderte View im Browser aufmachen, um die tatsächlichen Ergebnisse mit den erwarteten Ergebnissen zu vergleichen. In der Praxis dauert ein solcher Prozess bis zu einer halben Stunde. Eine mühsame und zeitaufwändige Arbeit.
Zweitens lässt sich feststellen, dass wir als Programmierer eine Vorstellung davon haben müssen, welche Ausgaben zu welchen Eingaben wir von dem Programm erwarten, bevor wir es implementieren. In dem obigen Beispiel wissen wir, dass wir das Programm die Kosten der einzelnen Posten aufsummieren lassen müssen, um zu einem korrekten Ergebnis zu kommen. Sehr wahrscheinlich formulieren wir im Kopf ein Beispiel für den Zusammenhang der Eingaben und der entsprechenden Ausgaben, wie zum Beispiel: Wenn ich drei Posten mit den Beträgen 2,50€, 30,00€ und 4,75€ habe, dann erwarte ich als Ergebnis 37,25€ von meinem Programm. Alternativ können wir die tatsächlichen Posten in dem Testsystem anschauen und die erwarteten Ergebnisse daraus ableiten.
Diese beiden Punkte sprechen für den testgetriebenen Entwicklungsansatz. Wenn wir den Modultest noch vor der Entwicklung der Funktionalität erstellen, wird er uns die wiederholte manuelle Ausführung unseres Programms ersparen. Außerdem bietet so ein Test einen Rahmen, in dem sich der Entwickler während seiner Arbeit sicher bewegen kann. Am Anfang schlägt so ein Modultest natürlich fehl, da das dazugehörige Programm noch nicht vorhanden ist. Der Entwickler kann dann so lange an diesem Programm arbeiten, bis der Test erfolgreich durchläuft. Damit hat er eine giwisse Sicherheit, dass das Programm seinen Vorstellungen entspricht und keine negativen Nebeneffekte aufweist.
Der Lebenszyklus der Programmieraufgabe sieht bei dem testgetriebenen Ansatz folgendermaßen aus:
Wie man sieht, ist der testgetriebene Entwicklungsansatz gar nicht so kontraintuitiv, wie es uns vielleicht am Anfang erschienen ist. Im Gegensatz: Er trägt zu einem strukturierten Vorgehen beim Programmieren bei und spart den Entwicklern viel Zeit, vor allem bei iterativen-inkrementellen Programmieraufgaben.
Nun fragt sich der Leser vielleicht, wie so ein Modultest denn aussieht und was er genau macht. Lassen sie uns dazu ein Beispiel anschauen. Dafür brauchen wir keine Unit-Test-Frameworks und keine besonderen Kenntnisse. In seiner reinsten Form ist ein Modultest nicht viel mehr als ein kleines Programm.
Nehmen wir zum Beispiel die folgende Anforderung:
"Programmiere eine Funktion, die den Abstand zwischen zwei Punkten auf einer Fläche berechnet."
Als Kennerin der funktionalen Domäne weiß die Entwicklerin, dass Punkte auf einer Fläche durch Paare von Fließkommazahlen dargestellt werden. Sie erstellt also die folgende Schnittstelle ihrer Funktion:
interface DistanceCalculator {
float distance(float x1, float y1, float x2, float y2);
}
Die in der Schnittstelle deklarierte Methode erwartet also vier Fließkommazahlen als Argumente. Diese repräsentieren die X- und die Y-Koordinaten der beiden Punkte auf einer Fläche. Das Ergebnis der Funktion ist eine weitere Fließkommazahl, die der Distanz zwischen den beiden Punkten entsprechen soll.
Als Nächstes bereitet die Entwicklerin eine Klasse vor, welche die Schnittstelle implementiert. Diese Klasse sieht so aus:
class DistanceCalculatorImpl implements DistanceCalculator {
public float distance(float x1, float y1, float x2, float y2) {
return 0.0f;
}
}
Die Methode distance()
liefert in diesem Zustand immer den Wert 0.0f
zurück, sie ist noch nicht implementiert. Bevor die Entwicklerin sie ausformuliert, überlegt sie sich die erwarteten Ausgaben zu allen möglichen Eingaben. Sie identifiziert vier verschiedene Testfälle:
Im ersten Fall ist der Abstand zwischen den zwei Punkten gleich dem Betrag der Differenz zwischen den beiden X-Werten. Im zweiten Fall ist es der Betrag der Differenz zwischen den Y-Werten. Im dritten Fall ist das Ergebnis die Länge der Hypotenuse eines rechtwinkligen Dreiecks, dessen Katheten durch die Abstände der X- und der Y-Werte definiert sind. Im vierten Fall beträgt der Abstand natürlich 0.
Die Entwicklerin schreibt als nächstes ein kleines Programm, das eine Implementierung des Interfaces DistanceCalculator verwendet und ihre Methode distance() mit bestimmten Argumenten aufruft. im letzten Schritt des Programms vergleicht sie das gelieferte Ergebnis mit dem im Kopf berechneten Wert. Das macht sie für alle vier Fälle.
Sehen wir uns das Testprogramm an.
public class TestDistanceCalculator {
public static final void(String[] args) {
testSameLine();
testSameColumn();
testDifferentLineAndColumn();
testSamePosition();
}
private void testSameLine() {
= new DistanceCalculatorImpl();
DistanceCalculator distanceCalculator float result = distanceCalculator.distance(5.0f, 10.0f, 7.0f, 10.0f);
if (result == 2.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
}
private void testSameColumn() {
= new DistanceCalculatorImpl();
DistanceCalculator distanceCalculator float result = distanceCalculator.distance(5.0f, 10.0f, 5.0f, 13.0f);
if (result == 3.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
}
private void testDifferentLineAndColumn() {
= new DistanceCalculatorImpl();
DistanceCalculator distanceCalculator float result = distanceCalculator.distance(5.0f, 10.0f, 8.0f, 14.0f);
if (result == 5.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
}
private void testSamePosition() {
= new DistanceCalculatorImpl();
DistanceCalculator distanceCalculator float result = distanceCalculator.distance(5.0f, 10.0f, 5.0f, 10.0f);
if (result == 0.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
}
}
Der Modultest ist in diesem Fall nichts anderes als ein normales Java-Programm, das über die statische main-Methode aufgerufen wird. In dieser Methode rufen wir nacheinander die vier Testmethoden auf.
Jede Testmethode fängt gleich an — mit der Initialisierung eines Objekts der zu testenden Klasse DistanceCalculatorImpl und der Ausführung ihrer Methode distance().
Die erste Testmethode behandelt den Fall, in dem beide Punkte auf einer horizontalen Geraden liegen, ihre Y-Werte also gleich sind. Die Entwicklerin hat hier die Punkte 5/10 und 7/10 ausgewählt. Sie weiß, dass der Abstand zwischen den zwei Punkten 2 sein muss. Also vergleicht sie das Ergebnis der Methodenausführung mit dem Wert 2.0f. Entspricht der tatsächliche Wert dem erwarteten, gibt das Programm das Wort "Success" aus. Andernfalls wird "Failure" ausgegeben. Dasselbe macht sie für die anderen Testmethoden.
Unser Modultest wird zu diesem Zeitpunkt sofort fehlschlagen (indem er "Failure" ausgibt), da die Methode, die getestet werden soll, noch nicht implementiert ist. Diese kann nun als Nächstes entwickelt werden. Der Modultest sorgt dabei für die Orientierung. Läuft er erfolgreich durch, hat man eine gewisse Sicherheit, dass die Implementierung der Distanzmessung korrekt ist.
Natürlich kann man unseren provisorischen Modultest noch verbessern. So könnte die Fehlerbeschreibung detaillierter sein. Auch sollte der Test nicht abbrechen, wenn eine der ersten Testmethoden fehlschlägt, sondern in jedem Fall alle Tests ausführen. Genau da kommen Modultest-Frameworks wie JUnit ins Spiel. Sie vereinfachen die Entwicklung der Modultests, indem sie den Programmierern viele Hilfsmittel zur Verfügung stellen.
Wie wir an dem obigen Beispiel gesehen haben, sind Modultests kein Hexenwerk, sondern eine bequeme Art, unsere Programmfunktionen mit bestimmten Parametern aufzurufen, um sicherzustellen, dass sie erwartete Ergebnisse liefern.
Wenn man sich in einen Aspekt der Softwareentwicklung einarbeitet, kann es hilfreich sein, sich mit dem Vokabular vertraut zu machen, das um diesen Aspekt entstanden ist. Die Kenntnis wichtiger Begriffe erleichtert nicht nur das Verständnis der neuen Konzepte, sondern vereinfacht auch die Kommunikation über das entsprechende Fachthema. Wir schauen in diesem Abschnitt auf die gängigen Begriffe zum Thema Modultests. Da Englisch bei Softwareentwicklung die vorherrschende Sprache ist, werden wir zu jedem Begriff die englische Entsprechung angeben.
Das Konzept der Überprüfung von Softwarequalität durch eine Stichprobe. Bei einem Test wird ein Stück Software mit vordefinierten Parametern ausgeführt und das Ergebnis gegen einen Referenzwert verglichen.
Der Begriff Test wird umgangssprachlich oft für drei verschiedenen Konzepte verwendet: Testfall, Testskript und Testausführung. Wir betrachten diese drei Begriffe nun etwas genauer.
Ein Testfall ist eine abstrakt definierte Behauptung über das Verhalten der Software. Diese Behauptung wird von den Anforderungen an die Software abgeleitet und definiert das richtige Verhalten in einem speziellen Fall.
Nehmen wir an, dass wir eine Funktion in einem Abonnement-Programm implementieren müssen, für welche die folgende Anforderung definiert ist:
Zeige an die Gesamtkosten aller abgeschlossenen Abonnements für den User im aktuellen Monat.
Der Testfall ist die Beschreibung eines konkreten Falls während der Ausführung der Funktion, der auf der obigen Anforderung basiert. Es könnte so aussehen:
Der User A hat drei Abonnements für den aktuellen Monat abgeschlossen: X, Y und Z. Das Abonnement X kostet 15,99€, das Abonnement Y 9,99€ und das Abonnement Z 5,99€. Rufe die Funktion
monthlyCosts
für den User A auf und überprüfe, dass das Ergebnis der Summe der drei Beträge entspricht, also 31,97€.
Während die Anforderung das gewünschte Verhalten und die erwarteten Ergebnisse für alle möglichen Eingaben definiert, beschreibt ein Testfall ein konkretes Ergebnis, das zu den entsprechenden konkreten Eingaben gehört.
Testskripte sind Umsetzungen der Testfälle im Code. Sie werden oft in derselben Sprache implementiert, wie die zu testende Anwendung selbst. In Java werden Testskripte (eigentlich Testklassen) meistens mit Hilfe des Frameworks JUnit umgesetzt.
Während Testfälle abstrakte Beschreibungen sind, erlauben Testskripte die Automatisierung der Testausführung. Unser Modultest-Beispiel weiter oben kann als ein Testskript bezeichnet werden.
Wie der Name schon sagt, handelt es sich bei der Testausführung um einen Testlauf, der erfolgreich durchlaufen oder fehlschlagen kann. Ein und derselbe Testskript kann nach zwei Ausführungen verschiedene Ergebnisse liefern, wenn der zu testende Code zwischen den Ausführungen verändert wird.
Zusicherungen sind Eigenschaften der zu testenden Software, die wir in Testfällen voraussetzen. Ihre Einhaltung entscheidet darüber, ob die Testausführung als erfolgreich oder als fehlgeschlagen gelten wird. Wenn wir zum Beispiel eine Funktion int summe(int a, int b)
testen und diese in unserem Testskript mit int result = summe(5, 7)
aufrufen, dann können wir nach der Ausführung der Funktion die Zusicherung result == 12
aufstellen. Sollte das Ergebnis von der Erwartung abweichen, würde die Testausführung fehlschlagen.
Moderne Softwareanwendungen bestehen aus tausenden von Modulen, die ständig weiterentwickelt werden. Jede Änderung am Code, jede Erweiterung kann jedoch ein Problem in der bereits vorhandenen Funktionalität verursachen. Man spricht in solchen Fällen von Regressionen. Einer der größten Vorteile von automatisierten Tests ist ihre Fähigkeit, Regressionen sehr schnell sichtbar zu machen.
Modultests sollen portabel und leichtgewichtig sein. Abhängigkeit des Testskripts von externen Diensten, wie Datenbankmanagementsystemen, dem Dateisystem oder Web-Diensten, stellen dabei ein Problem dar. Mocks (es wird in der Praxis überwiegend der englische Begriff verwendet) sind dafür da, solche Abhängigkeiten durch leichtgewichtige Nachahmungen zu ersetzen.
Mocks können mit normalen Programmiermitteln erstellt werden, zum Beispiel indem man Abhängigkeiten über Interfaces an den zu testenden Code anbindet und die Implementierung umschreibt. Alternativ existieren diverse Mock-Frameworks, die das Verhalten von Abhängigkeiten zur Laufzeit überschreiben können.
Sehen wir uns den Modultest aus dem Beispiel weiter oben etwas genauer an. Er besteht, wie jeder Test, aus drei Teilen: Vorbereitung, Ausführung und Validierung. Es ist wichtig, diese drei Phasen voneinander zu trennen.
Die Vorbereitungsphase dient dazu, eine Instanz des zu testenden Moduls vorzubereiten. In unserem Fall ist das eine leichte Aufgabe - wir müssen bloß ein Objekt vom Typ DistanceCalculatorImpl
instantiieren.
DistanceCalculator distanceCalculator = new DistanceCalculatorImpl();
Bei komplexeren Fällen könnten wir in dieser Phase auch die zu übergebenden Argumente vorbereiten und bei zustandbehafteten Modulen einen Initialzustand definieren.
Die Ausführungsphase ist die einfachste Phase im Modultest. Sie enthält bloß den Aufruf der zu testenden Logik und die Speicherung des Ergebnisses in einer Variable.
float result = distanceCalculator.distance(5.0f, 10.0f, 7.0f, 10.0f);
Die Validierungsphase überprüft schließlich, ob das gelieferte Ergebnis den Erwartungen des Entwicklers entspricht.
if (result == 2.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
Warum sollten die Phasen getrennt werden? Weil die Vorbereitungs- und die Ausführungsphase sich zwischen den verschiedenen Testfällen gar nicht bis sehr wenig unterscheiden — sie können wiederverwendet werden. In unserem Fall könnte man zum Beispiel die Initialisierung des Testobjekts aus den Testmethoden herausnehmen und sie in den Konstruktor der Testklasse verlegen.
Die Aufteilung der Phasen kommen auch der Lesbarkeit des Testskripts zugute.
Modultests sollen möglichst leichtgewichtig und portabel sein. Diese Eigenschaften sind mitunter schwer zu erreichen. Der Aufwand für die Erstellung von leichtgewichtigen und portablen Testskripten wird reduziert, wenn der zu testende Code gut isoliert, das heißt von allen Abhängigkeiten, wie dem Datenbankmanagementsystem, dem Dateisystem und anderen externen Diensten, abgekoppelt ist. Schnittstellen (Interfaces) sind ein geeignetes Mittel, um eine lose Kopplung zwischen den Modulen und ihren Abhängigkeiten zu erreichen.
Stellen wir uns ein Modul vor, das die Mehrwertsteuer eines Artikels aus seinem Netto-Preis berechnen und den berechneten Betrag in die Datenbank speichern soll. Die Anforderung an seine Implementierung sieht folgendermaßen aus:
Basierend auf der Anforderung können wir das Interface unserer neuen Funktion folgendermaßen definieren:
interface VatCalculator {
boolean calculateAndStoreVat(String articleNo, float price, float vatRate);
}
Wir gehen davon aus, dass der Mehrwertsteuersatz von 16% uns in der Form 0.16f
geliefert wird. Gelingt der Speichervorgang des ermittelten Wertes, erwarten wir den Wert true
als Ergebnis der Methodenausführung, ansonsten false
.
Eine naive Implementierung dieses Interfaces könnte wie folgt aussehen:
class VatCalculatorImpl implements VatCalculator {
public boolean calculateAndStoreVat(String articleNo, float price, float vatRate) {
float vat = calculateVat(price, vatRate);
storeVat(articleNo, vat);
}
private float calculateVat(float price, float vatRate) {
...
}
private void storeVat(String articleNo, float vat) {
...
}
}
Der Einfachheit halber lassen wir die Implementierung der beiden privaten Methoden calculateVat
und storeVat
außer Acht. Es kommt uns vor Allem auf die Kombination der beiden Methodenaufrufe in der öffentlichen Methode calculateAndStoreVat
.
Wollten wir diese Methode mit einem Modultest versehen, stünden wir vor einigen Schwierigkeiten. Denn: Die Methode storeVat
baut vermutlich die Verbindung zu einer Datenbank oder einem anderen Medium auf, um die berechnete Mehrwertsteuer zu persistieren. Das können wir bei der Ausführung des Modultests nicht zulassen, schließlich soll dieser leichtgewichtig und unabhängig von irgendwelchen externen Diensten sein.
Eine bessere Implementierung würde die beiden Schritte Berechnung und Persistierung in zwei verschiedene Module aufteilen. Dann könnten wir für den Rechenalgorythmus sehr einfach einen Modultest implementieren, während wir die Persistierung der Werte mit anderen Mitteln testen würden, zum Beispiel über einen Integrationstest.
Wie wir an diesem Beispiel sehen, ist das Design der zu testenden Software entscheidend für den erfolgreichen Einsatz von Modultests. Daher widmen wir ein ganzes Kapitel der Testabilität unserer Software.
In diesem Kapitel stellen wir gängige Praktiken vor, die von erfahrenen Softwareentwicklern bei der Erstellung von Modultests angewandt werden. Wir gehen der Frage nach, wie man die zu testenden Module überhaupt identifiziert, wie man Testfälle definiert, wie man eine Test-Suite einrichtet und in welcher Granularität man die Ergebnisse validiert. Wir schauen uns an, wie Massentests durchgeführt werden können, wenn wir eine große Tabelle mit Eingabewerten und den dazugehörigen Ergebnissen als Referenz haben. Anschließend behandeln wir den Umgang mit volatilen Daten, also solchen, die sich mit jedem Testlauf verändern können. Ein weiteres Thema ist die Simulation von Abhängigkeiten. Wir werden zeigen, wie externe Dienste (z.B. Datenbankmanagementsystem, Dateisystem) durch entsprechende leichtgewichtige Nachbildungen, sogenannte Mocks, ersetzt werden können.
Modultests führen Code aus und vergleichen gelieferte Ergebnisse mit Referenzwerden. Das ist nicht möglich, wenn der ausgeführte Code nichts zurückgibt, wie z.B. die Methodevoid doSomething()
. Wir werden eine Möglichkeit vorstellen, solchen Code trotzdem testen zu können. Ein ähnliches Problem besteht bei Code, der Fehlermeldungen zurückgibt, oft über sogenannte Ausnahmen (auch als Exceptions bekannt). Wir betrachten Testfälle, die Ausnahmen kontrolliert provozieren, und validieren, dass diese tatsächlich bei der Ausführung auftreten.
Als Nächstes widmen wir uns der Validierung von Ergebnissen und listen die bewährten Praktiken für diesen Schritt auf. Wir legen insbesondere Wert auf fehlgeschlagene Tests und stellen Überlegungen ein, wie wir Testfehleranalysen durch gute Fehlerbeschreibungen erleichtern können.
Ein Grund für die Unbelibtheit von Modultests ist der Umstand, dass viele Entwickler Code mit Modultests abdecken, der dafür nicht geeignet ist und daher viel Aufwand beim Testen kostet und wenig Mehrwert bringt. Im letzten Abschnitt dieses Kapitels lernen wir, solchen Code zu erkennen, um ihn in Modultests zu ignorieren.
Modultests beschäftigen sich, wie ihre Bezeichnung nahe legt, mit Modulen. Jeder Modultest stellt sicher, dass das zugehörige Software-Modul erwartungsgemäß funktioniert. Was ist aber ein Modul? Wie groß darf es sein? Wo sind die Abgrenzungen zu anderen Modulen? Und welche Module sind am besten für Modultests geeignet? In diesem Abschnitt gehen wir diesen Fragen nach.
Betrachten wir dazu eine Beispielanwendung.
Es handelt sich um einen Foto-Druck-Dienst. Benutzer registrieren sich in einer Webanwendung und melden sich dort an. Sie hinterlassen im System ihre Zahlungsdaten sowie die Versandadresse und laden eine beliebige Anzahl von Bildern hoch. Bilder können in Alben gruppiert, mit Metadaten versehen und nach verschiedenen Kriterien gefiltert und sortiert werden. Benutzer haben die Möglichkeit, einzelne Bilder auszuwählen und die Auswahl zu einem Druckauftrag hinzuzufügen. Druckaufträge sammeln sich in einem Warenkorb, der an der Kasse bestellt werden kann. Der Preis hängt von der gewünschten Größe und Qualität der zu druckenden Bilder ab. Davon wird der Rabatt abgezogen, der von der Menge der bisherigen Bestellungen abhängt. Kunden mit einem bisherigen Bestellwert von über 100 € bekommen 10 % Rabatt. Ab 500€ gibt es einen Rabatt von 20 %. Schließlich wird der Warenkorb bestellt, wobei die Zahlung abgewickelt und der Druck- und Versandprozess in die Wege geleitet wird.
Diese relativ komplexe Anwendung kann man im ersten Schritt auf zwei Arten in kleinere Komponenten zerlegen.
Die erste Möglichkeit besteht darin, dass man technische Bauteile der Anwendung auseinander nimmt, sogenannte Schichten. So erhalten wir Komponenten wie Persistenzschicht, Geschäftslogikschicht und User-Interface-Schicht. Diese Aufteilung nennt man auch die horizontale Aufteilung.
Die zweite Art des Schnitts, die vertikale Aufteilung, entsteht, wenn die Anwendung nach Funktionen zerteilt wird. In unserem Fall könnten so Komponenten wie Registrierung, Bildupload, Albumverwaltung, Warenkorb und Bestellung entstehen.
In der klassischen Schule der Software-Entwicklung war die horizontale Aufteilung die bevorzugte Wahl. Große Anwendungsserver definierten die technischen Schichten, in die Entwickler mehrere Komponenten integrierten. Es entstanden dabei stabile, monolytische Anwendungen.
Heutzutage gilt dieser Ansatz als überholt. In der Zeit der agilen Entwicklung und des immer höheren Bedarfs an Flexibilität werden die Anwendungen mehr und mehr nach Funktionen aufgeteilt. Microservices sind eine Ausprägung dieses Ansatzes.
Was bedeutet das für unsere Suche nach Modulen, die unabhängig voneinander getestet werden können? Nun, als erstes lässt sich feststellen, dass die bei der horizontalen und der vertikalen Aufteilung entstehenden Komponenten immer noch viel zu groß sind, um als Module (im Kontext von Modultests) betrachtet zu werden. Trotzdem können sie hilfreich sein, und zwar als Grundlage für eine weitere Aufteilung. Auch interessant: Einige Komponentenarten sind für Modultests eher weniger geeignet. Durch den Ausschluss dieser Komponenten aus der Modultestabdeckung können wir uns viel Arbeit ersparen. Es empfiehlt sich also, eine Matrix aufzustellen, die aus funktionalen und technischen Komponenten besteht.
Für die oben beschriebene Anwendung können wir folgende Komponenten identifizieren:
Funktion/Schicht |
|||
---|---|---|---|
Registrierung |
User-Interface |
Geschäftslogik |
Persistierung |
Anmeldung |
User-Interface |
Geschäftslogik |
Persistierung |
Profilverwaltung |
User-Interface |
Geschäftslogik |
Persistierung |
Bilder-Upload |
User-Interface |
Geschäftslogik |
Persistierung |
Bilderverwaltung |
User-Interface |
Geschäftslogik |
Persistierung |
Warenkorbverwaltung |
User-Interface |
Geschäftslogik |
Persistierung |
Kasse |
User-Interface |
Geschäftslogik |
Persistierung |
Rabattberechnung |
User-Interface |
Geschäftslogik |
Persistierung |
Bestellung |
User-Interface |
Geschäftslogik |
Persistierung |
Jede der Funktionen besteht aus technischen Schichten User-Interface, Geschäftslogik und Persistenz. Betrachten wir sie etwas genauer im Hinblick auf ihre Eignung für Modultests.
Die User-Interface-Schicht (auch Präsentationsschicht genannt) besteht hauptsächlich aus grafischen Eingabe- und Ausgabe-Elementen. Sie enthält Formulare, Buttons, Fenster und Tabellen. Manche User-Interfaces sind eigenständige Anwendungen, sogenannte Single-Page-Applications, andere werden auf dem Server vorbereitet und zum Browser in HTML-Form geschickt. In jedem Fall wird ihre Funktionalität hauptsächlich von bestimmten Ereignissen ausgelöst. Ein Ereignis könnte das Abschicken eines Formulars oder die Auswahl eines Wertes in einer Auswahlbox sein. Ereignisse werden also meist von Benutzerinnen und Benutzern produziert, das User-Interface reagiert darauf.
Es gibt durchaus Fälle, bei denen Modultests in der User-Interface-Schicht Sinn ergeben, allerdings empfehlen wir diese Schicht nicht als erste Wahl für die Modultestabdeckung. Dies hat drei Gründe.
Erstens liegt es in der Natur von User-Interfaces, dass sie visuelle Komponenten sind -- und somit einer gewissen Ästhetik unterordnet. Modultests sind weniger dazu geeignet, leicht verschobene Felder, seltsame Farben und falsch umgebrochene Texte zu erkennen.
Zweitens sind Komponenten der User-Interface-Schicht meist volatiler als ihre Backend-Counterparts. Grafische Oberflächen werden für moderne Bildschirmtypen optimiert, neue Stylesheets werden angewandt, Layouts verändern sich.. Das alles führt zu einem stark erhöhten Aufwand bei der Entwicklung und Pflege von Modultests.
Drittens haben wir es in dieser Schicht mit einer Abhängigkeit zu tun, die sich nur schwer von der Funktionalität der User-Interface-Komponenten abkoppeln lässt: dem Menschen. Im Gegensatz zu Backend-Komponenten, die mit ihrer Außenwelt über Parameter und Rückgabewerte kommunizieren, sind Komponenten der User-Interface-Schicht auf die Eingaben von Benutzern angewiesen. Die Korrektheit ihrer Ergebnisse liegt wiederum wortwörtlich im Auge der Betrachterin. Um User-Interface-Komponenten vollständig mit Modultests abdecken zu können, müssten diese Tests Menschen "mocken", und das ist eine schwierige Angelegenheit.
Allerdings sind vor allem moderne Single-Page-Applications weit mehr als Empfänger für Eingaben und schöne Oberflächen. Sie können Module enthalten, die mit Gewinn von Modultests abgedeckt werden können. Dazu gehören Sortier- und Filteralgorythmen, arythmetische Berechnungen, Entscheidungsbäume und Datenformatkonvertierer.
Die Geschäftslogik-Schicht ist oft am besten von ihren Abhängigkeiten isoliert und daher gut für Modultests geeignet. In dieser Schicht finden wir Dienste (Services) vor, die Geschäftsprozesse implementieren, ohne sich mit der Präsentation oder der Persistenz von Daten herumschlagen zu müssen. Eingaben und Ausgaben von Modulen dieser Schicht werden über wohldefinierte Parameter und Rückgabetypen definiert, zustandbehaftete Elemente sind an Objekteigenschaften erkennbar.
Eine möglichst hohe Testabdeckung der Module in dieser Schicht ist empfehlenswert, da Modultests hier den größten Mehrwert relativ zum nötigen Implementierungsaufwand vorweisen können. Als Module bezeichnen wir dabei die kleinsten Einheiten (units) mit einem öffentlichen Interface. Das sind in objektorientierten Programmiersprachen, wie Java oder C#, Klassen mit ihren Konstruktoren und öffentlichen Methoden. In prozeduralen Sprachen, wie C, sind es öffentliche Funktionen und Prozeduren.
Betrachten wir die Geschäftslogik-Schicht der Funktio Rabattberechnung in unserer Beispielanwendung, die in der Programmiersprache Java entwickelt wurde. Wir finden dabei folgende Source-Code-Struktur vor:
Als objektorientierte Programmiersprache nutzt Java Klassen als Quellcodeeinheiten. Diese werden in Paketen gruppiert. In unserem Beispiel befindet sich das Hauptpaket photoprintservice
auf der obersten Ebene des Programms. Es enthält weitere Pakete, welche die funktionalen Komponenten des Programms darstellen; es handelt sich also um eine vertikale Aufteilung. Neben all den anderen funktionalen Komponenten (die wir in der Auflistung auslassen) finden wir hier die Komponente zur Rabattberechnung; diese liegt in dem Paket discounts
. Dieses enthält wiederum Pakete, welche die Funktion in technische Schichten aufteilen. Uns interessiert vor allem die Geschäftslogikschicht, deren Quellcode sich in dem Paket businesslogic
befindet.
In dem businesslogic
Paket finden wir drei weitere Pakete: model
, services
und converters
. Diese enthalten einzelne Klassen und Interfaces. Lassen wir die Interfaces außen vor, sie enthalten keine Programmierlogik, die ausgeführt und getestet werden könnte. Anders verhält es sich bei Klassen, die Methoden mit ausführbarem Code enthalten. Jede dieser Klassen kann als ein Modul im Sinne des Modultests betrachtet werden. Es ist eine gängige Praxis, einen Testskript pro Klasse zu implementieren.
Sollen wir also jede einzelne Klasse mit einem Modultestskript versehen? Für die Geschäftslogikschicht lautet die Daumenregel: Je mehr Testabdeckung, desto besser. Allerdings sollte man auch hier ein gutes Augenmaß anwenden. Modellklassen sind zum Beispiel meist sehr einfach aufgebaut. Sie bestehen hauptsächlich aus Eigenschaften, wie Name, Nachname und Geburtsjahr, und den dazugehörigen Setter- und Getter-Methoden, mit denen man die Eigenschaften mit Werten versehen bzw. diese Werte auslesen kann. Ein Modultest würde zwar auch bei solchen Klassen zur Gesamtqualität beitragen, aber bei Weitem nicht so viel wie der Modultest einer komplexen Berechnungsmethode. Anders verhält es sich allerdings, wenn Modellklassen komplexe Methoden zu Darstellung, Vergleich oder Aufbau ihrer Daten enthalten. In Java sind es typischerweise die Methoden toString
, equals
und hashcode
sowie Konstruktoren. Sind diese komplex genug, sollten sie unbedingt im Fokus von Modultests stehen.
Bei Services ist die Sache klar. Diese implementieren wichtige Geschäftsprozesse und sollten im Allgemeinen von Modultests abgedeckt werden. Die Klasse DiscountCalculatorImpl
ist zum Beispiel für die Berechnung der Rabatte zuständig. Für das Geschäft ist es essenziell, dass diese in allen Fällen korrekt funktioniert. Modultests sind ein ausgezeichnetes Mittel, um die Korrektheit der Berechnung zu validieren. Rundungsfehler, Sonderfälle wie die Division durch Null und andere Ungereimtheiten können so frühzeitig und mit einem relativ niedrigen Aufwand abgefangen werden. Je komplexer der Quellcode im Service ist, desto detaillierter sollten die entsprechenden Modultests sein.
Widmen wir uns nun den Konvertierungsklassen. Diese sind dazu da, um Daten von einem Format in ein anderes zu übertragen. Es ist in der Praxis üblich, dass Eingaben und Ausgaben in der User-Interface-Schicht in einem Format gehalten werden, das die grafische Oberfläche repräsentiert. Gibt man beispielsweise ein Datum ein, könnte das User-Interface-Modell dazu ein Objekt mit drei Eigenschaften sein: Jahr, Monat und Tag. Diese Eigenschaften würden den drei Eingabefeldern im User-Interface entsprechen. Das Modell in der Geschäftslogik-Schicht auf der anderen Seite benötigt diese Aufteilung des Datums in drei Felder nicht. Es würde vermutlich eine einzige Eigenschaft namens Datum besitzen, welche das komplette Datum enthielte. Bei der Übergabe von Daten zwischen der User-Interface-Schicht und der Geschäftslogikschicht müssen die Daten in das entsprechende Format konvertiert werden. Dafür sind die Konvertierungsklassen zuständig.
Auf der einen Seite ist die Programmlogik der Konvertierungsklassen sehr einfach. Die meisten Eigenschaften der Modelle werden eins-zu-eins abgebildet, komplexe Konvertierungsalgorythmen sind selten. Auf der anderen Seite tendieren solche Klassen dazu, sehr groß zu werden. Enthalten die Modelle nämlich hunderte von Eigenschaften, müssen genau so viele Konvertierungsanweisungen programmiert werden. Die Aufgabe ist monoton und lästig. Sie verleitet Programmierer zu Copy-and-Paste-Salven, bei denen Fehler wortwörtlich vorprogrammiert sind. Diese Art von Fehlern ist schwer zu entdecken und kann später zu katastrophalen Fehlfunktionen im Gesamtprogramm führen, die lange Analysen und Fehlersuchen nach sich ziehen.
Können sie den Fehler im nachfolgenden Code erkennen?
convert(UiModelUser uiUser) {
BusinessModelUser = new BusinessModelUser();
BusinessModelUser bmUser .setFirstName(uiUser.getFirstName());
bmUser.setLastName(uiUser.getFirstName());
bmUser.setAddress(bmUser.getAddress());
bmUser}
Es gibt nämlich zwei davon. Erstens haben wir den Nachnamen des Nutzers im Geschäftslogikmodell fälschlicherweise auf den Vornamen des Nutzers aus dem User-Interface-Modell gesetzt. Zweitens haben wir die Adresse des Zielnutzers auf seine eigene Adresse gesetzt. Irgendwann wird dieser zweite Fehler zu einer üblen NullPointerException führen, deren Ursprung extrem schwer nachzuvollziehen sein wird.
Versehen sie daher alle Konvertierungsmethoden unbedingt mit Modultests.
Die Persistenzschicht kümmert sich um die Kommunikation der Anwendung mit dem Datenbankmanagementsystem. So können Daten in die Datenbank geschrieben und von dort ausgelesen werden. In objektorientierten Programmiersprachen besteht diese Schicht hauptsächlich aus Datenzugriffsklassen (DAO, Data Access Objects). Die Programmlogik dieser Klassen kann je nach der gewählten Technologie sehr einfach oder sehr komplex sein. Im Prinzip handelt es sich dabei um reine Konvertierungslogik, denn die Hauptaufgabe der Persistenzschicht ist die Abbildung des objektorientierten Datenmodells aus der Geschäftslogikschicht auf das relationale (oder sonstige) Datenbankmodell, und umgekehrt. In der Praxis werden zwei grundverschiedene Methoden angewendet, um die Persistenzschicht zu realisieren.
Die klassische Methode sieht vor, dass der Code der Datenzugriffsklassen den SQL-Code (oder analogen Code für nicht relationale Datenbanken) zum Auslesen und Schreiben von Daten generiert. Dieser Ziel-Code wird als Nächstes über eine Datenbankverbindung dem Datenbankmanagementsystem übermittelt, das es schließlich ausführt. Ein großer Nachteil dieser Methode ist die dafür notwendige mühsame und kaum lesbare Manipulation von Zeichenketten. Im folgenden Beispiel zeigen wir, wie eine Java-DAO-Klasse einen Nutzer in der Datenbank speichert.
public void saveUser(User user, Connection connection) throws SQLException {
String sql = "INSERT INTO users (name, email, password) " +
"VALUES ('" + user.getName() + "', '" + user.getEmail() + "', '" + user.getPassword() + "')";
Statement statement = connection.createStatement();
.executeUpdate(sql);
statement}
Wie man an den vielen Textverknüpfungen erkennen kann, verleitet diese Art des Datenbankzugriffs zu Fehlern, die schwer zu identifizieren sind. Daher empfehlen wir es in diesem Fall, die resultierende SQL-Anweisung über einen Modultest zu validieren. Das ist aber nicht so einfach. Erstens müssten wir dafür die Datenbankverbindung mocken. Zweitens ist die generierte SQL-Anweisung ein internes Detail der Methode; sie wird nicht nach außen veröffentlicht.
Wenn sie also diese Art des Datenbankzugriffs nicht vermeiden können, empfehlen wir seine Aufteilung auf zwei Module. Das erste Modul soll für die Generierung der SQL-Anweisung zuständig sein, das zweite für ihre Ausführung. Das würde in unserem Fall so aussehen:
public String saveUserSql(User user) {
String sql = "INSERT INTO users (name, email, password) " +
"VALUES ('" + user.getName() + "', '" + user.getEmail() + "', '" + user.getPassword() + "')";
return sql;
}
public void saveUser(userSql, Connection connection) throws SQLException {
Statement statement = connection.createStatement();
.executeUpdate(userSql);
statement}
Jetzt können sie die Generierung der SQL-Anweisung in der Methode saveUserSql
ohne viel Aufwand mit einem Modultest abdecken, während sie die saveUser
-Methode getrost ignorieren sollten.
Die zweite, und eine viel empfehlenswertere Möglichkeit, die Persistenzschicht zu implementieren, ist die Nutzung eines ORM-Frameworks. ORM (Object Relational Mapping) ist eine Technik, welche die automatisierte Abbildung von Klassen auf Datenbanktabellen erlaubt — und umgekehrt. Dafür wird die Konvertierungslogik deklarativ beschrieben (über Konfigurationsdateien oder Code-Annotationen), das ORM-Framework übernimmt dann den Rest. Ein populäres ORM-Framework für Java ist Hibernate. Ein anderes Framework, spring-boot-data, geht sogar soweit, dass es die Implementierung der ORM-Logik zur Laufzeit aus Methodennamen und Annotationen ableitet.
Wollen wir, wie in dem obigen Beispiel, eine Nutzerin in der Datenbank persistieren, können wir dafür in Java unter Verwendung des Spring-Boot-Frameworks folgenden Code implementieren:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
//Getter, Setter
}
public interface UserRepository extends CrudRepository<User, Long> {
}
Die Annotationen über den Elementen der Modellklasse beschreiben hier die gewünschte Abbildungslogik, wie den Namen der Datenbanktabelle und die Nutzung der Eigenschaft id als Primärschlüssel. Das Interface des DAO erweitert das Interface CrudRepository
(CRUD = Create, Read, Update, Delete). Haben Sie gemerkt, dass wir keine Klasse angegeben haben, die dieses Interface implementiert? Das liegt daran, dass es unnötig ist. Das Framework generiert die Implementierung zur Laufzeit basierend auf Konventionen und Annotationen.
Da ORM-basierte Datenbankzugriffe fast keinen ausführbaren Code enthalten, gibt es hier auf den ersten Blick nichts zu tun für Modultests. Schließlich würde der einzig sinnvolle Test eine Datenbank verlangen, was eine Hürde für die Portabilität und die Unabhängigkeit von Modultests darstellte. Trotzdem ist es möglich, das korrekte Speichern einer Nutzerin mit einem Modultest zu validieren. Dafür benötigen wir ein Framework, dass ein komplettes Datenbankmanagementsystem simuliert und im Arbeitsspeicher des Testausführungskontexts hält. Wir werden diese Methode im entsprechenden Rezept im Detail vorstellen.
Wie oben beschrieben, besteht jeder Modultest aus drei Schritten: Vorbereitung der Eingaben, Ausführung des zu testenden Codes mit diesen Eingaben und Validierung der produzierten Ausgaben. Für einen erfolgreichen Test ist es wichtig zu verstehen, welche Arten von Eingaben und Ausgaben der zu testende Code enthält. Dieser Frage widmen wir uns jetzt am Beispiel eines Quellcode-Ausschnitts. Wir betrachten dazu die folgende Java-Klasse:
import java.io.IOException;
import java.util.List;
public class DiscountCalculatorImpl implements DiscountCalculator {
public static float SMALL_DISCOUNT_THRESHOLD = 100f;
public static float BIG_DISCOUNT_THRESHOLD = 200f;
private UserDao userDao;
private float smallDiscountRate;
private float bigDiscountRate;
private float totalDiscount;
public DiscountCalculatorImpl(float smallDiscountRate, float bigDiscountRate) {
this.smallDiscountRate = smallDiscountRate;
this.bigDiscountRate = bigDiscountRate;
}
@Override
public float discount(String customerNo, Order order) throws IOException {
float discountRate;
= userDao.loadUser(customerNo);
User user List<Order> orderHistory = user.getOrderHistory();
float totalOrderedAmount = (float)orderHistory
.stream()
.mapToDouble(Order::getPrice)
.sum();
if (totalOrderedAmount >= BIG_DISCOUNT_THRESHOLD) {
= bigDiscountRate;
discountRate } else if (totalOrderedAmount >= SMALL_DISCOUNT_THRESHOLD) {
= smallDiscountRate;
discountRate } else {
= 0.0f;
discountRate }
long currentTime = System.currentTimeMillis();
.setOrderTime(currentTime);
orderif (discountRate != 0.0f) {
.setLastDiscountTime(currentTime);
user.storeUser(user);
userDao}
return order.getPrice() * discountRate;
}
public UserDao getUserDao() {
return userDao;
}
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public float getTotalDiscount() {
return totalDiscount;
}
public void setTotalDiscount(float totalDiscount) {
this.totalDiscount = totalDiscount;
}
}
Eins vorweg: Es handelt sich bei dieser Klasse um kein gutes Softwaredesign. Sie ist mit Absicht so entworfen, dass der Quellcode möglichst viele Arten von Ein- und Ausgaben enthält. Dies ist keine gute Praxis, dient uns in diesem Beispiel aber zur Veranschaulichung der Kommunikation dieser Klasse mit der Außenwelt. Schauen wir zuerst die Struktur und das Verhalten des Quellcodes an.
Wir haben es hier mit der Klasse DiscountCalculatorImpl
zu tun, die das Interface DiscountCalculator
implementiert. Offenbar handelt es sich dabei um die Umsetzung der Rabattberechnung aus der Anforderung weiter oben.
Die Klasse deklariert zwei öffentliche Klassenvariablen SMALL_DISCOUNT_THRESHOLD
und BIG_DISCOUNT_THRESHOLD
. Als statische Variablen sind diese nicht an Objektinstanzen der Klasse gebunden, sondern an die Klasse selbst. Sie sind dazu da, um zu definieren, ab welchem Bestellwert welche Rabatte vergeben werden sollen.
Als nächstes wird die Instanzvariable userDao
deklariert. Sie ist privat, kann allerdings mit Hilfe von Getter- und Setter-Methoden gelesen und gesetzt werden. Über dieses Objekt kann unsere Klasse mit der Datenbank kommunizieren. (Wir erinnern uns: DAO = Data Access Object.)
Die nächsten zwei Instanzvariablen smallDiscountRate
und bigDiscountRate
sollen die Raten für den kleinen und den großen Rabatt enthalten. Es sind Fließkommawerte; ein Rabatt von 10% wird damit als 0.1 repräsentiert. Diese Variablen können nicht über Getter- und Setter-Methoden ausgelesen und gesetzt werden. Stattdessen werden sie über Konstruktorparameter bei der Instanziierung der Klasse einmalig gesetzt.
Die letzte Instanzvariable -- totalDiscount
-- führt Buch über vergebene Rabatte. Sie enthält die Gesamtsumme aller Beträge, um welche die gezahlten Preise von Bestellungen reduziert wurden. Diese Variable kann von außen über ihre Getter-Methode ausgelesen werden. Eine Setter-Methode hat sie allerdings nicht.
Kommen wir nun zum Inhalt der discount
-Methode. Wir sehen, dass diese zwei Parameter deklariert: customerNo
vom Typ String und order
vom Typ Order. Als Rückgabetyp deklariert sie float. Es wird also eine Kundennummer und eine Bestellung erwartet, aus denen der Rabattwert berechnet und zurückgegeben wird. Zusätzlich deklariert die Methode eine IOException
. Ausnahmen (exceptions) sind in Java ein Mittel, um potenzielle Fehler zu behandeln, die bei der Ausführung des Programms auftreten können. Eine IOException
wird dann deklariert, wenn das Programm Daten auf externe Medien (Datenbank, Festplatte) schreibt oder diese daraus ausliest. Sie wird geworfen, wenn ein Lese- oder ein Schreibvorgang fehlschlägt (z.B. aufgrund unerreichbarer Datenbank, Speichermangel auf der Festplatte oder Ähnlichem).
Der Algorithmus der Methode legt als erstes die Variable discountRate
an, ohne ihr gleich einen Wert zuzuweisen. Danach wird der der Kundennummer entsprechende Nutzer aus der Datenbank geladen. Das Programm holt dann aus dem user
-Objekt seine Bestellunghistorie als eine Liste von Order-Objekten und summiert ihre Beträge auf. Das Ergebnis -- der Wert der Variable totalOrderedAmount
-- wird als Nächstes gegen die Werte der beiden Mindestbestellwertschwellen verglichen. Je nach der Größe des Gesamtbestellwertes wird der große oder der kleine Rabatt ausgewählt -- oder gar keiner.
Das an die Methode übergebene Order-Objekt enthält eine Instanzvariable orderTime
. Diese Variable wird nun über eine Setter-Methode auf den Wert der aktueller Zeit gesetzt, den wir über System.getCurrentTimeMillis()
vom System erfragen.
Das Objekt user
, dessen Daten wir von der Datenbank geladen haben, enthält eine Instanzvariable lastDiscountTime
. Diese speichert die Zeit des letzten Rabatts für den Nutzer. Falls die berechnete Rabattrate über 0% liegt, wird diese Variable ebenfalls auf den Wert der aktuellen Zeit gesetzt und das veränderte User
-Objekt in der Datenbank gespeichert.
Als letztes wird der Rabattwert berechnet, indem die Rabattrate mit dem Bestellpreis multipliziert wird. Diesen Wert gibt die Methode an den Aufrufer zurück.
Wenn wir einen Modultest für die vorliegende Klasse entwerfen wollen, müssen wir nun alle Kanäle identifizieren, über die diese mit der Außenwelt kommuniziert. Schließlich müssen wir vor der Ausführung der discount
-Methode im Modultest die Eingaben vorbereiten und danach die Ausgaben validieren. Schauen wir uns zuerst die Eingabetypen an. Es gibt mehr von ihnen, als auf den ersten Blick scheint.
customerNo
) und die Bestellung (order
) werden von dem Aufrufer an die Methode discount
übergeben.smallDiscountRate
und bigDiscountRate
festgelegt und im Zustand des Objektes festgeschrieben.SMALL_DISCOUNT_THRESHOLD
und BIG_DISCOUNT_THRESHOLD
sind öffentlich und können somit von dem Aufrufer (oder von sonst wem) beliebig gesetzt werden. Dazu wird nicht mal der Zugriff auf die Instanz der Klasse benötigt, da es sich um Klassenvariablen handelt.userDao
, die eine Referenz auf den Code enthält, welcher die Kommunikation mit der Datenbank abwickelt. Da der Typ UserDao
ein Interface ist, kann die Implementierung beliebig ausgetauscht werden, indem der Wert dieser Variable über die Setter-Methode auf die Instanz einer Klasse gesetzt wird, die das Interface implementiert. Wir werden später zeigen, wie wir dieses Prinzip ausnutzen können, um irrelevante Code-Teile in Modultests auszuschalten.discount
-Methode. Dasselbe gilt für alle Datenquellen, die innerhalb einer Methode abgefragt werden können: Dateien, REST-APIs, Web-Dienste, FTP-Anbindungen usw.discount
-Methode nutzt den Ausdruck System.getCurrentTimeMillis()
, um die aktuelle Systemzeit auszulesen. Damit ist sie von den Einstellungen des Betriebssystems und der Java-Laufzeitumgebung abhängig. Diese Umgebung ist also ebenfalls eine Eingabe. Zu den bekanntesten Elementen einer Systemumgebung zählen Umgebungsvariablen, Systemproperties, Zeitzone, Sprache und Region sowie Hardware- und Betriebssystem, auf denen die Anwendung ausgeführt wird.Es kann noch weitere, weniger offensichtliche Eingabearten geben: generierten Code (z.B. der Ansatz der modellgetriebenen Softwareentwicklung), Instrumentalisierung (z.B. lombok, AOP), Reflection (z.B. über Annotations und Namenskonventionen) usw. Wir fokussieren uns in diesem Buch allerdings auf die klassischeren Eingabearten.
Wir empfehlen es, die hier vorgestellten Eingabearten zu verinnerlichen und sie bei der Erstellung der Modultests stets im Kopf zu behalten. Denn nur wenn die Basis eines Modultests ordentlich vorbereitet ist, kann man von einem robusten und reproduzierbaren Test ausgehen. Wir wollen diesen Hinweis mit einigen Beispielen festigen, bei denen ein nächtlicher Build wegen eines fehlgeschlagenen Modultests zusammenbrechen kann, weil man einige Eingaben als gegeben hinnahm und nicht kontrolliert vorbereitete.
Wie bereits erwähnt, ist die hier vorgestellte Methode absichtlich so entworfen, um schlechte Testbarkeit aufzuzeigen. Im Kapitel Testbarkeit-Rezepte werden wir zeigen, wie wir die Testbarkeit unserer Module erhöhen können, indem wir ihre Eingaben einschränken.
Sind alle Eingaben vorbereitet, kann der Modultest ausgeführt und die produzierten Ausgaben validiert werden. Dabei müssen wir uns fragen, welche Arten von Ausgaben wir vor uns haben. Auch hier zeigt es sich, dass es mehr sind, als uns auf den ersten Blick erscheinen mag.
discount
. Darüber wird der berechnete Wert zurückgegeben.totalDiscount
verändert unsere Methode den Zustand der zu testenden Objektinstanz. Auch wenn der Aufrufer diese Veränderung nicht direkt mitbekommt, kann sie doch Auswirkungen auf die weitere Programmlogik haben und soll ebenfalls validiert werden. Übrigens könnte ein schlecht entworfener Modultest nach seiner Ausführung jede beliebige öffentliche oder über eine Setter-Methode erreichbare Klassen- oder Instanzvariable von allen Klassen im Klassenpfad verändert haben. Eine sehr unerwünschte Ausgabeart.IOException
geworfen werden. Wir haben diese Möglichkeit in der Methodensignatur deklariert. Eine geworfene Ausnahme wäre also kein Fehlverhalten unserer Methode, sondern eine wohldefinierte Ausgabe im unerwarteten Problemfall.IOException
ist schon zur Entwicklungszeit bekannt, dass die Ausführung des Codes fehlschlagen könnte. Schließlich ist die Kommunikation mit externen Medien immer unsicher. Darüber hinaus können zur Laufzeit des Programms Fehler auftreten, die man nicht antizipiert hatte, wie zum Beispiel bei einer Division durch 0, oder beim Zugriff auf Eigenschaften einer null-Referenz. Solche Fälle führen zu Laufzeitausnahmen (runtime exceptions). In unserem Fall würde eine solche Ausnahme geworfen werden, wenn wir der Eigenschaft userDao
den Wert null zuwiesen. Beim Versuch die Nutzerin zu laden, würde unser Programm dann mit einer NullPointerException
abbrechen.order
ist auf den ersten Blick eine Eingabe. Allerdings wird hier nur eine Referenz auf ein Objekt vom Typ Order
übergeben, über welche das Objekt selbst modifiziert werden kann. Das tun wir tatsächlich, indem wir die Objekteigenschaft orderTime
auf die aktuelle Zeit setzten. Abgesehen davon, dass diese Art der Modifikation nicht empfehlenswert ist, sollten wir also die Zustandsveränderung des Objekts order
als eine Ausgabe betrachten, die im Modultest validiert werden sollte.Darüber hinaus könnte man das Laufzeitverhalten der ausgeführten Methode ebenfalls als eine Art Ausgabe betrachten. Speicher- und CPU-verbrauch, Dauer der Ausführung (performance), Programmabstürze und belegte Verbindungen zu externen Ressourcen zählten zu dieser Kategorie. Allerdings beschäftigen sich Modultests eher selten mit dieser Art Ausgaben.
Stehen Sie vor der Aufgabe, ein Modul mit einem Test abzusichern, gehen Sie im ersten Schritt wie folgt vor:
Im letzten Abschnitt beschäftigten wir uns mit Ein- und Ausgabearten des zu testenden Quellcodes. In diesem Abschnitt zeigen wir, wie wir sinnvolle Testfälle definieren. Dafür müssen wir uns zuerst mit dem generellen Aufbau von prozeduralen Programmen auseinander setzen. Die folgenden Absätze mögen der erfahrenen Programmiererin trivial erscheinen; sie bilden allerdings das Fundament für das Verständnis des Konzepts der Testabdeckung, welches eine Schlüssellrolle bei der Erstellung von Testfällen spielt.
In prozeduralen und objektorientierten Programmiersprachen besteht der Quellcode von Funktionen/Prozeduren/Methoden aus verschachtelten Ausdrücken (expressions) und Anweisungen (statements).
Ausdrücke sind Werte, die entweder einfach (2
, "Hello World!"
, myVariable
, true
) oder zusammengesetzt (2 * 2
, myVariable == true
, a > b
,"Hello " + " World!"
) sind.
Anweisungen sind Befehle an eine programmierbare Maschine, die auf Ausdrücken operieren können. Beispiele für einfache Anweisungen sind print(2)
, return true
und String s = "Hello " + " World!"
. Anweisungen können ebenfalls zusammengesetzt sein. Typische Beispiele für zusammengesetzte Anweisungen sind Blöcke, Abfragen und Schleifen.
Ein Block ist eine Liste von Anweisungen. In vielen Programmiersprachen werden Blöcke definiert, indem die Anweisungen darin von geschweiften Klammern umgeben werden. Ein Block ist selbst eine Anweisung. Daraus resultiert, dass Blöcke verschachtelt werden können.
{
print(10);
print(20);
{
print(31);
print(32);
}
}
Eine Abfrage ist eine Anweisung, die an eine Bedingung geknüpft ist, z. B. if (a > 5) print(a);
. In diesem Beispiel ist print(a)
eine einfache Anweisung. Sie wird nur dann ausgeführt, wenn die Bedingung a > 5
erfüllt ist. Die Bedingung ist ein Ausdruck vom booleschen Typ, sie kann also immer zu true
oder false
zusammengefasst werden. Eine Abfrage ist selbst eine Anweisung, daher können Abfragen verschachtelt werden.
if (a > 5)
if (a < 10)
print(a);
Genauso können Abfragen mit Blöcken kombiniert werden. Im folgenden Beispiel nutzen wir eine Blockanweisung.
if (a > 5 && b > 5) {
print(a);
print(b);
}
Die meisten Programmiersprachen erlauben es, in Abfragen Anweisungen zu definieren, die ausgeführt werden, wenn die Bedingung nicht erfüllt wird. Das ergibt sogenannte if/else-Abfragen.
if (a > 5)
print(a);
else
= 5; a
Schleifen sind dazu da, um andere Anweisungen mehrmals ausführen zu können. In diesem Buch konzentrieren wir uns auf die einfachste Form von Schleifen, die sogenannte while-Schleife.
while (a > 5)
= a - 1; a
Hier wird die Anweisung a = a - 1;
vom Programm so lange ausgeführt, bis die Bedingung a > 5
nicht mehr erfüllt ist.
Schleifen sind wie Blöcke und Abfragen selbst Anweisungen. Diese drei Strukturen können also beliebig kombiniert werden. Betrachten wir dazu den Quellcode der folgenden Methode:
public boolean primeNumber(int number) {
boolean prime = true;
int denominator = number - 1;
while (denominator > 1) {
if (number % denominator == 0) {
= false;
prime = 1;
denominator } else {
= denominator - 1;
denominator }
}
return prime;
}
Die Methode prüft für eine Ganzzahl, ob diese eine Primzahl ist, also durch keine andere Zahl außer sich selbst und 1 geteilt werden kann, ohne dass ein Rest dabei entsteht.
Der Inhalt der Methode ist eine Blockanweisung, die ihrerseits aus vier Anweisungen besteht: zwei Variablendeklarationen (mit Wertzuweisungen), einer while-Schleife und einer return-Anweisung.
Die while-Schleife definiert eine Anweisung, die ausgeführt werden soll, solange die Bedingung denominator > 1
erfüllt ist. Diese Anweisung ist wiederum eine Abfrage. Sie definiert eine Bedingung (number % denominator == 0
, das Ergebnis der Division darf also keinen Rest enthalten), eine Blockanweisung, die im Falle der erfüllten Bedingung ausgeführt werden soll, und eine einfache Anweisung, die ausgeführt wird, falls die Bedingung nicht erfüllt ist.
Obwohl der Quellcode unserer Methode Verzweigungen und Schleifen definiert, ist die Ausführungsreihenfolge der Anweisungen streng linear -- diese ist allerdings von den konkreten Eingaben abhängig, in unserem Fall von dem Argument des Parameters number
. Nehmen wir an, dass wir die Methode mit dem Argument 6 aufrufen. In diesem Fall wäre die Anweisungsreihenfolge die folgende:
boolean prime = true;
int denominator = number - 1;
= denominator - 1;
denominator = denominator - 1;
denominator = false;
prime = 1;
denominator return prime;
Diese Reihenfolge resultiert aus der Auswertung des Kontrollflusses in Schleifen und Abfragen. Sie können sie nachvollziehen, indem Sie den sogenannten Schreibtischtest machen. Notieren Sie dazu die aktuellen Werte aller Variablen und Parameter auf einem Zettel und gehen Sie durch den Code der Methode, wobei Sie alle ausgeführten Anweisungen aufschreiben und die Werte der Variablen aktualisieren. Alternativ können Sie einen Debugger dafür verwenden.
Würden wir dieselbe Methode mit dem Wert 3 aufrufen, hätten wir bei der Ausführung die folgende Anweisungsreihenfolge:
boolean prime = true;
int denominator = number - 1;
= denominator - 1;
denominator return prime;
Solche Anweisungsreihenfolgen werden Ausführungspfade genannt. Ein idealer Modultest würde alle möglichen Pfade des zu testenden Quellcodes durchlaufen und die entsprechenden Ausgaben validieren. In der Praxis ist das schon deshalb nicht möglich, da Schleifen solche Pfade beliebig verlängern können. Ein Pfad mit einem einzigen Schleifendurchlauf unterscheidet sich von einem mit zwei Schleifendurchläufen und das lasst sich beliebig weiterführen. Wenn wir allerdings einen einzigen Schleifendurchlauf und mehrere Schleifendurchläufe als gleiche Pfadabschnitte definieren, dann können wir die Anzahl aller möglichen Ausführungspfade in unserer Methode tatsächlich auflisten. Dafür müssen wir die Ergebnisse unserer zwei verschachtelten Bedingungen denominator > 1
und number % denominator == 0
kombinieren. Daraus entstehen insgesamt drei Ausführungspfade:
denominator > 1
trifft nicht zu. Der Pfad führt um die Schleife herum. Dieser Pfad entsteht beispielsweise, wenn das Argument des Parameters number
gleich 2 ist.denominator > 1
trifft zu, und number % denominator == 0
trifft nicht zu. Der Pfad betritt die Schleife und führt dann durch den else-Teil der inneren Abfrage. Das Argument 3 führt zu einem solchen Pfad.denominator > 1
trifft zu, und number % denominator == 0
trifft ebenfalls zu. Der Pfad betritt die Schleife und führt dann durch den if-Teil der inneren Abfrage. Das Argument 4 führt zu einem solchen Pfad.Man beachte, dass in unserem Beispiel jeder Pfad, der den if-Teil der Abfrage durchläuft, zuvor den else-Teil durchgelaufen haben muss. Damit haben wir mit dem dritten Pfad auch den zweiten Pfad mit abgedeckt. Wir haben also insgesamt zwei verschiedene Ausführungspfade, welche durch die Eingaben 2 und 4 produziert werden können. Damit ergeben sich folgende zwei Testfälle:
In Javas Modultest-Framework JUnit sähe der entsprechende Testskript folgendermaßen aus:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class PrimeNumberCalculatorTest {
@Test
void testPrimeNumber() {
boolean primeNumber;
= new PrimeNumberCalculator().primeNumber(2);
primeNumber assertTrue(primeNumber);
= new PrimeNumberCalculator().primeNumber(4);
primeNumber assertFalse(primeNumber);
}
}
In dem obigen Beispiel ist die vollständige Testabdeckung aller Pfade relativ leicht zu erreichen. In der Praxis hat man es allerdings oft mit komplexeren Quellcode-Strukturen zu tun, so dass die Mengen aller möglichen Ausführungspfade schnell unüberschaubar werden. Man spricht dann von hoher Zyklomatischer Komplexität des Codes. Refactoring kann Abhilfe schaffen, aber viele Berechnungsprobleme sind inhärent so komplex, dass eine vollständige Pfadabdeckung inpraktikabel ist. Ein Beispiel dazu ist der folgende Code:
public String employeeNumber(
String lastName,
String department,
String position,
String gender) {
String lastNameAbbreviation;
if (lastName.length() <= 2) {
= lastName;
lastNameAbbreviation } else {
= lastName.substring(0, 2);
lastNameAbbreviation }
String departmentAbbreviation;
if (department.equals("Development")) {
= "DEV";
departmentAbbreviation } else if (department.equals("Sales")) {
= "SLS";
departmentAbbreviation } else if (department.equals("Research")) {
= "RES";
departmentAbbreviation } else {
= "OTH";
departmentAbbreviation }
String positionAbbreviation;
if (position.equals("Manager")) {
= "MGR";
positionAbbreviation } else if (position.equals("Board Member")) {
= "BRD";
positionAbbreviation } else if (position.equals("Intern")) {
= "INT";
positionAbbreviation } else if (position.equals("External")) {
= "EXT";
positionAbbreviation } else {
= "EMP";
positionAbbreviation }
String genderAbbreviation;
if (gender.equals("Male")) {
= "m";
genderAbbreviation } else if (gender.equals("Female")) {
= "f";
genderAbbreviation } else {
= "d";
genderAbbreviation }
return
+ "." +
lastNameAbbreviation + "." +
departmentAbbreviation + "." +
positionAbbreviation ;
genderAbbreviation}
Die Methode employeeNumber
generiert Mitarbeiter-Ids. Sie verwendet dafür Argumente der Parameter lastName
, department
, position
und gender
. Diese werden unabhängig voneinander ausgewertet, woraus Teile der Mitarbeiter-Id entstehen. Am Ende verbindet die Methode die berechneten Teile mit .
und gibt das Ergebnis zurück.
Wenn wir uns die Struktur der Methode anschauen, fällt uns auf, dass wir es hier mit vier Abfragen zu tun haben, die unabhängig voneinander durchlaufen werden können. Die erste Abfrage ist eine Verzweigung mit zwei mögliche Ausgängen. (Der Nachname ist maximal zwei Zeichen lang oder er ist länger.) Die zweite Abfrage hat gleich vier mögliche Ausgänge, die dritte fünf und die vierte drei.
Da die Abfragen unabhängig voneinander sind, existieren für diese Methode 2 * 4 * 5 * 3 = 120
verschiedene Ausführungspfade -- zu viele, um sie alle mit Tests abzudecken.
Wir müssen uns also mit einer weniger dichten Testabdeckung zufrieden geben. In einem solchen Fall empfehlen wir es, sich auf die Abdeckung aller Zweige zu fokussieren (branch coverage). Bei dieser Vorgehensweise decken wir nach wie vor alle Verzweigungen, die bei Abfragen entstehen mit Tests ab, allerdings kombinieren wir im Gegensatz zur Pfadabdeckung nicht alle möglichen Verzweigungen aus verschiedenen Abfragen miteinander.
In unserem Fall brauchen wir für eine vollständige Abdeckung aller Zweige maximal 2 + 4 + 5 + 3 = 14
Testfälle. Der entsprechende JUnit-Testskript ist überschaubar:
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class EmployeeNumberTest {
@Test
void testShortLastName() {
= new EmployeeNumberGenerator();
EmployeeNumberGenerator o String employeeNumber;
= o.employeeNumber("Li", "dummy", "dummy", "dummy");
employeeNumber assertEquals("Li", employeeNumberElement(employeeNumber, 0));
= o.employeeNumber("Miller", "dummy", "dummy", "dummy");
employeeNumber assertEquals("Mi", employeeNumberElement(employeeNumber, 0));
= o.employeeNumber("dummy", "Development", "dummy", "dummy");
employeeNumber assertEquals("DEV", employeeNumberElement(employeeNumber, 1));
= o.employeeNumber("dummy", "Sales", "dummy", "dummy");
employeeNumber assertEquals("SLS", employeeNumberElement(employeeNumber, 1));
= o.employeeNumber("dummy", "Research", "dummy", "dummy");
employeeNumber assertEquals("RES", employeeNumberElement(employeeNumber, 1));
= o.employeeNumber("dummy", "Catering", "dummy", "dummy");
employeeNumber assertEquals("OTH", employeeNumberElement(employeeNumber, 1));
= o.employeeNumber("dummy", "dummy", "Manager", "dummy");
employeeNumber assertEquals("MGR", employeeNumberElement(employeeNumber, 2));
= o.employeeNumber("dummy", "dummy", "Board Member", "dummy");
employeeNumber assertEquals("BRD", employeeNumberElement(employeeNumber, 2));
= o.employeeNumber("dummy", "dummy", "Intern", "dummy");
employeeNumber assertEquals("INT", employeeNumberElement(employeeNumber, 2));
= o.employeeNumber("dummy", "dummy", "External", "dummy");
employeeNumber assertEquals("EXT", employeeNumberElement(employeeNumber, 2));
= o.employeeNumber("dummy", "dummy", "Other Employee", "dummy");
employeeNumber assertEquals("EMP", employeeNumberElement(employeeNumber, 2));
= o.employeeNumber("dummy", "dummy", "dummy", "Male");
employeeNumber assertEquals("m", employeeNumberElement(employeeNumber, 3));
= o.employeeNumber("dummy", "dummy", "dummy", "Female");
employeeNumber assertEquals("f", employeeNumberElement(employeeNumber, 3));
= o.employeeNumber("dummy", "dummy", "dummy", "Diverse");
employeeNumber assertEquals("d", employeeNumberElement(employeeNumber, 3));
}
private String employeeNumberElement(String employeeNumber, int index) {
if (employeeNumber.isEmpty()) {
throw new RuntimeException("employeeNumber is empty");
}
String[] parts = employeeNumber.split("\\.");
if (parts == null || parts.length != 4) {
throw new RuntimeException(
"The value for employeeNumber is in wrong format: " + employeeNumber
);
}
return parts[index];
}
}
Hier verwenden wir die Hilfsmethode employeeNumberElement
, um einzelne Elemente aus der generierten Mitarbeiter-Id zu extrahieren. Wir validieren mit ihrer Hilfe alle Ergebnisse, die wir erhalten, wenn wir jede einzelne Verzweigung im Code einmal durchlaufen. Da wir uns im Testskript auf die Zweigabdeckung beschränken, setzen wir die Aufrufparameter, die für den konkreten Testfall irrelevant sind, auf den Wert "dummy".
Bitte beachten Sie: Wir haben zwecks Platzeinsparung alle Testfälle in einer Testmethode zusammengefasst. In der Praxis wäre es besser, eine Testmethode pro Testfall zu entwickeln.
Bis jetzt haben wir Testabdeckung als Relation von getesteten Verzweigungen und Pfaden zu den insgesamt vorhandenen Verzweigungen und Pfaden im Quellcode definiert. Eine Pfadabdeckung von 100% bedeutet, dass wir alle möglichen Ausführungspfade getestet haben. Mit einer Zweigabdeckung von 25% geben wir an, dass unsere Testfälle ein Viertel aller möglichen Verzweigungen, die durch Abfragen und Scheifen entstehen, durchlaufen haben.
Es stellt sich allerdings dabei die Frage, ob ein Testfall, der die volle Pfadabdeckung eines Moduls sicherstellt, auch seine Korrektheit garantiert. Die Antwort lautet: Nein.
Betrachten wir als Beispiel die folgende Anforderung:
Die Betreiberin eines Webshops möchte im Rahmen einer Marktanalyse wissen, wie hoch der Anteil von Frauen unter ihrer Kundschaft ist. Implementiere eine Funktion, welche die Anzahl von Kundinnen und die Gesamtanzahl der Kundinnen und Kunden als Eingaben erhält und den gewünschten Anteil zurückgibt.
Die Implementierung der Funktion ist relativ einfach. Der Quellcode der entsprechenden Java-Methode kann folgendermaßen aussehen:
public float femaleRate(int numberOfFemaleCustomers, int numberOfCustomers) {
return (float)numberOfFemaleCustomers / (float)numberOfCustomers;
}
Die Methode wandelt die Argumente der Parameter in Fließkommazahlen um und teilt dann die Anzahl der Kundinnen durch die Gesamtanzahl von Kundinnen und Kunden. Das Ergebnis wird an den Aufrufer der Methode zurückgegeben.
Da diese Methode aus einer einzigen Anweisung besteht, ist die volle Pfadabdeckung leicht zu erreichen. Dazu reicht ein einziger Testfall, wie z. B. der folgende:
Führe die Methode mit den Argumenten 2 und 4 aus. Validiere, dass das Ergebnis 0.5 beträgt.
Dieser Testfall entspricht dem folgenden JUnit-Testskript:
@Test
void testFemaleRate() {
assertEquals(0.5f, new Statistics().femaleRate(2, 4));
}
Seine Ausführung läuft erfolgreich durch und resultiert in der huntertprozentigen Pfadabdeckung des getesteten Quellcodes.
Was passiert aber, wenn der Webshop gar keine Kundinnen oder Kunden vorzuweisen hat? Es könnte ja sein, dass die Methode direkt nach seiner Inbetriebnahme aufgerufen würde, und zwar mit den Argumenten 0
und 0
. Division durch 0 ist in der Mathematik nicht vorgesehen. Java definiert zwar für Fließkommazahlen die Auswertung des Ausdrucks 0.0f / 0.0f
, das Ergebnis dieser Auswertung ist allerdings der Wert NaN
(Not a Number). Wahrscheinlich wird die Nutzerin des Webshops damit nichts anfangen können.
Das Problem dieses Sonderfalls liegt bereits in der Definition der Anforderung, denn seine Behandlung hätte schon dort beschrieben werden sollen. Allerdings zeigt er uns, dass wir bei der Testabdeckung nicht nur auf Pfade und Verzweigungen achten müssen, sondern auch auf die Wertemengen der Eingaben.
In den allermeisten Fällen ist eine vollständige Testabdeckung der Wertemengen von Eingaben praktisch unmöglich, denn dafür müssten wir jede einzelne Zahl testen. Im Gegensatz zur Mathematik gibt es in der Programmierung zwar nicht unendlich viele davon -- aber trotzdem eine ganze Menge. Daher sind wir beim Entwurf von Testfällen auf Stichproben angewiesen, bei denen wir den Quellcode mit Eingaben aufrufen, welche ganze Werteteilmengen repräsentieren. Man spricht dabei von Äquivalenzklassen. Äquivalenzklassen sind Mengen, die folgende Eigenschaften haben müssen:
Die Aufteilung der Eingabewertemengen in Äquivalenzklassen ist oft nicht trivial. Zum einen hängt sie von der Spezifikation des Programms ab. Zum anderen können wir die Wertemengen einzelner Eingaben nicht unabhängig voneinander in Äquivalenzklassen aufteilen, sondern müssen sie kombinieren. In unserem Beispiel handelt es sich bei der Wertemenge aller Eingaben um die Menge aller möglichen Paare von Ganzzahlen.
Für die Wertemenge einer einzelnen Integer-Zahl in Java können wir folgende Äquivalenzklassen definieren:
NEG
:= {Alle negativen Zahlen}
NULL
:= {0}
POS
:= {Alle positiven Zahlen}
Für die Wertemenge des Eingabepaares können die Äquivalenzklassen dann so aussehen:
ZÄHLER INVALID
:= Die erste Zahl ist aus der Menge NEG
, die zweite ist aus der Menge POS
oder aus der Menge NULL
.NENNER INVALID
:= Die zweite Zahl ist aus der Menge NEG
, die erste ist aus der Menge POS
oder aus der Menge NULL.DEFAULT
:= Die erste Zahl ist aus der Menge POS
oder NULL
, die zweite Zahl ist aus der Menge POS
.DIVISION DURCH NULL
:= Die erste Zahl ist aus der Menge POS
oder aus der Menge NULL
, die zweite Zahl ist aus der Menge NULL
.Jetzt können wir pro Äquivalenzklasse einen Testfall erstellen, indem wir beliebige Zahlenpaare als Eingabe verwenden, die Elemente der Menge der jeweiligen Äquivalenzklasse sind. Das setzt natürlich voraus, dass die Anforderung verfeinert und das gewünschte Verhalten für alle Äquivalenzklassen definiert wird. Es entstehen folgende Testfälle:
Wir hätten die Äquivalenzklassen noch feiner definieren können. Die Eingabe, bei der die erste Zahl größer als die zweite ist, ergibt beispielsweise funktional keinen Sinn. Auch könnte die Kombination 0,0 als eine eigene Äquivalenzklasse definiert werden.
Die Definition von Äquivalenzklassen sollte zwar aus den funktionalen Anforderungen abgeleitet werden, aber es gibt auch technische Besonderheiten. die dabei eine Rolle spielen können. So sollte man für Grenzwerte eines Wertebereichs eigene Äquivalenzklassen definieren.
Als Nächstes betrachten wir gängige Datentypen und untersuchen sie auf ihre möglichen Äquivalenzklassen.
Objektreferenzen
Ein typischer Sonderwert eines Objektes ist die null-Referenz. Es lohnt sich meistens, einen Testfall zu entwerfen, der diesen Wert behandelt.
In objektorientierten Programmiersprachen wie Java können Objekte mehrere Typen vorweisen. Ein Objekt vom Typ Developer kann gleichzeitig Employee, Person und Object sein, wobei der letztere Typ in Java alle möglichen Objekte umfasst. Bekommt man als Eingabe ein Argument vom Typ einer Superklasse, könnte man bekannte Subtypen als Äquivalenzklassen definieren und einen Testfall zu jeder davon entwerfen.
Unvollständig oder falsch initialisierte Objekte sollen eine eigene Äquivalenzklasse bilden.
Zeichenketten
Auch hier ist die null-Referenz oft relevant.
Die leere Zeichenkette (""
) soll ebenfalls eine eigene Äquivalenzklasse bilden.
Kodierung, Sprache und Zeichensatz können als Grundlage für die Bildung von Äquivalenzklassen genommen werden.
Sehr lange Zeichenketten könnten in einer eigenen Äquivalenzklasse zusammengefasst werden.
Integer-Zahlen
0 ist eine eigene Äquivalenzklasse.
Für den Minimalwert und für den Maximalwert des Datentypwertebereiches gilt dasselbe.
Negative und positive Zahlen gehören in verschiedene Äquivalenzklassen.
1 und -1 sollten eigenständige Äquivalenzklassen bilden.
Fließkommazahlen
Hier gilt dasselbe, wie bei den Integer-Zahlen
Zusätzlich sollen Sonderwerte eigene Äquivalenzklassen bilden. In Java wären es NaN
, Infinity
und -Infinity
.
Zahlen mit hoher Präzision (z.B. 0.000000001) sollen in einer eigenen Äquivalenzklasse zusammengefasst werden.
Boolesche Werte
Da die Wertemenge hier mit true
und false
aus bloß zwei Elementen besteht, sollen diese auf zwei Äquivalenzklassen aufgeteilt werden.
Manche Programmiersprachen erlauben einen dritten Wert für boolesche Typen (z.B. UNDEFINED
oder auch null
). Dieser soll als Sonderwert eine eigene Äquivalenzklasse bilden.
Collections und Arrays
Auch diesen kann meistens der Wert null
zugewiesen werden. Dieser soll seine eigene Äquivalenzklasse bekommen.
Leere und nicht-leere Collections und Arrays gehören zu verschiedenen Äquivalenzklassen.
Ähnlich zu den Zeichenketten soll für sehr große Collections und Arrays eine eigene Äquivalenzklasse reserviert werden.
Je nach Spezifikation können Typen und Inhalte der Elemente einer Collection oder einem Array als Grundlage für die Aufteilung auf Äquivalenzklassen hinzugezogen werden. Das gilt insbesondere für null-Elemente.
In diesem Abschnitt haben wir den Begriff der Testabdeckung eingeführt. Testabdeckung beschreibt den Anteil von getestetem Quellcode-Verhalten in der Menge aller möglichen Verhalten.
Testabdeckung dient als Basis für die Erstellung von Testfällen. Gute Testfälle überlappen sich im Sinne der Testabdeckung nur wenig und decken den Quellcode in ihrer Gesamtheit möglichst gut ab.
Wir haben zwei verschiedene Arten von Testabdeckung kennen gelernt.
Bei der Testabdeckung von Anweisungen versucht man möglichst viele Ausführungspfade des Quellcodes zu durchlaufen (path coverage). Bei komplexen Programmen beschränkt man sich meistens auf die möglichst hohe Abdeckung aller Zweige im Quellcode (branch coverage).
Bei der Testabdeckung von Eingabewertemengen werden diese in Äquivalenzklassen eingeteilt, wobei man davon ausgeht, dass jedes Element einer Äquivalenzklasse sich ähnlich zu allen anderen Elementen derselben Äquivalenzklasse verhält. Die Einteilung hängt von technischen Eigenschaften der Eingaben, vor allem aber von der funktionalen Spezifikation des zu testenden Programms ab. Die Testabdeckung dieser Art beschreibt den Anteil der getesteten Äquivalenzklassen in der Gesamtmenge aller Äquivalenzklassen. Dabei reicht eine Stichprobe pro Äquivalenzklasse als Testfall.
In den vorherigen Abschnitten haben wir gesehen, wie wir Ein- und Ausgaben eines Moduls identifizieren und wie wir Testfälle entwerfen, die eine möglichst hohe Abdeckung des getesteten Moduls sicherstellen.
In diesem Abschnitt untersuchen wir die letzte Phase eines Modultests -- die Validierung der produzierten Ergebnisse. Diese Phase ist sehr wichtig, denn sie stellt die Qualität des getesteten Moduls erst sicher. Ein Test kann alle Pfade und alle Äquivalenzklassen der Eingaben eines Moduls abdecken, ohne richtige Validierung der Ergebnisse bleibt er sinnlos.
Auf der anderen Seite ist diese Validierung nicht immer trivial. Sie erfordert neben der Vorbereitung der Eingaben den größten Aufwand beim Entwurf und bei der Implementierung von Modultests. Zum Glück existieren viele Best Practices, die dabei helfen, effiziente und sichere Validierung zu implementieren. In diesem Abschnitt werden wir zuerst die allgemeinen Best Practices anschauen. Im Anschluss widmen wir uns den Besonderheiten bei der Validierung von Ausgaben mit bestimmten Datentypen.
Die Validierung der Ausgaben in einem Modultest wird mit Hilfe von sogenannten Zusicherungen (assertions) umgesetzt. Eine Zusicherung ist eine Behauptung, die wahr sein muss, damit ein Test als erfolgreich gelten kann. Ein Modultest kann beliebig viele Zusicherungen definieren. Meistens findet man Zusicherungen am Ende von Testskripten, wo sie Aussagen über produzierte Ergebnisse treffen. In ihrer Reinform ist eine Zusicherung ein boolescher Ausdruck. Haben wir eine Methode getestet, die ihr Ergebnis in der Variable result
gespeichert hat, können auf diese Variable je nach Datentyp folgende Zusicherungen angewandt werden (hier in Java-Syntax):
== 5;
result < 10;
result == 5 || result < 10;
result .equals("SUCCESS");
result!= null;
result .firstName().equals("Bill");
result!result;
[1] == 3;
resultinstanceof String; result
Der Erfolg eines Modultests basiert darauf, dass alle Zusicherungen wahre Behauptungen enthalten. Produziert eine einzelne Zusicherung eine falsche Behauptung, bedeutet das einen Testfehlschlag. In diesem Fall gilt das getestete Modul als fehlerhaft. (Natürlich kann eine nicht erfüllte Zusicherung einen Fehler im Testcode und nicht in dem getesteten Modul als Grund haben.)
Würden wir Modultests manuell implementieren, könnten wir die Auswertung von Zusicherungen wie in unserem ersten Beispiel folgenderweise implementieren:
if (result == 2.0f) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
Hier definieren wir eine Zusicherung mit der Behauptung result == 2.0f
. Der Testcode gibt eine Erfolgsmeldung oder einen Fehler auf die Console aus, je nachdem, ob die Behauptung wahr oder falsch ist.
Modultest-Frameworks erleichtern die Definition von Zusicherungen, indem sie entsprechende Methoden bereitstellen. Unser obiges Beispiel würde in JUnit so aussehen:
assertEquals(2.0f, result);
Die Methode assertEquals
vergleicht den erwarteten Wert 2.0 mit dem Wert der Variable result. Sind die Werte gleich, passiert nichts. Sind sie jedoch unterschiedlich, wirft die Methode eine Laufzeitausnahme (runtime exception) welche den Programmfluss unterbricht und den Modultest als fehlgeschlagen abbricht.
Definiert ein Test mehrere Zusicherungen, kann es im Falle eines fehlgeschlagenen Testlaufs hilfreich sein zu sehen, welche von ihren zum Fehlschlag geführt hat. Deshalb können JUnit-Zusicherungen mit Beschreibungen versehen werden.
assertEquals(2.0f, result, "result value must be 2.0f");
Beschreibungen sind allerdings selten nötig, da JUnit von selbst ganz gute Angaben macht. In unserem Beispiel würde die Testausführung im Fehlerfall folgende Ausgabe produzieren:
org.opentest4j.AssertionFailedError: result value must be 2.0f ==> expected: <2.0> but was: <3.0>
JUnit definiert viele Zusicherungsmethoden. Neben assertEquals
gibt es assertNull
, assertArrayEquals
und assertSame
. Findet man keine passende Methode, kann man seine Zusicherung immer mit der allgemeinen Form assertTrue
ausdrücken. So ist beispielsweise die Anweisung assertEquals(2.0f, result);
äquivalent zu der Anweisung assertTrue(2.0f == result);
Beachten Sie bei assertEquals
die Reihenfolge der Argumente. Der erste Parameter ist für den Referenzwert gedacht, der zweite für den zu prüfenden Wert. Die umgekehrte Reihenfolge würde ebenfalls funktionieren, allerdings würde sie Fehlerbeschreibungen verfälschen.
Reihenfolge der Zusicherungen
Manchmal müssen mehrere Eigenschaften eines Ausgabewertes zugesichert werden. In solchen Fällen sollten zuerst die allgemeinen Eigenschaften, danach die spezifischeren Eigenschaften validiert werden.
Nehmen wir an, dass wir bei einem von der getesteten Methode zurückgelieferten Ergebnis prüfen wollen, ob es sich um eine Zeichenkette mit exakt drei Zeichen handelt. Das Ergebnis bekommen wir über die Variable result
vom Typ Object
. Theoretisch könnten wir die folgende Zusicherung für die Validierung verwenden:
assertEquals(3, ((String)result).length());
Allerdings kann es passieren, dass die getestete Methode den Wert null zurückgibt. Die Testausführung meldet dann folgendes:
java.lang.NullPointerException: Cannot invoke "String.length()" because "result" is null
Der Testskript ist also nicht kontrolliert fehlgeschlagen, sondern mit einer NullPointerException abgestürzt.
Etwas Ähnliches passiert, wenn die Methode einen Wert zurückgibt, der keine Zeichenkette (string) ist. Die Testausführung gibt dann etwa Folgendes aus :
java.lang.ClassCastException: class java.util.Date cannot be cast to class java.lang.String
Die Prüfung der Zeichenkettenlänge ist nur bei Werten vom Typ String sinnvoll. Die Überprüfung des Datentyps ergibt wiederum nur bei Werten Sinn, die nicht null sind. Daraus ergibt sich die optimale Reihenfolge von Zusicherungen:
assertNotNull(result);
assertTrue(result instanceof String);
assertEquals(3, ((String)result).length());
Unabhängig vom Fehlerfall wird der Test nun kontrolliert und mit der richtigen Fehlermeldung fehlschlagen.
Validierung von Ausnahmen
Wie wir in dem Abschnitt über Ein- und Ausgabearten gesehen haben, sind Ausnahmen (exceptions) valide Ausgaben von Java-Methoden. Andere Programmiersprachen haben ähnliche Konzepte, um unerwartete (oder auch erwartete) Fehler an den Aufrufer zu signalisieren. Nehmen wir z. B. eine Methode, welche die Division von zwei Ganzzahlen berechnet.
public int div(int numerator, int denominator) {
return numerator / denominator;
}
Division durch 0 ist in der Mathematik nicht definiert. Ähnlich verhält es sich mit Integer-Werten in Java. Rufen wir die Methode beispielsweise mit den Argumenten 2 und 0 auf, resultiert das in einer ArithmeticException
.
Da Ausnahmen selten vorkommen und schwer greifbar sind, neigen Entwickler dazu, sie in den Testfällen zu ignorieren. Das ist ein schwerer Fehler, denn gerade solche Sonderfälle verursachen später die meisten Probleme und sind in der integrierten Anwendung nur schwer als Ursache für falsches Verhalten und Programmabstürze zu erkennen. Wie wir bereits wissen, sollte der Wert 0 einer Ganzzahl eine eigene Äquivalenzklasse bilden und als Sonderfall mit einem Testfall versehen werden. Die Ausnahme ArithmeticException
ist allerdings kein Fehler, sondern eine korrekte Ausgabe, wenn das Nenner-Argument 0 beträgt. Wir sollten sie also in unserem Testfall zusichern. Aber wie soll das gehen? Immerhin kommt die Methode div
in diesem Fall nicht zu ihrem Ende, sondern wird durch die Ausnahme abgebrochen. Wie kann da die Zusicherung aussehen?
Bevor wir uns anschauen, wie JUnit das macht, implementieren wir die Validierung einer Ausnahme per Hand. Betrachten wir die folgende Testmethode:
void testDiv() {
boolean testResult = false;
try {
int result = new AssertionExamples().div(2, 0);
} catch (ArithmeticException e) {
= true;
testResult }
if (testResult) {
System.out.println("Success");
} else {
System.out.println("Failure");
}
}
Da die Methode div
nicht zum Ende kommt, können wir ihr Ergebnis, das in der Variable result
gespeichert sein sollte, nicht validieren. Wir können allerdings über den try/catch
-Block validieren, ob die Ausnahme tatsächlich aufgetreten ist. In Java wird ein try
-Block wie ein normaler Block ausgeführt, bis eine Ausnahme auftritt. Tritt sie tatsächlich auf, wechselt der Kontrollfluss zu dem catch
-Block und führt den Code aus, der dort definiert ist. Die Voraussetzung dafür ist, dass die Ausnahme von dem Typ ist, der in dem catch
-Block deklariert ist.
Unsere Testmethode legt eine boolesche Variable testResult
an und weist ihm den Wert false
zu. Nur wenn ArithmeticException
tatsächlich auftritt, wird sie im catch
-Block abgefangen und testResult
auf den Wert true
gesetzt.
Der Wert von testResult
entscheidet am Ende der Methode, ob der Test erfolgreich war, oder nicht.
JUnit vereinfacht den Testfall. Hier sieht die äquivalente Testmethode folgendermaßen aus:
@Test
void testDiv() {
Exception exception = assertThrows(ArithmeticException.class, () -> {
new AssertionExamples().div(2, 0);
});
}
Die Methode assertThrows
definiert zwei Parameter. Der erste ist der Typ der erwarteten Ausnahme. Der zweite ist ein Objekt vom Typ Executable
, welcher eine Methode definiert, die keine Parameter empfängt und nichts zurück gibt. In dieser Methode rufen wir die Methode auf, die wir testen wollen. Das Framework sorgt dafür, dass das Auftreten der ArithmeticException
validiert wird. Wird diese wider Erwartung nicht geworfen, schlägt der Test mit der folgenden Begründung fehl:
org.opentest4j.AssertionFailedError: Expected java.lang.ArithmeticException to be thrown, but nothing was thrown.
Zusätzlich gibt assertThrows
die geworfene Ausnahme zurück. Damit kann diese genauer validiert werden.
Validierung von Prozeduren
Jede Entwicklerin von Modultests kennt Quellcode-Stellen, um die die Testabdeckung einen großen Bogen macht. Es handelt sich bei solchem Code meist um Prozeduren, also Funktionen ohne Rückgabewert. Prozeduren tun beim Aufruf einfach ihre Arbeit und sind irgendwann fertig, ohne dass der Aufrufer etwas in den Händen hält, was validiert werden könnte. Ein Beispiel für eine solche Prozedur wäre die folgende Java-Methode:
public void log(String text) {
System.out.println(text);
}
Wie kann man eine solche Prozedur mit einem Test abdecken? Immerhin gibt sie nichts zurück, deklariert keine Ausnahmen und ist auch sonst eher unscheinbar. Was können wir da machen?
Unsere pragmatische Empfehlung lautet: Nichts. Die Methode println
gehört zum Java-Standard-Repertoire, wir können uns auf sie verlassen. Die Übergabe des Wertes ist ebenfalls trivial.
Nun gibt es aber komplexere Prozeduren, wie z.B. die folgende:
public void log(String text) {
= text.replaceAll(".", "_");
text System.out.println(text);
}
Hier wollen wir im Text alle Punkte durch Unterstriche ersetzen, bevor wir ihn ausgeben. Der Text file.txt
soll also als file_txt
ausgegeben werden.
In diesem Fall lohnt sich ein Modultest schon deshalb, weil unser Code nachweislich einen Fehler enthält. Die Methode replaceAll
aus der Klasse String
interpretiert das erste Argument nämlich als regulären Ausdruck. Ein Punkt ist in der Sprache von regulären Ausdrücken ein sogenanntes Wildcard, darunter fallen alle möglichen Zeichen. Damit werden ausnahmslos alle Zeichen der Eingabe durch Unterstriche ersetzt. file.txt
wird somit zu ________
.
Da die vorliegende Prozedur nicht mit trivialen Mitteln getestet werden kann, empfehlen wir ein Refactoring. Der Aufrufer der Methode log
könnte beispielsweise selbst die Ersetzung des Textes vornehmen, oder eine andere Methode dafür verwenden. Damit würden wir die Methode log
auf die Form vom letzten Beispiel reduzieren, so dass kein Modultest für sie nötig wäre.
public class Printer {
public String replaced(String text) {
return text.replaceAll(".", "_");
}
public void log(String text) {
System.out.println(text);
}
public void logReplaced(String text) {
String replaced = replaced(text);
log(replaced);
}
}
Nun können wir unsere Textersetzung bequem testen:
@Test
void testReplaced() {
assertEquals("file_txt", new Printer().replaced("file.txt"));
}
Der Test schlägt mit der folgenden Begründung fehl:
org.opentest4j.AssertionFailedError: expected: <file_txt> but was: <________>
Wir haben den Fehler somit frühzeitig und ohne großen Aufwand entdeckt. Hätten wir den Code nicht mit einem Modultest abgedeckt, würde der Fehler möglicherweise erst viel später auffallen und schwieriger zu fixen sein.
Generell sollten Ausgaben und Berechnungen voneinander getrennt werden. Im Kapitel Testbarkeit werden wir weitere Design-Prinzipien kennen lernen, die Modultests erheblich erleichtern.
Manchmal kommt ein Refactoring nicht infrage, sei es wegen Coding-Konventionen, Vermeidung von Regressionsrisiken oder anderen Einflussfaktoren. Wie können wir in einem solchen Fall Prozeduren validieren?
Eine Möglichkeit wäre es, sogenanntes Mocking anzuwenden. Mocking ist eine Technik, bei der der zu testende Quellcode oder der kompilierte Maschinencode in Modultests instrumentalisiert, also automatisch um zusätzliche Aspekte erweitert wird. Dafür existieren diverse Frameworks, wie z.B. Mockito, EasyMock und JMockit. Durch die Instrumentalisierung bekommt man die Möglichkeit, innere Methodenvariablen auszulesen, so dass sie validiert werden können.
Da Mocking ein komplexes Thema ist, werden wir es in dem entsprechenden Abschnitt in Detail untersuchen. Hier fokussieren wir uns zuerst auf die klassischen Techniken der Validierung.
Prozeduren generieren nur auf den ersten Blick keine Ausgaben. Wäre es tatsächlich so, ergäben sie keinen Sinn. Quellcode, der keine Kommunikation zur Außenwelt hat, ist nutzlos. Prozeduren liefern zwar keine Rückgabewerte an den Aufrufer zurück, aber die meisten von ihnen kommunizieren durchaus mit der Außenwelt -- durch Seiteneffekte.
Betrachten wir dazu drei Beispiele. Ihre Methodensignaturen sehen folgendermaßen aus:
log(String text)
saveUser(User user)
updateOrder(Order order)
Bei allen drei Methoden handelt es sich um Prozeduren, sie deklarieren also keine Rückgabewerte. Alle drei haben jedoch Seiteneffekte auf das System, die validiert werden können.
Das erste Beispiel haben wir bereits kennengelernt. Die Implementierung der Methode sieht demnach so aus:
public void log(String text) {
= text.replaceAll(".", "_");
text System.out.println(text);
}
Der Seiteneffekt, den diese Prozedur auf das System hat, ist offensichtlich -- es ist das Schreiben des Textes auf die Konsole... oder auch nicht.
Der Standard-Ausgabekanal kann in Java nämlich verändert werden -- mit der statischen Methode System.setOut
. Wir können die Textausgabe also von der Konsole auf einen beliebigen Stream vom Typ PrintStream
umleiten, auch auf einen solchen, der eine Variable als Ziel hat. Der JUnit-Test kann dann beispielsweise so aussehen:
@Test
void testLog() {
PrintStream originalStream = System.out;
ByteArrayOutputStream output = new ByteArrayOutputStream();
PrintStream testStream = new PrintStream(output );
System.setOut(testStream);
new Printer().logReplaced("file.txt");
assertEquals("file_txt", output.toString());
System.setOut(originalStream);
}
Hier speichern wir zuerst den Wert von System.out
in der Variable originalStream
, damit wir ihn nach dem Test wiederherstellen können. Als Nächstes erzeugen wir eine neue PrintStream
-Instanz, die einen Byte-Array als Ziel hat. Mit dem Aufruf von System.setOut(testStream)
überschreiben wir den Standard-Ausgabekanal. Ab jetzt schreibt jeder Aufruf von System.out.println()
in das Byte-Array statt auf die Konsole. Nun können wir unsere Prozedur log
aufrufen und danach den Inhalt des Byte-Arrays validieren, indem wir ihn über output.toString()
auslesen. Schließlich setzen wir die Standard-Ausgabe auf den zuvor gesicherten originalen Ausgabekanal.
Bei dem zweiten Beispiel ist der Seiteneffekt ebenfalls klar ersichtlich: Die Methode saveUser(User user)
erstellt einen neuen Eintrag in der Nutzerdatenbank. Schauen wir die Klasse genauer an, die diese Methode implementiert.
public class UserService {
private UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userDao;
}
public void saveUser(User user) {
if (user != null) {
.add(user);
userDao}
}
}
Uns liegt eine Service-Klasse vor, also ein Modul der Geschäftslogikschicht. Diese implementiert die Methode saveUser
. Die Methode prüft, ob die übergebene User-Instanz den Wert null
hat und speichert den Nutzer, wenn es nicht der Fall ist. Da das Speichern des Nutzers die Aufgabe der Persistenzschicht ist, delegiert unsere Service-Methode sie an den DAO vom Typ UserDao
.
Die Referenz auf die Instanz des DAO wird in der Objektvariable userDao
vom Typ UserDao
gehalten. UserDao
ist ein Interface, das so aussieht:
public interface UserDao {
void add(User user);
}
In dem fertigen Programm wird userDao
auf eine komplexe Implementierung der Datenbankzugriffslogik gesetzt. Für den Modultest reicht uns eine leichtgewichtige Implementierung, die beispielsweise so aussehen kann:
public class UserDaoForTest implements UserDao {
private List<User> users = new ArrayList<>();
public List<User> getUsers() {
return users;
}
@Override
public void add(User user) {
.add(user);
users}
}
Jeder Aufruf der Methode add
wird den Nutzer zu der Liste der Nutzer in der Instanzvariable users
hinzufügen. Die Liste können wir im Modultest auslesen, um zu sehen, ob sie die erstellten Nutzer enthält.
class UserServiceTest {
@Test
void testSaveUser() {
;
UserDaoForTest userDaoForTest
= new UserService();
UserService userService
= new UserDaoForTest();
userDaoForTest .setUserDao(userDaoForTest);
userService.saveUser(null);
userServiceassertEquals(0, userDaoForTest.getUsers().size());
= new UserDaoForTest();
userDaoForTest .setUserDao(userDaoForTest);
userService.saveUser(new User());
userServiceassertEquals(1, userDaoForTest.getUsers().size());
}
}
Hier testen wir zwei Fälle. Im ersten Fall hat die User-Instanz den Wert null
und wir validieren über die Eigenschaft users
unserer DAO-Implementierung, dass keine neue Nutzerin angelegt wurde. Im zweiten Fall übergeben wir eine valide User-Instanz an die Service-Methode und validieren, dass die Nutzerin angelegt wurde.
In unserem letzten Beispiel erhalten wir eine Bestellung, und aktualisieren diese, ohne sie zurück zu geben.
public void updateOrderTime(Order order) {
long currentTime = System.currentTimeMillis();
.setOrderTime(currentTime);
order}
Seiteneffekte wie dieser sind leicht zu validieren. Da das Argument des Parameters order
lediglich eine Referenz auf eine Objektinstanz vom Typ Order
ist, können wir den Zugriff auf diese Instanz auch außerhalb der Methode erhalten. Im Modultest können wir dann einfach validieren, ob die Eigenschaft orderTime
der Instanz tatsächlich gesetzt wurde:
@Test
void testUpdateOrderTime() {
= new Order();
Order order assertEquals(0L, order.getOrderTime());
new OrderService().updateOrderTime(order);
assertNotEquals(0L, order.getOrderTime());
}
Nachdem wir die allgemeinen Best Practices für die Validierung von Ausgaben kennen gelernt haben, widmen wir uns den Spezialfällen, die bei bestimmten Datentypen von Ausgaben entstehen. Dabei betrachten wir die üblichen Stolpersteine und Tücken und zeigen Lösungen für komplexere Validierungsprobleme auf.
Boolesche Werte
Da der boolesche Typ bloß zwei mögliche Werte, true
und false
, enthält, ist die Validierung von Ausgaben dieses Typs sehr einfach. Nutzen Sie in JUnit die Zusicherungen assertTrue()
und assertFalse()
, um solche Ausgaben validieren. Achten Sie jedoch darauf, ob die Programmiersprache, die Sie nutzen, Sonderwerte für den boolschen Typ zulässt. Oft ist beispielsweise der Wert null
ein Element der Wertemenge. Ein Wert, der nicht true
ist, ist dann nicht zwangsweise false
.
Ganzzahlen
Validierung von Ganzzahlen ist ebenfalls relativ einfach. In vielen Fällen lassen sie sich über assertEquals()
bequem validieren. Andere Beziehungen lassen sich über Arithmetik leicht abbilden, wie man an den folgenden Beispielen sieht:
assertTrue(a > 0)
assertEquals(0, a % 2)
assertEquals(1, a % 2)
assertEquals(0, a % 16)
assertTrue(a * b >= 0)
assertEquals(a * a, b * b)
Fließkommazahlen
Bei der Validierung von Fließkommazahlen muss man einige Besonderheiten beachten. Im Gegensatz zur Mathematik sind Fließkommazahlen in der Informatik nicht unendlich präzise. Ihre Genauigkeit wird von der Aufnahmefähigkeit des jeweiligen Datentyps definiert. Dadurch entstehen unter Umständen Ergebnisse, die mathematisch nicht korrekt sind.
Ein Beispiel dafür ist der Typ double
in Java. Der Ausdruck 0.1 + 0.2
scheint trivial zu sein. Wenn wir ihn aber von Java ausrechnen lassen, bekommen wir als Ergebnis nicht etwa 0.3, sondern 0.30000000000000004.
Ergebnisse von Berechnungen mit Fließkommazahlen sind bloß Annäherungen. Als Programmierer muss man das im Kopf behalten und mit akzeptablen Rundungen rechnen. Dasselbe gilt für Validierungen in Modultests.
Sichern Sie nie die exakte Gleichheit von Fließkommazahlen zu, sondern nur, dass sie ungefähr gleich sind. JUnit trägt der Rundungsproblematik Rechnung, indem es die assertEquals
-Methode für double-Parameter mit dem optionalen dritten Parameter versieht, der definiert, wie groß die Abweichung des eigentlichen Wertes von dem Referenzwert maximal sein darf. Bei den zwei Zusicherungen unten wird die erste fehlschlagen, während die zweite erfolgreich durchläuft.
assertEquals(0.3, 0.1 + 0.2);
assertEquals(0.3, 0.1 + 0.2, 0.000001);
Ein weiterer Unterschied zur Mathematik ist die Tatsache, dass Fließkommatypen vieler Programmiersprachen den Wert für Unendlichkeit und weitere exotische Werte, wie Not a Number enthalten. In Java darf man Fließkommazahlen z. B. durch 0 teilen. Folgende Zusicherungen werden dort erfolgreich durchlaufen:
assertEquals(Double.POSITIVE_INFINITY, 1 / 0.0);
assertEquals(Double.NEGATIVE_INFINITY, -1 / 0.0);
assertEquals(Double.NaN, 0.0 / 0.0);
Zeichenketten
Der Vergleich von Zeichenketten ist nicht immer trivial. Ist die Behauptung unten wahr?
"a" == "a"
Die Antwort hängt von der Spezifikation der Programmiersprache ab. Betrachten wir die folgenden vier Zusicherungen in JUnit:
1. assertTrue("a".equals("a"));
2. assertTrue("a" == "aa".substring(0, 1));
3. assertTrue("a" == "a");
4. assertTrue("a" == "aa".substring(0, 1).intern());
Die erste Zusicherung wird erfolgreich durchlaufen. String-Literale werden in Java zu Objekten, deren equals
-Methoden bei gleichen Inhalten true
zurückgeben.
Die zweite Zusicherung wird fehlschlagen, obwohl die Operanden inhaltsgleich sind. Der Operator ==
vergleicht nämlich nicht Inhalte von Objekten auf Gleichheit, sondern ihre Referenzen (Zeiger). Da es sich um zwei verschiedene Objekte im Heap-Speicher des Java-Programms handelt, ist das Ergebnis false
Allerdings wird die dritte Zusicherung trotzdem erfolgreich durchlaufen. Java spezifiziert nämlich, dass einfache String-Literale im internen Cache gehalten werden. Deshalb handelt es sich bei dem ersten und bei dem zweiten a
um ein und dasselbe Objekt. (Das ist der Grund, warum wir in der zweiten Zusicherung den rechten Operanden so aufwändig konstruiert haben; wir wollten Caching vermeiden.)
Auch die vierte Zusicherung wird erfolgreich durchlaufen, obwohl wir es hier nicht mit einem Literal auf der rechten Seite des Vergleichs zu tun haben. Das liegt an dem Aufruf der String-Methode intern()
, die dafür sorgt, dass die Zeichenkette aus dem Cache geholt wird.
Studieren Sie also die Spezifikation Ihrer Programmiersprache bzgl. der Handhabung von Zeichenketten, um diese korrekt validieren zu können.
Als Nächstes schauen wir uns einige nützliche Operationen auf Zeichenketten an, um komplexere Validierungen vornehmen zu können. Wir verwenden dafür String-Methoden aus der Java-Bibliothek. Andere Programmierplattformen werden in den meisten Fällen ähnliche Funktionen anbieten.
substring()
extrahiert einen Teil der Zeichenkette. Beispiel: assertEquals("Unit", "Unit Test".substring(0, 4));
startsWith()
, endsWith()
, charAt()
und contains()
sind nützliche Methoden, um Teile von Zeichenketten zu validieren. Beispiel: assertTrue("JavaScript".startsWith("Java"));
equalsIgnoreCase()
können Sie Zeichenketten vergleichen, ohne auf Groß-/Kleinschreibung zu achten, z. B. assertTrue("Hauptstrasse1".equalsIgnoreCase("hauptstrasse1"));
.matches()
prüft, ob eine Zeichenkette Instanz eines regulären Ausdrucks ist, beispielsweise: assertTrue("14.10.1981".matches("[0-9]{2}\\.[0-9]{2}\\.[0-9]{4}"));
Generell sind reguläre Ausdrücke ein mächtiges Mittel, um Zeichenketten zu validieren. Achten Sie allerdings darauf, dass komplexere reguläre Ausdrücke den Testskript unnötig verkomplizieren oder schlecht lesbar machen können.split()
zerteilt eine Zeichenkette in ihre Teile. Dafür verwendet die Methode einen Trenner, der in der Form eines regulären Ausdrucks als Argument an sie übergeben wird. Einzelne Teile der zerstückelten Zeichenkette können dann für sich validiert werden. Beispiel: String parts[] = "14.10.1981".split("\\."); assertEquals(2, parts[0].length());
Die Validierungsreihenfolge ist bei Zeichenketten wichtig. Validieren sie die allgemeinen Eigenschaften eines String-Objekts vor seinen speziellen Eigenschaften, um unerwartete Fehler zu vermeiden. Prüfen Sie als Erstes, ob der Objektreferenz nicht der Wert null
zugewiesen ist. Stellen Sie danach sicher, dass die Zeichenkette nicht leer ist. Vergewissern Sie sich, dass genügend Trennzeichen in der Zeichenkette vorkommen, bevor Sie sie mit split()
in Teile zerlegen. Messen Sie mit length()
die Länge einer Zeichenkette, bevor Sie mit substring()
über Zeichenindizes Teil-Strings daraus bilden.
Collections
Collections sind Datenstrukturen, die Sammlungen von Werten enthalten. Dazu gehören Listen, Mengen, Abbildungen und Arrays. Der folgende Java-Methodenkopf deklariert beispielsweise eine Liste von Zeichenketten als Rückgabetyp:
public List<String> bookTitles();
Die Validierung von Collections gestaltet sich etwas schwieriger als die von einfachen Werten, da es hierbei Einiges zu beachten gibt. Sichern Sie die Eigenschaften von Collections in folgender Reihenfolge zu:
null
;myCollection instanceof ArrayList
);null
-Werten;List<Object>
) beispielsweise Integer-, String- und alle anderen möglichen Typen von Werten enthalten.Als Nächstes betrachten wir einige der gängigen Anwendungsfälle für Validierung con Collections und stellen am Beispiel Java etablierte Umsetzungsmöglichkeiten für sie vor.
Anwendungsfall: Validieren Sie, dass die berechnete Liste von IP-Adressen nur aus Adressen besteht, die in einer White List definiert sind. Umsetzung: Nutzen Sie dafür die Collection::containsAll
-Methode: assertTrue(whiteList.containsAll(ipAdresses));
Anwendungsfall: Validieren Sie, dass die berechnete Liste der IDs keine Duplikate enthält. Umsetzung: Listen dürfen in Java Duplikate enthalten, Mengen (Sets) jedoch nicht. Erstellen Sie eine Set-Collection und kopieren Sie alle Listen-Elemente dorthin. Ist die Anzahl der Elemente in der Set gleich der Anzahl der Elemente in der Liste, dann gibt es in der letzteren keine Duplikate. Ist sie kleiner, enthält die Liste Duplikate. assertEquals(new HashSet<String>(resultingList).size(), resultingList.size());
Anwendungsfall: Das berechnete Ergebnis ist eine Liste mit den Eigenschaften eines Nutzers in String-Form. Das erste Element enthält den Vornamen, das zweite den Nachnamen, das dritte das Geburtsjahr und das vierte den Geburtsort. Alle Werte können groß oder kleingeschrieben werden. Validieren Sie, indem Sie Groß-/Kleinschreibung ignorieren, dass die Liste wie folgt ist: ["max", "mustermann", "1967", "Berlin"]
Umsetzung: Nutzen Sie für komplexere Operationen auf Collections Streams. Statt über die vier Elemente der Collection zu iterieren und jedes Element mit dem entsprechenden Referenzwert zu vergleichen, kann die Validierung folgendermaßen aussehen: assertEquals("max:::mustermann:::1967:::berlin", resultingList.stream().map(String::toLowerCase).collect(Collectors.joining(":::")));
Hier verwenden wir einen Stream, um über alle Elemente der Collection zu iterieren, sie auf Kleinschreibung umzustellen und sie am Ende mit dem Trenner :::
in einer Zeichenkette zusammenzufassen. Die so entstandene Zeichenkette vergleichen wir mit der Zeichenkette, die unserer Referenz-Collection entspricht.
Anwendungsfall: Validieren Sie, dass die Liste der Nutzernamen keine null
-Werte enthält. Umsetzung: Auch hier können Streams eine gute Hilfe sein, und zwar im Zusammenspiel mit der filter()
-Methode. Die Implementierung der Zusicherung sieht dann so aus: assertEquals(0, resultingList.stream().filter(e -> e == null).count());
Hier erstellen wir aus der Liste der Nutzernamen einen Stream, filtern es so, dass bloß null
-Werte übrig bleiben, und validieren, dass die Anzahl der Elemente im gefilterten Stream gleich 0 ist.
Streams sind generell ein sehr gutes Mittel, um Collections zu validieren. Beachten Sie bei ihrer Nutzung allerdings zwei Punkte. Erstens sollen Collections auf ihre globalen Eigenschaften geprüft werden, bevor Sie Streams daraus erstellen. Damit ersparen Sie sich kryptische Stream-Exceptions bei fehlgeschlagenen Tests, wenn eine Collection z. B. den Wert null
oder eines ihrer Elemente den falschen Typ hat. Zweitens sollen Streams in den Testskripten nicht überbordend komplex ausfallen. Schließlich wollen wir die Korrektheit von Testskripten nicht durch weitere Modultests nachweisen müssen.
In den vorherigen Abschnitten stellten wir die grundlegenden Techniken für den Entwurf von Modultests vor. Wir zeigten, wie Ein- und Ausgaben von Modulen identifiziert werden können, beschrieben die Techniken der Testfallerstellung, wie die Abdeckung vom Quellcode und von Wertemengen der Eingaben, und setzten uns mit verschiedenen Möglichkeiten der Validierung von Ergebnissen auseinander.
Dieser Abschnitt beschäftigt sich mit der praktischen Seite von Modultests. Hier wollen wir aufzeigen, wie Testskripte verwaltet und in den Softwareentwicklungsprozess eingebunden werden können, so dass sie den größtmöglichen Mehrwert bei möglichst geringem Aufwand generieren.
Wir zeigten bereits, wie Testskripte üblicherweise aufgebaut sind. Unabhängig von der zugrundeliegenden Programmiersprache haben wir es in der Regel mit einem Modultestskript je Modul zu tun. Dabei betrachten wir als Module Quellcode-Einheiten (units), die isoliert für sich lauffähig und in ihrer Funktionalität gekapselt sind. In den meisten Fällen handelt es sich dabei um öffentliche Funktionen, Prozeduren, Methoden oder Klassen der jeweiligen Programmiersprache.
Ein Modultestskript besteht in seiner einfachsten Form aus einer oder mehreren Testfunktionen. Jede dieser Testfunktionen implementiert einen Testfall und definiert stets drei Schritte: Vorbereitung des zu testenden Moduls und seiner Eingaben, Ausführung des zu testenden Moduls und schließlich die Validierung der generierten Ausgaben.
Ein Programm kann allerdings aus hunderten oder tausenden von Modulen bestehen. Je nach dem Grad der Testabdeckung könnte es genau so viele Testskripte geben. Es wäre unpraktisch, sie alle einzeln ausführen zu müssen. Deswegen fasst man Testskripte in der Praxis zu Testsuiten zusammen.
Eine Testsuite ist ein Testskript, der nichts anderes tut als eine Liste von weiteren Testskripten auszuführen und ihre Ergebnisse zu aggregieren. Damit können bequem viele Modultests in einer Art Stapelverarbeitung ausgeführt werden. Eine Testsuite kann weitere Testsuiten als Elemente haben, somit können Testsuiten beliebig verschachtelt werden.
Schauen wir ein Beispiel in JUnit an:
@Suite
@SelectClasses
(
{
.class,
AdditionTest.class,
SubstractionTest.class,
MultiplicationTest.class
DivisionTest}
)
public class CalculatorTestSuite {
}
Die Klasse CalculatorTestSuite
ist inhaltlich leer. Allerdings ist sie mit mehreren Annotations versehen. Die erste, Suite
, identifiziert die Klasse als eine Testsuite. JUnit scannt alle Klassen in seinem Kontext nach dieser Annotation und behandelt jene, die sie enthalten, Testsuiten, also als Sammlungen von Testklassen. Die letzteren werden in der Testsuite über die Annotation selectClasses
angegeben. Damit weiß das Modultest-Framework, welche Testklassen im Rahmen der Testsuiteausführung ausgeführt werden sollen.
Testsuiten können auch impliziert entstehen, indem das Testframework alle Testskripte in einem Paket oder einem Ordner zusammenfasst. So kann man JUnit mit einem Parameter starten, der den Pfad eines Java-Pakets als Argument hat. Damit betrachtet das Testframework alle Testklassen in dem Paket als Teile der implizierten Testsuite und führt sie alle nacheinander aus. Wir empfehlen diese implizierte Definition von Testsuiten generell, da sie zur besseren Wartbarkeit des Testcodes beiträgt; immerhin brauchen wir so keine Testsuiteklassen zu pflegen. Erstellen Sie explizierte Testsuiten nur dann, wenn ihr Testframework keine Zusammenfassung von Testskripten über Pakete erlaubt, oder wenn Sie Testskripte aus verschiedenen Paketen zusammenfassen wollen. Der letztere Fall kann beispielsweise vorkommen, wenn Sie zwecks Zeitersparnis eine reduzierte Testsuite mit den wichtigsten Testskripten ausführen wollen.
Trennen Sie unbedingt den Quellcode der Testskripte von dem Code der Module, die getestet werden sollen. Strukturieren Sie (implizierte) Testsuiten analog zur Struktur der getesteten Module. In Java können Sie beispielsweise zwei Ordner anlegen: einen für den Quellcode und einen für den Testcode. Organisieren Sie den Testcode dann in Paketen analog zum Quellcode, wie im folgenden Beispiel zu sehen ist:
/users/ui/UsersController.java
sourcecode/users/logic/UserService.java
sourcecode/users/logic/UserServiceImpl.java
sourcecode/users/logic/User.java
sourcecode/users/persistence/UserDao.java
sourcecode/users/persistence/UserDaoImpl.java
sourcecode
/users/ui/UsersControllerTest.java
testcode/users/logic/UserServiceImplTest.java
testcode/users/logic/UserTest.java
testcode/users/persistence/UserDaoImplTest.java testcode
Hier haben wir es mit dem vertikalen Schnitt der Anwendung zu tun. Der Code der funktionalen Domain Users liegt also komplett in dem Paket users
und wird je nach technischer Schicht auf die Unterpakete ui
, logic
und persistence
aufgeteilt. Die ganze Struktur liegt in dem Hauptordner sourcecode
, der als Quellcodeverzeichnis im Java-Classpath festgelegt ist.
Daneben befindet sich der Ordner testcode
, der alle Testklassen für den Quellcode der Domain Users enthält. Die Namen der Testklassen werden von den Namen der Module abgeleitet, indem an sie der Suffix Test angehängt wird. Jede Testklasse befindet sich in demselben Paket wie das zugehörige Modul.
Eine solche Strukturierung des Testcodes bietet drei Vorteile.
Erstens sind Modulklassen in den entsprechenden Testklassen sichtbar, sofern der Ordner testcode
als Quellcodeverzeichnis im Java-Classpath deklariert wird. Das liegt daran, dass Java Klassen, die im selben Paket liegen, zusammenlegt, auch wenn diese aus verschiedenen Quellen stammen. Somit entfallen lästige import
-Anweisungen im Testcode.
Zweitens kann der Testcode so einfach aus dem Buildprozess ausgeschlossen werden, wenn die Anwendung zwecks Deployment auf der Zielumgebung zu einer Bibliothek verpackt wird. Das ist sinnvoll, denn der Modultestcode ist im Betrieb der Anwendung irrelevant und sogar gefährlich, da er Einfallstore für Hacker bieten könnte. Indem wir den Quellcode vom Testcode trennen, können wir den letzteren aus dem Verpackungsvorgang (packaging) des Build-Tools ausschließen.
Drittens entstehen durch diese Strukturierung automatisch implizierte Testsuiten, die unabhängig voneinander ausgeführt werden können. Nun kann man das Testframework so konfigurieren, dass es nur den Testcode in dem Unterpaket users.persistence
oder in dem Paket users
ausführt, wenn es notwendig sein sollte.
An dieser Stelle wollen wir einige Eigenschaften auflisten, die gute Testskripte aufweisen. Diese Eigenschaften sind insofern wichtig, da sie die Verwaltung von Testskripten erleichtern sowie ihre schnelle und korrekte Ausführung fördern.
Autonomie
Wir haben bereits an einer anderen Stelle hervorgehoben, dass die Durchführung von Modultests unabhängig von externen Diensten, Systemen oder Code-Bibliotheken sein soll. Die einzige Abhängigkeit bei einer Modultestausführung ist im Regelfall nur der Quellcode des getesteten Moduls. Allerdings gibt es noch eine Art von Abhängigkeit, die oft übersehen bzw. sogar absichtlich von Entwicklern der Testskripte eingebaut wird: die Abhängigkeit der Testskripte voneinander. Betrachten wir die folgende Klasse:
public class Caching {
private Map<String, String> cache = new HashMap<>();
public boolean putInCache(String key, String value) {
boolean existedAlready = cache.get(key) != null;
.put(key, value);
cachereturn existedAlready;
}
public String getFromCache(String key) {
return cache.get(key);
}
}
Hier implementieren wir ein einfaches Caching-Modul, das auf einer Map
basiert. Darin können Werte unter bestimmten Schlüsseln abgelegt und dann wieder herausgeholt werden. Implementieren wir nun eine Testklasse, welche die beiden Methoden putInCache()
und getFromCache()
abdecken soll. Daraus entsteht folgender Testcode:
public class CachingTest {
public static Caching caching = new Caching();
@Test
void testPutInCache() {
boolean existedAlready = caching.putInCache("A", "value1");
assertFalse(existedAlready);
}
@Test
void testPutInCacheWhenEntryAlreadyExisted() {
boolean existedAlready = caching.putInCache("A", "value2");
assertTrue(existedAlready);
}
@Test
void testGetFromCache() {
Object value = caching.getFromCache("A");
assertNotNull(value);
assertTrue(value instanceof String);
assertEquals("value2", (String)value);
}
}
Hier haben wir drei Testfälle vor uns. Der erste prüft, ob ein Objekt in einem leeren Cache abgelegt werden kann. Der zweite speichert einen Wert unter einem Schlüssel, der bereits im Cache vorhanden ist und validiert den entsprechenden Rückgabewert der Methode. Schließlich prüft der dritte Testfall, ob ein vorher gespeichertes Objekt wieder aus dem Cache herausgeholt werden kann.
Eine Besonderheit dieser Testklasse besteht darin, dass die Instanz der zu testenden Klasse Caching
nicht in jedem Testfall neu erstellt wird. Stattdessen wird in allen drei Klassen dieselbe Instanz verwendet. Das muss auch sein, denn Testfälle basieren aufeinander. Der zweite Testfall wird nur deswegen erfolgreich durchlaufen, da der erste Testfall bereits einen Wert im Cache-Objekt abgelegt hat. Der dritte Testfall wiederum funktioniert nur deshalb, weil der zweite Testfall den Wert value2 gespeichert hat.
Auf den ersten Blick handelt es sich hierbei um eine Optimierung der Testklasse. Schließlich sparen wir uns den Aufwand in der jeweiligen Vorbereitungsphase jedes Testfalls. In der näheren Betrachtung ist der Testcode allerdings problematisch. Das Ergebnis des Testlaufs ist nämlich von der Ausführungsreihenfolge der Testfälle abhängig. Entscheidet sich das Testframework dafür, die Testmethode testGetFromCache()
als erste aufzurufen, schlägt der Test fehl. Man kann die Testklasse zwar so konfigurieren, dass die Testmethoden in der gewünschten Reihenfolge ausgeführt werden, empfehlenswert ist es jedoch nicht. Zum Einen erschwert eine solche Vorgehensweise die Erweiterbarkeit der Testklasse. Zum Zweiten verhindert sie die Parallelisierung der Testausführung. Und falls das Testframework sich aus irgendwelchen Gründen entscheiden sollte, einzelne Testmethoden in separaten Prozessen auszuführen, würden wir die Inhalte der statischen Variable sowieso verlieren.
Testfunktionen sollten also zustandslos sein. Gehen Sie immer davon aus, dass sie in einer zufälligen Reihenfolge ausgeführt werden, oder sogar parallel zueinander, und setzen Sie die Testklassen entsprechend um. Die Testklasse aus unserem obigen Beispiel hätte folgenderweise aussehen müssen:
public class CachingTest {
@Test
void testPutInCache() {
= new Caching();
Caching caching boolean existedAlready = caching.putInCache("A", "value1");
assertFalse(existedAlready);
}
@Test
void testPutInCacheWhenEntryAlreadyExisted() {
= new Caching();
Caching caching .putInCache("A", "value1");
cachingboolean existedAlready = caching.putInCache("A", "value2");
assertTrue(existedAlready);
}
@Test
void testGetFromCache() {
= new Caching();
Caching caching .putInCache("A", "value2");
cachingObject value = caching.getFromCache("A");
assertNotNull(value);
assertTrue(value instanceof String);
assertEquals("value2", (String)value);
}
}
Nun sind die Testmethoden etwas aufgeblähter, dafür sind sie aber unabhängig voneinander.
Klare Struktur
Die drei Phasen einer Testfunktion (also Vorbereitung, Ausführung und Validierung) sollten klar voneinander getrennt werden. Das ist schon deshalb sinnvoll, da mehrere Testfunktionen dieselbe Vorbereitung erfordern könnten. Ein typisches Beispiel dafür sind in Java Testklassen, die Methoden von komplexen Objekten ausführen, und zwar mehrmals, mit jeweils verschiedenen Argumenten. In einem solchen Fall würde es sich lohnen, die Initialisierung der zu testenden Objektinstanz als eine zentrale Funktion im Testskript anzubieten, auf die alle Testfunktionen Zugriff hätten.
Betrachten wir dazu die folgende Java-Klasse:
public class User {
private String firstName;
private String lastName;
//getter and setter
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (o instanceof User == false) {
return false;
}
= (User)o;
User user
if (!user.getFirstName().equals(this.getFirstName())) {
return false;
}
if (!user.getLastName().equals(this.getLastName())) {
return false;
}
return true;
}
//hashcode
}
Hier liegt uns die Klasse User
vor, die zwei Eigenschaften besitzt: firstName
und lastName
. Beide Eigenschaften können über die entsprechenden Getter- und Setter-Methoden ausgelesen bzw. verändert werden. Zusätzlich prüft die Methode equals
, ob die Objektinstanz die gleichen Inhalte wie die als Argument übergebene Instanz hat.
Der Modultest dieser Methode ist empfehlenswert, allein schon wegen der Tatsache, dass sie nicht optimal ist und in mehreren Fällen unerwartete Ausgaben produzieren wird. Der Einfachheit halber ignorieren wir die Problematik der null
-Werte sowie den Inhalt der hashcode
-Methode und definieren fünf Testfälle:
null
. Erwarte als Ausgabe false
;String
. Erwarte als Ausgabe false
;User
-Objekt, dessen Eigenschaft firstName
einen abweichenden Wert hat. Erwarte als Ausgabe false
;User
-Objekt, dessen Eigenschaft lastName
einen abweichenden Wert hat. Erwarte als Ausgabe false
;true
.In allen fünf Testfällen vergleichen wir denselben Objekt-Inhalt mit verschiedenen Inhalten auf der Gegenseite. Bei der Implementierung der Vorbereitungsphase müssten wir also in jeder der fünf Testmethoden den gleichen Code ausführen, beispielsweise:
= new User();
User testUser .setFirstName("Max");
testUser.setLastName("Mustermann"); testUser
Um Code-Redundanz zu vermeiden, können diese Anweisungen in einer privaten Methode abgelegt werden, die von den Testmethoden in der jeweiligen Vorbereitungsphase aufgerufen wird.
class UserTest {
private User testUser() {
= new User();
User testUser .setFirstName("Max");
testUser.setLastName("Mustermann");
testUserreturn testUser;
}
@Test
void testNull() {
= testUser();
User testUser assertFalse(testUser.equals(null));
}
@Test
void testDifferentType() {
= testUser();
User testUser assertFalse(testUser.equals(""));
}
@Test
void testDifferentFirstName() {
= testUser();
User testUser = new User();
User refUser .setFirstName("Different");
refUser.setLastName("Mustermann");
refUserassertFalse(testUser.equals(refUser));
}
@Test
void testDifferentLastName() {
= testUser();
User testUser = new User();
User refUser .setFirstName("Max");
refUser.setLastName("Different");
refUserassertFalse(testUser.equals(refUser));
}
@Test
void testEqual() {
= testUser();
User testUser = new User();
User refUser .setFirstName("Max");
refUser.setLastName("Mustermann");
refUserassertTrue(testUser.equals(refUser));
}
}
Da die Methode testUser
nicht mit der Annotation @Test
markiert wurde, wird sie von JUnit nicht als eine Testmethode anerkannt und dient ausschließlich der Strukturierung unseres Testcodes. Der Ansatz ist bei Modultests so gebräuchlich, dass JUnit diesem Umstand mit der Annotation @BeforeEach
Rechnung trägt. Damit können Methoden markiert werden, die vor jeder Testmethode ausgeführt werden sollen. Unser Testbeispiel entspricht dann dem folgenden Code:
class UserTest {
private User testUser;
@BeforeEach
private void testUser() {
= new User();
testUser .setFirstName("Max");
testUser.setLastName("Mustermann");
testUser}
@Test
void testNull() {
assertFalse(testUser.equals(null));
}
@Test
void testDifferentType() {
assertFalse(testUser.equals(""));
}
@Test
void testDifferentFirstName() {
= new User();
User refUser .setFirstName("Different");
refUser.setLastName("Mustermann");
refUserassertFalse(testUser.equals(refUser));
}
@Test
void testDifferentLastName() {
= new User();
User refUser .setFirstName("Max");
refUser.setLastName("Different");
refUserassertFalse(testUser.equals(refUser));
}
@Test
void testEqual() {
= new User();
User refUser .setFirstName("Max");
refUser.setLastName("Mustermann");
refUserassertTrue(testUser.equals(refUser));
}
}
Hier verlegen wir den Inhalt des Testusers in eine Instanzvariable, die vor jeder Ausführung einer Testmethode über die Methode testUser
initialisiert wird. Bitte beachten Sie: Obwohl die Testklasse somit theoretisch zustandsbehaftet ist, sind unsere Testmethoden trotzdem absolut unabhängig von der Ausführungsreihenfolge. Dafür sorgt die Annotation @BeforeEach
, welche den Zustand der zu testenden Instanz vor jeder Ausführung zurücksetzt.
Aus der Erfahrung können wir berichten, dass Entwickler leider nur wenig Aufwand für die ordentliche Strukturierung der Modultestskripte betreiben. Der Testcode wird oft als Quellcode zweiter Klasse wahrgenommen, sein Refactoring wird vernachlässigt. Zu Unrecht! Gute Strukturierung bedeutet wie beim Code der Anwendung selbst bessere Lesbarkeit, Erweiterbarkeit und Veränderbarkeit. Behandeln Sie ihren Testcode mit derselben Sorgfalt, wie bei dem Hauptcode, und entwerfen Sie es nach den bewährten Designprinzipien.
Granulare Testfunktionen
Die Testklasse im vorigen Absatz enthält fünf Testmethoden, die allesamt dieselbe Methode des Quellcodes testen. Man könnte sich die Frage stellen: Brauchen wir denn überhaupt fünf Testmethoden? Warum können wir nicht alle Fälle in derselben Testmethode abwickeln? Dann hätten wir einen viel kompakteren Testcode:
class UserTest {
@Test
void testAll() {
= new User();
User testUser .setFirstName("Max");
testUser.setLastName("Mustermann");
testUser
assertFalse(testUser.equals(null));
assertFalse(testUser.equals(""));
= new User();
User refUser .setFirstName("Different");
refUser.setLastName("Mustermann");
refUserassertFalse(testUser.equals(refUser));
.setFirstName("Max");
refUser.setLastName("Different");
refUserassertFalse(testUser.equals(refUser));
.setFirstName("Max");
refUser.setLastName("Mustermann");
refUserassertTrue(testUser.equals(refUser));
}
}
Diese Testklasse wird einwandfrei funktionieren. Wer raten trotzdem davon ab, verschiedene Testfälle in der gleichen Testmethode zusammenzufassen. Eine solche Vorgehensweise macht die Analyse von Testfehlern viel schwieriger und das Hinzufügen neuer Testfälle um einiges aufwändiger.
Implementieren Sie in Ihren Testskripten immer eine Testfunktion pro Testfall. Sie erkennen, dass ihre Testfunktion nicht granular genug ist, daran, dass die Testphasen wie in unserem Beispiel verschränkt werden, und es mehr als einen Aufruf des zu testenden Moduls gibt. Überarbeiten Sie in einem solchen Fall den Testskript, so dass jede Testfunktion genau einen Testfall ausführt.
Einfache Zusicherungen
Zusicherungen sind ein wichtiger Bestandteil eines Modultests, denn ihre Erfüllung oder Nicht-Erfüllung entscheidet über das Ergebnis der Testausführung. Ein Modultestskript kann eine hundertprozentige Code- und Äquivalenzklassenabdeckung garantieren und erfolgreich durchlaufen, ohne vernünftige Zusicherungen sagt seine Ausführung trotzdem nichts über die Qualität des Moduls aus.
Zusicherungen sind Behauptungen über den Verlauf der Testausführung, die wahr sein müssen. Eine nicht-erfüllte Zusicherung führt zu einem Fehlschlag der Testausführung. Das ist in der Regel ein erwünschtes Ergebnis, denn nur so lassen sich Probleme im getesteten Code aufdecken. Allerdings ist ein Fehlschlag der Testausführung mit nachgelagerter Analysearbeit verbunden. Die Entwicklerin oder die Systemadministratorin, die den Test ausführte, muss nun herausfinden, was den Fehler verursachte.
Am einfachsten geht das, wenn die Zusicherungen einfach gehalten sind. Vergleichen Sie die folgenden zwei Zusicherungen in JUnit:
assertEquals(expectedValue, actualValue);
assertTrue(actualList.stream().filter(e -> e.length() > 0).count() == expectedValue);
Wird die erste Zusicherung während der Testausführung nicht erfüllt, meldet JUnit den folgenden Testfehler:
org.opentest4j.AssertionFailedError: expected: <2> but was: <3>.
Dazu wird die Testklasse, die Testmethode und die Zeile genannt, in denen der Fehler aufgetreten ist. Auf den ersten Blick sieht man, dass das produzierte Ergebnis (3) vom erwarteten Wert (2) abweicht. Das erleichtert die Fehlersuche im Modulcode.
Ein Fehlschlag bei der zweiten Zusicherung resultiert in der folgenden Fehlermeldung:
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
Auch hier bekommen wir die Information über die Stelle, an der der Fehler passierte, doch die Fehlersuche gestaltet sich viel schwieriger, weil wir zuerst die Struktur der Zusicherung auseinandernehmen und verstehen müssen.
Versuchen Sie, generische Zusicherungen von der Form assertTrue()
zu vermeiden und nutzen Sie stattdessen spezifischere Zusicherungen. Studieren Sie die Referenz Ihres Testframeworks, um herauszufinden, welche Mittel es Ihnen zur Verfügung stellt. Wenn es sich nicht vermeiden lässt, können Sie die assertTrue()
-Zusicherung mit einer Beschreibung versehen. Für unser Beispiel würde die Optimierung so aussehen:
assertTrue(
.stream().filter(e -> e.length() > 0).count() == expectedValue,
actualList"The resulting list must contain " + expectedValue + " non-empty Strings."
);
Die Fehlermeldung, die wir nun im Falle einer fehlgeschlagenen Testausführung bekommen, sieht schon etwas klarer aus:
org.opentest4j.AssertionFailedError: The resulting list must contain 2 non-empty Strings. ==> expected: <true> but was: <false>
Hier sind ein paar weitere Tipps, wie Zusicherungen in JUnit einfach gehalten werden können.
false
-Wert validieren wollen, nutzen Sie assertFalse(result)
statt assertTrue(!result)
;assertNotEquals(unexpectedResult, actualResult)
statt assertFalse(unexpectedResult.equals(actualResult))
;assertNotNull(nullable)
, um Werte gegen null
zu vergleichen;assertSame(expected, actual)
, statt assertTrue(expected == actual)
, um zu prüfen, ob zwei Referenzen auf dasselbe Objekt zeigen;Modultests entfalten ihre größte Wirkung, wenn sie in einem regelmäßigen Build und Deployment-Prozess automatisch ausgeführt werden. In diesem Kapitel beschreiben wir eine solche Ausführung am Beispiel des Java-Build-Tools Maven. Zuerst wollen wir allerdings die Elemente eines typischen Build-Prozesses durchgehen und den Begriff der Kontinuierlichen Integration (Continuous Integration, CI) einführen.
Kontinuierliche Integration beschreibt den Prozess, bei dem mehrere Entwickler parallel am Quellcode einer Anwendung arbeiten, der in einer gemeinsamen Ablage verwaltet wird. Da bei der Zusammenführung von Codeänderungen typische Synchronisationskonflikte entstehen können, sind Konventionen und Tools gefragt, welche die reibungslose Integration des Codes und die Qualität der Anwendung sicherstellen sollen. Eine wichtige Rolle nehmen dabei Build- und Deployment-Pipelines, auch CI Pipelines genannt, ein. Diese Pipelines sind Skripte, die durch Änderungen am Quellcode ausgelöst werden und fest definierte Schritte durchführen. Eine Aufgabe von CI Pipelines besteht darin, den veränderten Quellcode nach jeder Änderung automatisch zu kompilieren (build) und den resultierenden Maschinencode auf dem Zielsystemen zu installieren (deployment). Die zweite Aufgabe ist es, dafür zu sorgen, dass problematische Änderungen nicht integriert, sondern abgewiesen werden.
Hier kommen Modultests ins Spiel. Ist der Quellcode ausreichend mit Testskripten abgedeckt, und werden alle Modultests nach der Integration einer Änderung ausgeführt, besteht eine gute Chance, dass eine potentielle Regression erkannt wird. In einem solchen Fall würde mindestens ein Modultest fehlschlagen, was zum Abbruch der CI-Pipeline-Ausführung führte. Das Deployment der fehlerhaften Software wäre somit verhindert.
Maven ist ein beliebtes Build-Tool in vielen Java-Entwicklungsumgebungen. Es braucht nur wenig Konfiguration und setzt stattdessen auf Konventionen, um den Build-Prozess auszuführen. So erwartet das Tool in einem Standardfall die folgende Struktur des Quellcodes:
src/main/java/User.java
src/test/java/UserTest.java
pom.xml
Neben dem src
-Verzeichnis, wo der ganze Quellcode inklusive Testklassen untergebracht ist, wird Maven von der Konfigurationsdatei pom.xml
gesteuert. Im einfachsten Fall ist diese Datei sehr klein. An dieser Stelle wollen wir nicht auf ihren Inhalt eingehen. Es reicht zu sagen, dass hier build-spezifische Maven-Einstellungen definiert werden.
Der Quellcode wird in zwei Unterverzeichnisse unterteilt: main
und test
. main
enthält den Quellcode der Anwendung. In test
befinden sich die Testklassen.
Maven kann dann aus dem Verzeichnis ausgeführt werden, in dem die pom.xml-Datei liegt:
> mvn deploy
Das Tool definiert unter anderem folgende Ausführungsphasen:
- Compile
- Test
- Package
- Deploy
Diese werden sequenziell ausgeführt. Uns interessieren vor allem die Phasen Compile
, Test
und Deploy
.
In der Compile
-Phase wird der Quellcode aus dem Verzeichnis src/main in den Bytecode der Java Virtual Machine übersetzt. Aus einer Quellcodedatei User.java
entsteht so die ausführbare Datei User.class
, die Maven zusammen mit anderen kompilierten Dateien in einem bestimmten Verzeichnis ablegt.
In der Test
-Phase werden alle Modultests ausgeführt. Dafür scannt Maven das Verzeichnis src/test
nach Testklassen und lässt sie von JUnit ausführen. So könnte beispielsweise die Testklasse UserTest
ausgeführt werden, um die Klasse User
zu validieren.
Schlägt die Ausführung auch nur einer Testklasse fehl, bricht der Maven-Vorgang ab. War die Ausführung aller Tests erfolgreich, geht der Build-Prozess weiter, so dass der kompilierte Code als eine Java-Bibliothek verpackt und in der Deploy
-Phase in einem Artifact-Repository registriert wird.
Der Maven-Build, den wir hier manuell angestoßen haben, kann auch in ein Continuous Integration
-Tool, wie GitLab oder Azure DevOps, integriert werden, welches dann dafür sorgt, dass das Kommando mvn deploy
nach jeder Code-Änderung ausgeführt wird.
Auf diese Weise wird die Ausführung von Modultests automatisiert.
Nun wollen wir untersuchen, welchen Stellenwert Modultests im typischen Softwareentwicklungsprozess einnehmen. In diesem Zusammenhang beschreiben wir Best Practices beim Einsatz von Modultests in drei verschiedenen Szenarien: Entwicklung einer neuen Anwendung, Weiterentwicklung einer bereits betriebenen Anwendung und das Bug Fixing.
Neuentwicklung
Dieses Szenario ist bei Entwicklern sehr beliebt, denn hierbei können sie den Grad der technischen Qualität der Anwendung sowie ihre Architektur und den Softwareentwicklungsprozess entscheidend mitgestalten.
In diesem Szenario arbeitet das Entwicklerteam meist im Rahmen eines Projekts. Ob die Projektleitung dabei auf das Wasserfallmodell oder auf agile Methodik setzt -- die Anbahnungsphase des Projekts verläuft in jedem Fall ähnlich: Es werden der prozessuale Rahmen für die Entwicklung definiert, die Infrastruktur aufgebaut und das Grundgerüst der Architektur erstellt. Die Implementierung der Quellcodeablage sowie der CI-Pipeline spielt dabei eine große Rolle, da diese die Basis für die weitere Softwareentwicklung bilden und die Effizienz des Entwicklerteams auf lange Sicht beeinflussen werden.
Als Grundlage für die Entwicklung einer Anwendung dienen funktionale Anforderungen, welche von einem Fachexperten definiert werden. Dabei könnte es sich beispielsweise um die Beschreibung eines Webshops oder einer Buchhaltungssoftware handeln. Die funktionalen Anforderungen im Blick, definiert eine Architektin dann die Struktur des technischen Systems, das diese umsetzen soll, wobei sie auch nicht-funktionale Anforderungen, wie Sicherheit, Wartbarkeit und Erweiterbarkeit, berücksichtigt und Komponenten, wie Schichten und Domänen, festlegt. Das Ergebnis ist die Architekturbeschreibung der neuen Anwendung, welche den Rahmen für das nachfolgende technische Design bildet. Beim technischen Design entwirft das Entwicklungsteam die technische Umsetzung der Anforderungen mit den Prinzipien und Mitteln, die die Architektur vorgibt. Komponenten werden dabei weiter zerteilt. Dabei entstehen Module, deren Umsetzung den einzelnen Entwicklern als Arbeitspakete zugeteilt wird.
Man sieht an diesem Vorgang, wie eine (aus technischer Sicht) abstrakte Anforderung immer weiter konkretisiert und in kleinere Teile zerlegt wird, bis am Ende einzelne Codebausteine (Module) vorliegen. Dieser Prozess heißt Schrittweise Verfeinerung. An seinem Ende können Entwickler einzelne Module relativ einfach und unabhängig voneinander implementieren.
Mit der Implementierung der Module ist die Arbeit allerdings noch nicht getan. Jetzt müssen diese miteinander integriert werden, wobei größere Komponenten der Anwendung entstehen. Die Komponenten wiederum müssen in die Gesamtanwendung integriert werden, die irgendwann den Nutzern zur Verfügung gestellt wird, um ihre Anforderungen zu erfüllen. Dieser Abschnitt der Entwicklung geht hier also den der schrittweisen Verfeinerung entgegengesetzten Weg -- den der Schrittweisen Integration. Bitte beachten Sie: Verfeinerung, Implementierung und Integration des Quellcodes müssen nicht drei verschiedene Phasen in dem Entwicklungsprojekt sein. Im Gegensatz raten wir davon ab. Agile Methodiken sehen eine flexiblere Vorgehensweise vor, bei der die Entwicklung einzelner Features auf die drei Phasen aufgeteilt wird, nicht die der ganzen Anwendung. In der Praxis manifestiert sich das in den regelmäßigen Refinement-Meetings auf der einen und der kontinuierlichen Integration mittels CI-Pipelines auf der anderen Seite.
Die Integration des Codes muss getestet werden. Schließlich soll die Anwendung am Ende korrekt funktionieren. Es entstehen dabei in der Regel folgende Testarten:
Wie wir bereits wissen, kann die Ausführung von Modultests vollständig automatisiert werden. Das macht sie relativ einfach und günstig. Integrationstests können ebenfalls automatisiert werden, diese Automatisierung ist allerdings mit einem höheren Entwicklungs- und Pflegeaufwand verbunden. User Acceptance Tests liegen nicht mehr in der Verantwortung des Entwicklerteams, sie werden von den Nutzern durchgeführt, meistens manuell.
In der Theorie würden User Acceptance Tests völlig ausreichen, um die Korrektheit der Anwendung nachzuweisen. Trotzdem spielen Integrationstests und vor allem Modultests eine entscheidende Rolle im Entwicklungsprozess, denn sie reduzieren die Kosten von Fehlern enorm. Wir wollen das an einem konkreten Beispiel demonstrieren.
Gegeben sei die folgende funktionale Anforderung:
Durch den Klick auf den Menü-Punkt "Customers" soll die Übersicht aller Kunden angezeigt werden. Jeder Eintrag soll den Vornamen und den Nachnamen der Kundin enthalten, sowie eine Kundennummer, die aus den ersten drei Buchstaben des Nachnamens und dem Geburtsdatum besteht, z,B, für Maria Schmidt: Sch19690425.
Nun implementiert der zuständige Entwickler die Anforderung und programmiert dabei das folgende Modul:
public class UserToCustomerNumber implements Function<User, String> {
@Override
public String apply(User user) {
return
.getLastName().substring(0, 3)
user+
.getBirthDate().format(DateTimeFormatter.BASIC_ISO_DATE);
user}
}
Die Klasse konvertiert User
-Objekte zu Kundennummern. Dafür nimmt sie die ersten drei Buchstaben des jeweiligen Nachnamens und hängt das Geburtsdatum im entsprechenden Format hinten an.
Der aufmerksamen Leserin wird ein Problem bereits aufgefallen sein: Die Methode apply()
ist nicht auf den Fall vorbereitet, bei dem der Nachname des Kunden kürzer als drei Zeichen ist. Statt eine Kundennummer für den Herrn Jet Li zu generieren, würde beim Aufruf der substring()
-Methode eine IndexOutOfBoundsException
geworfen werden.
Im günstigsten Fall wäre die Funktionalität der Klasse UserToCustomerNumber
ausreichend von einem Modultestskript abgedeckt worden. Die Menge aller möglichen User
-Objekte wäre vom Entwickler des Moduls in Äquivalenzklassen aufgeteilt worden, wobei der Sonderfall mit den kurzen Nachnamen eine eigene Äquivalenzklasse bilden würde. Um den entsprechenden Testfall programmieren zu können, müsste der Entwickler das erwartete Ergebnis mit der Autorin der Anforderung abstimmen. Wir nehmen an, dass kurze Namen dann einfach vollständig in die Kundennummer einfließen sollen.
Spätestens nach der Ausführung der Testsuite in seiner lokalen Entwicklungsumgebung würde der Entwickler merken, dass die Testmethode testShortName()
fehlschlägt. Die Korrektur des Fehlers wäre eine Frage von einigen Minuten.
Vergleichen wir diesen Aufwand mit dem Aufwand, der entstanden wäre, wenn der Entwickler weder Modul- noch Integrationstests für die neue Funktionalität entwickelt hätte.
Der Code wäre in das Code-Repository hochgeladen und mit dem restlichen Code der Anwendung integriert worden. Die Anwendung wäre auf einer Testumgebung installiert, wo manuelle Tests von Testern durchgeführt worden wären. Da Testumgebungen meistens einen reduzierten Datensatz in ihren Datenbanken haben, wäre der Sonderfall mit den kurzen Namen wahrscheinlich nicht entdeckt worden.
Irgendwann würde die Anwendung für den User Acceptance Test auf eine produktionsnahe Umgebung installiert werden. Dort würden die Testnutzer schließlich auf ein Problem stoßen, bei dem der Aufruf der Seite eine kryptische Fehlermeldung zurück gäbe. Das Entwicklerteam fände sich mit drei Problemen konfrontiert:
An diesem Beispiel sieht man die Vorteile von Modultests deutlich. Sie sind zwar nicht in der Lage, alle Fehler aufzudecken. (Integrationsfehler bleiben unerkannt.) Dafür helfen sie während der Entwicklung, die Anzahl der Fehler, die lange unerkannt bleiben, stark zu reduzieren. Daher empfiehlt sich in der Praxis die folgende Testpyramide:
Mit den Mitteln einer CI-Pipeline kann man die Ausführung von Modul- und Integrationstests automatisieren. Dabei ist die erfolgreiche Ausführung der entsprechenden Testsuiten die Voraussetzung für die Ausführung der nachgelagerten Schritte der Pipeline. Schlägt mindestens ein Modultest fehl, wird die entsprechende Änderung nicht mehr in die gemeinsame Ablage integriert. Fehler bei der Ausführung von Tests verhindern dann das Deployment auf der Testumgebung. Es handelt sich bei diesen Einschränkungen um sogenannte Quality Gates. Es können weitere Quality Gates hinzugefügt werden, bei denen Testabdeckung gemessen und nicht ausreichend abgedeckte Änderungen abgewiesen werden.
In der Praxis empfehlen wir die Integration des Codes mittels sogenannter Merge Requests. Dabei wird die Code-Ablage in mehreren Strängen (branches) verwaltet.
Eine Entwicklerin, die an dem Kundennummer-Feature arbeiten soll, wird einen Strang namens customerNumber von dem develop-Strang abzweigen und ihre Änderung mitsamt Modultests in diesem isolierten Strang implementieren. Jedes Speichern ihrer Änderungen in der Ablage löst eine CI-Pipeline aus, die den Code kompiliert und Modultests durchführt. Sie bricht mit einem Fehler ab, falls mindestens ein Test fehlschlägt.
Ist die Änderung fertig, beantragt die Entwicklerin ihre Integration mit dem aktuellen develop-Strang. Dafür erstellt sie einen Merge Request. Ein anderer Entwickler nimmt diesen Merge Request an und führt ihn aus, indem er mit den Mitteln der Codeversionierungssoftware den Code aus dem customerNumber-Strang in den develop-Strang kopiert. Dieser Vorgang löst eine weitere CI-Pipeline aus. Die Pipeline kompiliert den integrierten Code und führt wieder alle Modultests, aber diesmal auch Integrationstests, aus. War die Ausführung erfolgreich, erstellt die Pipeline anschließend den neuen Stand der Anwendung und installiert diese auf eine Entwicklungstestumgebung. Nun können Entwickler die Anwendung aufrufen und ihre Funktionalität begutachten.
Da mehrere Entwickler ihre Änderungen mehrmals am Tag integrieren können, sollte der Stand des develop-Strangs und die entsprechende Installation der Anwendung auf der Entwicklungstestumgebung als instabil betrachtet werden. Daher sind hier keine User Acceptance Tests möglich. Stattdessen wird der aktuelle Stand des develop-Strangs zu abgestimmten Zeitpunkten über einen Merge Request mit dem main-Strang zusammengeführt. Daraufhin weist die entsprechende CI-Pipeline der Anwendung eine stabile Version zu und installiert sie zwecks User Acceptance Test auf der dafür vorgesehenen Testumgebung.
Weiterentwicklung
In dem Szenario einer Neuentwicklung genossen wir als Entwicklerteam den Vorteil eines Sicherheitsnetzes durch Modultests. Da wir die Überprüfung der Modultestabdeckung als notwendigen Teil des Entwicklungsprozesses eingeführt haben, können wir in der Zukunft Änderungen am Code vornehmen, ohne befürchten zu müssen, dass diese zu Regressionen führen. Schliesslich würden automatisierte Modultests in den meisten Fällen dafür sorgen, dass Regressionen schnell erkannt und sichtbar gemacht würden.
Anders verhält es sich bei der Weiterentwicklung von großen, meist alten Anwendungen, deren existierender Code nur wenig bis gar nicht von Modultests abgedeckt ist. In vielen Fällen handelt es sich dabei um Legacy-Anwendungen, die über Jahre und Jahrzehnte weiterentwickelt wurden und deren Code-Qualität entsprechend sehr zu wünschen übrig lässt.
Bei Weiterentwicklung solcher Anwendungen steht das Entwicklerteam vor einem Dilemma: Einerseits sind Module so stark miteinander gekoppelt, dass jede Änderung an einem von ihnen das Risiko von Regressionen in allen anderen Modulen mitbringt. Da keine Modultests existieren, werden solche Regressionen erst spät entdeckt und sind entsprechend kostspielig.
Auf der anderen Seite würde die nachträgliche Entwicklung von Modultests ebenfalls viel Aufwand erfordern, denn der Legacy-Code weist eine schlechte Testbarkeit auf. Da Module miteinander verschmolzen sind, ist die Isolation von Modultestskripten nur durch aufwendiges Mocking zu erreichen. Niedrige Kohärenz sorgt dafür, dass es sehr viele Kombinationen von Eingaben gibt. Hohe zyklomatische Komplexität macht die Abdeckung aller Code-Verzweigungen durch Testfälle zu einer kostspieligen Aufgabe. Code-Optimierung zwecks Verbesserung der Testbarkeit fällt ebenfalls schwierig aus, da sie ihrerseits zu Regressionen führen kann.
Bei diesem Szenario handelt es sich also um einen Teufelskreis. Die Testentwicklung ist aufwändig wegen der nierdigen Testbarkeit. Die Testbarkeit kann aber ohne Tests nur mühsam und unter Risiko erhöht werden.
Dieser Teifelskreis ist wahrscheinlich der Hauptgrund für die Unbeliebtheit von Modultests bei vielen Entwicklern. Nachfolgend wollen wir auf die einzelnen Schritte eigehen, die dem Entwicklerteam dabei helfen sollen, in einem solchen Szenario aus dem Teufelskreis auszubrechen.
Wir haben bereits erläutert, dass Modultests insofern ihre Daseinsberechtigung haben, dass sie viele Fehler schnell aufdecken, die in späteren Entwicklungsphasen hohe Kosten verursachen würden. Zusätzlich bilden sie ein feinmaschiges Sicherheitsnetz, das Regressionen abfangen kann.
Diese beiden Vorteile relativieren sich allerdings im Falle einer Legacy-Anwendung mit minderwertiger Code-Qualität.
Die Entwicklung von Modultestskripten, die schlechten Quellcode abdecken, erweist sich als sehr aufwendig. Der Testcode selbst wird schnell unübersichtlich und aufgebläht. Später, wenn wir den zu testenden Code optimieren sollten, müssten wir auch den Testcode stark anpassen.
Das Argument der dichten Testabdeckung durch Modultests verliert in diesem Szenario ebenfalls an Kraft. Da eine hohe Modultestabdeckung durch niedrige Kohärenz und hohe zyklomatische Komplexität des zu testenden Codes nur schwer realisierbar ist, werden in der Praxis nur die gängigsten Testfälle implementiert.
Daher empfiehlt es sich in einem solchen Szenario, während der ersten Phase der Weiterentwicklung grobe Integrationstests zu definieren und entsprechende Testskripte zu entwickeln. Dadurch erhalten sie ein zwar grobmaschiges aber immerhin vorhandenes Sicherheitsnetz, das sie im zweiten Schritt als Basis für Refactoring und Modultestentwicklung verwenden können.
Finden Sie mit Mitteln der statischen Code-Analyse Stellen im Legacy-Code heraus, die ohne viel Risiko im Sinne der besseren Testbarkeit optimiert werden können. Entfernen Sie unverständliche Kommentare, benennen Sie Variablen um, zerteilen Sie komplexe Ausdrücke in mehrere Teilausdrücke. Packen Sie duplizierten Code in wiederverwendbare Funktionen oder Prozeduren, ersetzen Sie sogenannte Magic Numbers durch Konstanten. Trennen Sie unverständliche oder minderwertige Teile des Codes von den klaren und sauberen Codestellen. Isolieren Sie Codestellen, die mit externen Diensten, dem Dateisystem oder dem Datenbankmanagementsystem kommunizieren, von denen, die reine Berechnungen durchführen. Teilen Sie große Codedateien in mehrere kleinere auf.
Das Ziel soll eine bessere Testbarkeit sein. Vermeiden Sie Änderungen, die die Funktionalität der Anwendung beeinflussen können. Lassen Sie Performanceoptimierungen sein, genauso wie Anpassungen von Code-Teilen, bei denen Sie sich nicht sicher sind, was sie bewirken. Überprüfen Sie regelmäßig die Korrektheit der Anwendung, indem sie Integrationstestssuiten ausführen oder die Funktionalität manuell testen.
Identifizieren Sie Komponenten, die zentrale Bedeutung für die Funktionalität haben und entwickeln Sie Modultestskripte für diese. Fokussieren Sie sich dabei auf Geschäftslogik, arithmetische Berechnungen, logische Entscheidungen sowie Textmanipulationen und -zusammensetzungen. Weitere gute Kandidaten für erste Modultests sind Suchen, Filter, Sortieralgorythmen, Formatierungen und Konvertierungen. Suchen Sie als erstes Code-Stellen aus, die möglichst gut isoliert sind. Ignorieren Sie Code, der Kommunikation mit externen Diensten implementiert.
Nach und nach kann man nun zu komplexeren Refactorings übergehen. Diese sollen parallel von Modultests abgedeckt werden. Wir empfehlen auch hier die testgetriebene Vorgehensweise, bei der Modultestskripte noch vor den Code-Änderungen entwickelt werden. Haben Sie zum Beispiel in einer Java-Anwendung eine Sortiermethode vorliegen, die sehr stark mit dem restlichen Code gekoppelt ist, erstellen Sie ein Interface, das den Vertrag des Sortieralgorythmus definiert, entwickeln Sie danach die Testklasse und refakturieren Sie anschließend den Code, indem sie die Sortierlogik in eine Klasse verlegen, die das neue Interface implementiert. Als Letztes kann die neue Klasse in den Legacy-Code integriert werden, indem eine Insanz davon erstellt und die Sortiermethode aufgerufen wird.
Bug Fixing
In diesem Szenario haben wir es mit einer fertigen Anwendung zu tun, die sich im Betrieb befindet und bei deren Funktionalität unerwartet ein Fehler (Bug) auftritt. Abgesehen von Hardware- und Verbindungsproblemen sowie Ausfällen von externen Diensten, kann ein Bug in einem vorher funktionierenden System aus drei Gründen entstehen:
Ist die Anwendung von einem engmaschigen Sicherheitsnetz aus Modultests geschützt, werden die meisten Vertreter der ersten Bug-Kategorie bereits während der Integration der entsprechenden Änderungen abgefangen. Die meisten Regressionen werden in diesem Fall Fehler bei der Modultestausführung verursachen. Eine fehlgeschlagene Ausführung der CI-Pipeline ist unangenehm, lässt sich aber relativ leicht beheben. Im besten Fall führt die Entwicklerin einer fehlerhaften Änderung die komplette Modultestsuite auf ihrem Rechner aus, bevor sie den Code in die Ablage hochlädt. Trotzdem passiert es in der Praxis immer wieder, dass der ein oder andere Bug durch das Testnetz schlüpft und sich bis zum produktiven Betrieb der Anwendung durchschleust.
Ähnliches gilt für die Bugs der zweiten Kategorie. Das Risiko solcher Bugs hängt mit dem Grad der Modultestabdeckung der Änderung zusammen. Man kann es aber kaum komplett ausschliessen.
Bei den Bugs der dritten Kategorie helfen technische Tests nur wenig. Schließlich basieren diese auf der funktionalen Spezifikation einer Neuentwicklung. Ist diese fehlerhaft, zum Beispiel weil ihr Autor einen Anwendungsfall falsch entworfen hat, wird der Fehler sich erst beim User Acceptance Test oder sogar in Produktion bemerkbar machen. Trotzdem können Modultests auch hierbei hilfreich sein, weil sie Sonderfälle transparent machen können, die von den Autoren der funktionalen Spezifikation übersehen worden sind. In solchen Fällen kann die Entwicklerin schon während der Entwicklungsphase Kontakt mit den Fachexperten aufnehmen, um die Spezifikation verfeinern zu lassen. Kurze Nachnamen bei der Berechnung der Kundennummern, die wir weiter oben erörtert haben, wären ein Beispiel für einen solchen Fall.
Tritt ein Bug auf, muss er analysiert werden. Der erste Schritt der Analyse besteht darin, dass man den Fehler zu reproduzieren versucht. Dabei wird eine Kombination von Eingaben gesucht, die zum Fehler führt. Auf der Nutzerebene könnte der Anfang eines solchen Anwendungsfalls folgendermaßen aussehen: "Lege einen Nutzer mit dem Namen Jet Li an. Klicke auf den Button Customers."
Nun wird die Abweichung der tatsächlichen Ausgabe von der erwarteten Ausgabe exakt beschrieben. Diese Beschreibung ist einem Modultestfehler, wie z. B. JUnit sie produziert, sehr ähnlich, auch wenn sie sich hier auf die Anwendungsebene bezieht. Die Abweichung könnte in unserem Fall so aussehen: "Erwartet wurde die Übersicht aller Nutzer mit ihren Vor- und Nachnamen sowie generierten Kundennummern. Tatsächlich wurde eine Fehlerseite mit dem Test 500 - Unerwarteter Serverfehler ausgegeben."
Entstand der Fehler aufgrund fehlerhafter oder, wie in unserem Fall, unvollständiger Spezifikation, muss diese nun angepasst werden. Eine gewisse Robustheit der Anwendung sowie ihre Resilienz gegenüber falschen Eingaben wird in der Praxis zwar impliziert vorausgesetzt, in unserem Fall existiert aber tatsächlich eine Lücke in der Spezifikation. Der Algorythmus für die Berechnung von Kundennummern wurde für kurze Nachnamen nicht definiert. Das muss nun nachgeholt werden.
Mit der formalen Beschreibung des Fehlers wurde impliziert ein User Acceptance Testfall definiert. Dieser wird von dem Nutzer-Testteam in ihre Testsuite aufgenommen und dazu genutzt, die Korrektheit der Fehlerkorrektur nachzuweisen, sobald diese vorliegt.
Ist der funktionale Fehler klar definiert, beginnt die Arbeit der Entwickler. Diese gehen der technischen Wurzel des Problems nun auf die Spur, indem sie Log-Dateien analysieren, die Anwendung debuggen und den Quellcode statisch durchgehen. Wird der Fehler als ein Problem der Integration von Modulen identifiziert, wird er auch auf dieser Ebene reproduziert, indem relevante Eingaben sowie Abweichungen der Ausgaben beschrieben werden. Das Ergebnis ist dann ein Integrationstestfall, den man in einem entsprechenden Testskript umsetzen kann.
Im Kontext dieses Buches interessieren uns hauptsächlich Fehler, die noch tiefer liegen, nämlich auf der Ebene der einzelnen Module, so wie in dem Beispiel mit der Berechnung von Kundennummern. Im Falle eines solchen Fehlers empfiehlt sich die selbe Vorgehensweise, wie bei den Fehlern auf den höheren Ebenen. Wir definieren also auch hier einen Testfall, dessen Eingaben den Fehler auslösen, und beschreiben die Abweichung von der erwarteten Ausgabe. Im Gegensatz zur Analyse auf der Anwendungs- oder Gesamtcodeebene, haben wir es bei der Analyse des Moduls mit Methodenparametern, Rückgabewerten, Ausnahmen und Zuständen der einzelnen Klassen oder Funktionen als Ein- und Ausgaben zu tun. Ist der Testfall definiert, implementieren wir ihn mit den Mitteln unseres Modultestframeworks. Den so entstandenen Testskript fügen wir der Testsuite zu.
Ist der Modultestskript implementiert, haben wir einen guten Rahmen für die Korrektur des Fehlers. Die zuständige Entwicklerin kann jetzt das Bugfixing vornehmen, wobei ihr der Modultest als Stütze dient. Schlägt er nicht mehr fehl, und ist die Ausführung aller anderen Modultests ebenfalls erfolgreich, so besteht eine hohe Wahrscheinlichkeit, dass der Bug richtig behoben wurde.
In vorigen Abschnitten demonstrierten wir, wie Modultests in den Entwicklungsprozess integriert werden können. Dabei fokussierten wir uns hauptsächlich auf die Testausführung. Damit lernten wir zwar Methoden und Werkzeuge kennen, die Codequalität sicherstellen, aber wie steht es mit der Qualität der Testskripte selbst? Viele Projekte machen den Fehler, dass Modultests im Entwicklungsprozess zwar vorgesehen werden, in der Praxis aber zu bloßer Zeremonie verkommen -- ein notwendiges Übel ohne echten Mehrwert. Wir wollen zeigen, wie die Sinnhaftigkeit von Modultests kontinuierlich gemessen und ausgewertet werden kann. Diese Überwachung bietet zwei Vorteile: Zum Einen weist sie das Entwicklungsteam auf potenzielle Probleme im Testprozess hin. Zum Zweiten können damit fachliche Stakeholder überzeugt werden, die technischen Aspekten ohne fachlichen Mehrwert oft kritisch gegenüberstehen. Betrachten wir zuerst die Metriken, die im Zusammenhang mit Modultests von Interesse sind.
Codeabdeckung
Wie bereits erörtert, stellt diese Metrik den Anteil des Codes dar, der von Modultests abgedeckt ist. Je vollständiger die Testabdeckung, desto stabiler das Sicherheitsnetz, das die betroffene Anwendung gegen Regressionen schützt. Die Testabdeckung sollte innerhalb von CI-Pipelines gemessen werden. Dabei ist vor allem der Trend dieser Metrik interessant, der zeigt, wie sich die Testabdeckung im Laufe der Zeit verändert. Sie kann sinken, wenn neuer Code integriert wird, welcher keine Modultests aufweist, oder steigen, wenn Refactoring- und Testmaßnahmen in einem Legacy-Projekt eingeleitet werden.
Testabdeckung sollte automatisiert gemessen und in Form von maschinenlesbaren Reports in einer Ablage festgehalten werden. In Java-basierten Builds können diese Aufgabe Tools wie JaCoCo und Cobertura erledigen. Der Trend kann aus den Reports berechnet werden, indem diese zu einem Auswertungsserver, wie SonarQube, geschickt werden. SonarQube bietet ein Dashboard, das den Abdeckungstrend beispielsweise in Form einer Kurve visuell darstellt. Zusätzlich lassen sich Schwellenwerte definieren. Sinkt die Testabdeckung unter einen Schwellenwert, wird das Team informiert oder die Integration des Codes verhindert.
Bei der Messung der Testabdeckung reicht eine Zahl in der Praxis nicht aus, denn sie lässt sich leicht manipulieren. So kann man die Testabdeckung ohne viel Aufwand (und ohne besonderen Mehrwert) steigern, indem man alle Getter- und Setter-Methoden mit trivialen Testskripten abdeckt oder bereits abgedeckte Methoden durch sinnfreies Refactoring aufbläht, um mehr abgedeckte Code-Zeilen angerechnet zu bekommen. Neben der Trendkurve sollten also weitere Kriterien der Testabdeckung ausgewertet werden. Teilweise helfen dabei Tools, aber auch eine menschliche Analyse ist immens wichtig. Beachten Sie bei einer solchen Analyse folgende Aspekte:
Beim Optimieren der Testabdeckung ist es wichtig, die Balance zwischen guter Qualität und dem Aufwand für Testerstellung zu wahren. Ein wichtiges Ziel von Modultests ist die Zeitersparnis im Entwicklungsprozess. Es ist daher nicht empfehlenswert, übermäßig viel Aufwand zu betreiben, um nebensächliche Module, die lange problemlos funktionieren, vollständig mit Modultests abzudecken. Setzen Sie also Prioritäten.
Performance
Bei agiler Softwareentwicklung werden Code-Änderungen kontinuierlich integriert. Dabei laufen CI-Pipelines oft heiß. Immer wieder werden sie von Entwicklern angestoßen, die Feature-Branches mergen und Release-Kandidaten erstellen. Dauert die Ausführung einer CI-Pipeline zu lange, droht ein Stau. Dabei nimmt die Ausführung von Modultests einen nicht unwesentlichen Anteil der Pipeline-Laufzeit ein. Eine gute Test-Performance ist daher wichtig. Mit den folgenden Taktiken können Sie sie steigern:
Testcode-Qualität
Die Qualität des Quellcodes einer Software beschreibt nicht nur den Grad seiner Korrektheit, Performance und Sicherheit, sondern auch seine Lesbarkeit, Erweiterbarkeit und Wartbarkeit. Diese letzteren Attribute können durch automatisierte statische Code-Analyse oder manuelle Code-Reviews verbessert werden. Dabei versuchen Tools und menschliche Reviewer typische Quellcode-Schwachpunkte (sogenannte Smells) aufzudecken. Es kann sich dabei um stark verschachtelte Kontrollflussstrukturen handeln, um Code-Redundanz oder um starke Kopplung von Modulen. Code-Smells sollten möglichst vermieden werden, damit der Code wartbar, erweiterbar und lesbar bleibt.
Dasselbe gilt für den Quellcode der Testskripte. Allerdings unterscheidet sich der Testcode in seiner Struktur von dem funktionalen Quellcode der Anwendung. So sind Testfunktionen beispielsweise meist kurz und enthalten keine komplexe Logik. Aus diesem Grund sollte sich die statische Analyse des Testcodes auf bestimmte, für Testskripte typische Smells fokussieren. Nachfolgend zählen wir die wichtigsten davon auf:
assertNotNull(result, "result was null.");
toString()
-Methode eines Kundenobjektes testen, die lediglich auf seinem Vor- und seinem Nachnamen basiert, sollten Sie nur diese beiden Eigenschaften mit Werten initialisieren und die restlichen Eigenschaften, wie Geburtsjahr, Geschlecht oder Bestellhistorie, undefiniert lassen. Das erspart nicht nur Arbeit, sondern steigert auch die Wartbarkeit sowie die Lesbarkeit der Testfunktion.System.out.println("test1");
in Java.Thread.sleep(100);
in Java weisen fast immer auf schwerwiegende Probleme im Testcode hin. Damit wird oft der Versuch unternommen, die Testausführung mit anderen Tests oder mit Threads innerhalb der Testmethode zu synchronisieren. Beides kann zu unzuverlässigen Testergebnissen führen und soll vermieden werden. Timeouts deuten wiederum auf Nutzung von externen Ressourcen hin, was ebenfalls keine gute Praxis ist.try/catch
-Blöcken). Das ist eine schlechte Praxis, da sie zu unzuverlässigen Testergebnissen führen kann. Unerwartete Ausnahmen sollten vom Testframework behandelt werden und zu Testausführungsfehlern führen. Zugesicherte Ausnahmen sollten ebenfalls mit den Mitteln des Testframeworks validiert werden.Effektivität
Die Effektivität einer Testfunktion beschreibt ihre Fähigkeit, Fehler im getesteten Code zuverlässig zu erkennen. In der Praxis wird die Effektivität von Testskripten meistens im Grad der Testabdeckung gemessen. Ist jede Zeile und jede Verzweigung des Quellcodes vom Testcode abgedeckt, geht man in der Regel davon aus, dass die Effektivität der Testsuite optimal ist. Wie wir bereits gezeigt haben, kann es sich dabei um eine Fehlannahme handeln.
Erstens sagt die Testabdeckung nichts über Sonderfälle aus, die durch bestimmte Eingabekombinationen verursacht werden, solange diese nicht in eigenen Kontrollflusspfaden resultieren. Ein typisches Beispiel ist die Anweisung return a / b;
. Hier reicht ein einziger Testfall für die komplette Testabdeckung, jedoch könnte der Sonderfall der Division durch 0 dabei trotzdem untergehen.
Zweitens beschreibt die Testabdeckung lediglich die ausgeführten Pfade des getesteten Codes, nicht aber die korrekte Validierung der dadurch produzierten Ergebnisse. Würde man also alle Zusicherungen aus einer Testsuite entfernen, hätte das keine negativen Auswirkungen auf den Grad der Testabdeckung, wohl aber auf die Effektivität dieser Testsuite.
Neben der Testabdeckung des Quellcodes brauchen wir also weitere Metriken der Testeffektivität, vor allem für kritische Quellcode-Bereiche. Wir haben bereits erläutert, wie alle möglichen Eingabekombinationen in Äquivalenzklassen aufgeteilt werden können, um daraus eine Testsuite für jegliche Sonderfälle zu generieren. Diese Methode ist empfehlenswert, hat allerdings zwei Nachteile. Zum einen gibt es keine Garantie dafür, dass wir alle Äquivalenzklassen der Eingaben erkannt und als Testfälle umgesetzt haben. Zum zweiten besteht nach wie vor das Problem von falschen oder unterlassenen Zusicherungen.
Wie kann sich eine Entwicklerin also sicher sein, dass ihre Testskripte tatsächlich die meisten Code-Fehler erkennen würden, wenn diese vorkämen?
Eine Antwort lautet: Mutant Testing.
Mutant Testing ist eine Technik, bei der der getestete Code absichtlich und auf vielfältige Weisen verfälscht wird. Die vielen falschen Versionen des Codes werden Mutanten genannt. Der Testskript wird dann auf jeden dieser Mutanten angewendet. Seine Qualität wird darin gemessen, wie viele Mutante tatsächlich in Testausführungsfehlern resultieren. Man spricht dabei vom Mutantentöten. Tötet der Testskript keinen der Mutanten, würde er vermutlich auch in der echten Welt kaum Fehler erkennen, welche beispielsweise durch Regressionen entstünden. Werden alle Mutanten getötet, so weist das auf eine gute Qualität des Testskripts hin.
Theoretisch könnte man die falschen Versionen des Quellcodes manuell erstellen und testen, aber das wäre mühsam und redundant. Es existieren Tools, die das automatisiert machen, so wie zum Beispiel PIT für Java. Wir wollen ein Beispiel daraus demonstrieren.
Betrachten wir dazu die folgende Java-Methode:
public boolean shorterThan(String s, int threshold) {
return s.length() < threshold;
}
Diese Methode empfängt eine Zeichenkette sowie einen Schwellenwert und gibt zurück, ob die Länge der Zeichenkette kleiner als der Schwellenwert ist. Dazu entwickeln wir eine Testmethode.
@Test
public void testShorterThan() {
assertTrue(shorterThan("ABCD", 5));
assertFalse(shorterThan("ABCDEF", 5));
}
Wie man einfach sehen kann, deckt dieser Testcode alle Zeilen der getesteten Methode ab -- immerhin enthält diese nur eine einzige Anweisung. An die Zweigabdeckung wurde ebenfalls gedacht, denn die Testmethode validiert sowohl einen false- als auch einen true-Fall.
Wenden wir das PIT-Tool auf diese Testmethode an, erhalten wir allerdings den folgenden Bericht:
- changed conditional boundary → SURVIVED
- negated conditional → KILLED
- replaced return of integer sized value with (x == 0 ? 1 : 0) → KILLED
Es wurden hierbei drei falsche Versionen der Methode testShorterThan()
erstellt. Als erstes entstand so eine Version mit dem falschen Vergleichsoperator. PIT veränderte unter anderem <
durch <=
, um den typischen Grenzfall auszuprobieren. Als Zweites wurde die Bedingung negiert, um einen weiteren Mutanten zu erzeugen. Schliesslich kam ein Mutant ins Leben, der das negierte Ergebnis zurück gibt.
Die Erstellung von Mutanten wird in PIT übrigens durch Manipulationen des Java-Bytecodes zur Testzeit realisiert. Es entstehen dadurch keine zusätzliche Java-Klassen. Das Tool verwendet Mutatoren, die typischen Programmierfehlern entsprechen.
Der Bericht sagt uns, dass ein Mutant überlebte. Das liegt daran, dass unsere Testmethode den Grenzfall nicht überprüft, bei dem die Zeichenkette die Länge hat, welche dem Schwellenwert exakt entspricht. Ein wichtiger Grenzfall, den wir lieber von einem Testfall abdecken sollten.
Mutant Testing sollte nicht in eine CI-Pipeline integriert werden, da diese Technik im Gegensatz zu Testausführung nicht den getesteten Code validiert, sondern den Testcode selbst. Sie kann allerdings von Entwicklern während der Implementierung von Testskripten angewandt werden, um ihre Effektivität zu steigern.
Fehlerdichte
Die Fehlerdichte beschreibt die Anzahl von Programmfehlern in Relation zur Menge des Quellcodes. Niedrige Fehlerdichte ist wünschenswert. Fehlerdichte kann eine wichtige Metrik für die Qualität der Modultestsuite sein. Insbesondere ihr Verlauf über eine längere Zeit kann angeben, ob der Trend der Testabdeckung sich in die richtige Richtung entwickelt.
Erfassen Sie daher produktive Fehler und führen Sie Buch darüber. Beachten Sie dabei, dass im Kontext von Modultests nur Fehler beachtet werden sollen, die auf Modulebene entstanden sind. Fehler aufgrund falscher fachlicher Spezifikation sowie Integrationfehler haben keine Aussagekraft über die Qualität der Modultestsuite. (Sie lassen sich natürlich auf anderen Ebenen ebenfalls mit Gewinn auswerten.)
Neben dem allgemeinen Trend lässt die Fehlerdichte Rückschlüsse darauf zu, welche Codeteile besonders fehleranfällig sind. Diese Information kann nützlich sein, um Modultestentwicklung zu priorisieren. Quellcode, der seit Jahren stabil läuft, kann auch weiterhin ohne Modultests auskommen, während instabile Komponenten gute Kandidaten für Testabdeckung sind.
Ein Tipp für die Praxis: Messen Sie den tatsächlichen Aufwand für die Behebung produktiver Fehler auf Modulebene und stellen Sie diesen Aufwand dem Aufwand gegenüber, der entstanden wäre, wenn diese Fehler von Modultests erkannt würden. Die Ersparnis ist im letzteren Fall enorm und lässt sich bei Stakeholdern und Management wirksam als Argument für die Notwendigkeit der Modultests einsetzen.
Wir haben bereits gezeigt, dass gute Modultests isoliert sind. Die Voraussetzung für diese Isolation ist die Unabhängigkeit des getesteten Codes von externen Diensten und Ressourcen, wie z.B. dem Datenbankmanagementsystem, dem Dateisystem oder Webservices. Nur so lassen sich Modultests überall ausführen, ohne auf Firewall-Einstellungen, Verfügbarkeit von Diensten, Zustände der externen Ressourcen und unzuverlässige Antworten angewiesen zu sein.
Nun hat allerdings der Quellcode der meisten Anwendungen diverse Abhängigkeiten. Eine Nutzerverwaltungsanwendung ergibt ohne eine Datenbankverbindung wenig Sinn. Ein Webbrowser benötigt die Verbindung zum Internet. Sogar einfache Terminal-Anwendungen haben Abhängigkeiten zu ihren Nutzern und zu der Konsole. In diesem Abschnitt werden wir zeigen, wie solche Abhängigkeiten von dem Code der getesteten Module abgekapselt werden können, damit die Testausführung einfacher wird. Dabei nutzen wir an dieser Stelle lediglich die Mittel von Testframeworks. Refactoring des getesteten Codes kann zwar erheblich dazu beitragen, die Testbarkeit zu erhöhen, wir widmen diesem Thema jedoch ein eigenes Kapitel.
Bevor wir zu den Techniken der Isolierung kommen, wollen wir gängige Abhängigkeiten auflisten, mit denen die Anwendungen es in der Praxis zu tun haben.
Jetzt, wo wir gängige Abhängigkeitsarten kennen gelernt haben und wissen, dass wir sie im Kontext von Modultests möglichst eliminieren sollen, stellt sich die Frage: Wie werden wir solche Abhängigkeiten los?
Isolierung von Modultests kann sehr einfach oder sehr aufwändig sein, je nach dem, wie hoch die Testbarkeit des getesteten Moduls ist. Im nächsten Kapitel werden wir zeigen, mit welchen Mitteln wir die Testbarkeit drastisch erhöhen können. An dieser Stelle jedoch erörten wir die zwei grundsätzliche Ansätze zur Isolierung von Modultests ungeachtet der Codequalität.
Attrappen sind meist billige oder vereinfachte Nachahmungen echter Dinge. So wurden im zweiten Weltkrieg massenweise Panzerattrappen aus Holz und anderen preiswerten Materialien produziert, um feindliche Aufklärer in die Irre zu führen. Im Bereich Software sind Attrappen (besser unter dem Begriff Mocks bekannt) einfache Nachbildungen von schwergewichtigen Modulen. Sie werden in Modultests verwendet, um getestete Module von ihren Abhängigkeiten zu entkoppeln. Im Idealfall sind Attrappen so gebaut, dass das getestete Modul gar nicht weiß, dass es mit einer Attrappe und nicht mit seiner echten Abhängigkeit interagiert.
Stellen Sie sich ein Modul vor, das Fremdwährungsbeträge in Euro-Beträge umrechnet. Für diese Umrechnung werden aktuelle Fremdwährungskurse benötigt, die das Modul von einem Webdienst bezieht. Mit der Eingabe des Fremdwährungsbetrages und dem bezogenen Währungskurs gestaltet sich die Umrechnung relativ einfach. Wollen wir die Umrechnungslogik jedoch mit einem Modultest absichern, stehen wir vor einer Herausforderung: Wie werden wir den Währungskursdienst los?
Der tatsächliche Währungskurs wird ermittelt, indem eine HTTP-Client-Implementierung den externen Dienst über das Internet aufruft. Dafür muss zuerst die Währung, für die wir den Kurs brauchen, serialisiert, in eine HTTP-Anfrage verpackt und mit dieser über das Netz zum Server geschickt werden. Danach muss der Client entsprechend dem HTTP-Protokoll auf eine Antwort warten, in der der Währungskurs enthalten ist. Dieser wird deserialisiert, um schliesslich in der Umrechnung verwertet zu werden. Natürlich müssen im Client die Adresse des Servers, mögliche Anmeldedaten, TLS-Einstellungen und sonstige Details konfiguriert werden. Der Client muss dazu auf verschiedene Fehlerfälle, wie den Ausfall des Servers, Netzwerkprobleme und abgelaufene Zertifikate, vorbereitet sein. Alles in allem, eine ziemlich aufwändige Prozedur, die in einem Modultest nichts zu suchen hat.
Auf der anderen Seite sind die übermittelten Informationen trivial. Das Modul gibt einen Währungscode, wie z.B. USD, vor, und seine Abhängigkeit liefert den entsprechenden Kurs in Bezug auf die Euro-Währung. In dem Prozess könnte etwas schief gehen. Das Ganze ist über eine simple Schnittstelle beschreibbar:
interface EuroRateProvider {
float euroRate(String currencyCode) throws IOException;
}
Ist das Modul der Kursumrechnung sauber von seinen Abhängigkeiten abgekapselt, könnte seine Implementierung folgendermaßen aussehen:
class RateCalculator {
private EuroRateProvider euroRateProvider;
public void setEuroRateProvider(EuroRateProvider euroRateProvider) {
this.euroRateProvider = euroRateProvider;
}
public float calculateEuroAmount(float amount, String currencyCode) throws IOException {
float euroRate = euroRate(currencyCode);
* euroRate;
amount }
}
Die Klasse RateCalculator ist lediglich über eine Schnittstelle mit der Abhängigkeit verbunden, die Währungskurse bereitstellt. Ist die Implementierung dieser Abhängigkeit in der Klasse EuroRateProviderImpl untergebracht, können wir sie in eine Instanz der Klasse RateCalculator mit dem folgenden Code injizieren:
= new RateCalculator();
RateCalculator rateCalculator .setEuroRateProvider(new EuroRateProviderImpl()); rateCalculator
Somit enthält unsere Anwendung die komplette Implementierung der Währungsumrechnung.
In einem Modultest für die Klasse RateCalculator wollen wir allerdings keine Verbindung zu einem HTTP-Server aufbauen. Schließlich ist unser Ziel der Test der Klasse an sich, nicht seiner Abhängigkeiten. Also ersetzen wir die Implementierung der Kursbereitstellung durch eine Attrappe (Mock):
class EuroRateProviderMock implements EuroRateProvider {
public float euroRate(String currencyCode) throws IOException() {
return 2.0f;
}
}
Wie wir sehen, liefert die Mock-Implementierung der Abhängigkeit den konstanten Wert 2.0 zurück, statt den richtigen Kurs vom entsprechenden Webdienst zu beziehen. Für unseren Modultest ist es absolut ausreichend, denn die Implementierung erfüllt den Vertrag der Schnittstelle EuroRateProvider. In der Testklasse können wir diese Attrappe als Abhängigkeit der Instanz von RateCalculator injizieren:
= new RateCalculator();
RateCalculator rateCalculator .setEuroRateProvider(new EuroRateProviderMock()); rateCalculator
Nun können wir den Test ausführen, ohne uns auf die schwergewichtige Abhängigkeit verlassen zu müssen. In der Anwendung wäre das konfigurierte Verhalten nicht korrekt, aber im Kontext unseres Modultests kommt die Attrappe gerade recht.
Das Konzept der Integration von Modulen und ihren Abhängigkeiten über Instanzvariablen ist unter dem Begriff Dependency Injection bekannt. Dependency Injection ist eines der wichtigsten Softwaredesignprinzipien und spielt für die Testbarkeit des Quellcodes eine große Rolle. Wir werden dieses Designprinzip im nächsten Kapitel genauer untersuchen.
Mocking ist ein eleganter Kniff, um den Code von seinen Abhängigkeiten loszulösen und Modultestskripte somit zu vereinfachen. Allerdings sind wir bei dieser Technik auf eine gewisse Testabilität des Moduls angewiesen. Um Attrappen einsetzen zu können, muss der getestete Code mit seinen Abhängigkeiten über klar definierte Schnittstellen kommunizieren und uns ermöglichen, die Implementierungen hinter diesen Schnittstellen zu ersetzen. Leider können wir uns nicht immer auf eine solch gute Codequalität verlassen. Oft haben wir es mit Modulen zu tun, die sehr eng mit ihren Abhängigkeiten gekoppelt sind. Betrachten wir zum Beispiel eine alternative, weniger elegante Implementierung der Währungskursumrechnung.
class RateCalculator {
public float calculateEuroAmount(float amount, String currencyCode) throws IOException {
float euroRate = euroRate(currencyCode);
return amount * euroRate;
}
private float euroRate(String currencyCode) throws IOException {
//Beziehe den HTTP-Client.
//Bereite den HTTP-Request vor.
//Sende den HTTP-Request und warte auf die entsprechende Response.
//Entpacke die HTTP-Response und extrahiere den Wert für den Währungskurs.
//Gib den Währungskurs zurück.
return 0.0f;
}
}
In dieser Version verlässt sich die Klasse RateCalculator nicht mehr auf eine externe Abhängigkeit, um Währungskurse zu beziehen, sondern implementiert sie selbst, indem sie die private Methode euroRate() einführt. Was auf den ersten Blick übersichtlicher und einfacher als Dependency Injection erscheint, erweist sich spätestens beim Entwickeln der entsprechenden Testklasse als ein Problem. Die HTTP-Kommunikation kann jetzt nämlich nicht mehr mit normalen Programmiermitteln von der getesteten Klasse abgekoppelt werden. Gleichzeitig wollen wir unseren Modultest aber portabel und leichtgewichtig halten. Wie können wir das in diesem Fall erreichen?
Als erste Wahl käme Refactoring des Moduls in Frage. Mit etwas Anpassung könnten wir den Quellcode auf die Version im ursprünglichen Beispiel zurückführen und dann mit Mocks arbeiten. Allerdings ist das nicht immer eine Option. Legacy-Code ist oft so rigide und komplex, dass jede Änderung ein hohes Risiko von Regressionen mit sich bringen würde. Man müsste in einem solchen Fall zumindest eine grobe Modultestabdeckung erreichen, bevor man sich an das Refactoring heranwagen könnte. Exotische Programmierrichtlinien, fehlende Schreibberechtigungen und geschützte Autorschaft könnten weitere Hindernisse für das Refactoring sein.
Gehen wir davon aus, dass der getestete Code unveränderbar bleiben muss, bleibt uns keine andere Option, als Stubbing einzusetzen. Stubbing ist eine Technik, bei der die Funktionalität einer Methode, Funktion oder Prozedur dynamisch ersetzt wird, um die Testausführung zu erleichtern. In unserem Beispiel würden wir die Funktionalität der privaten Methode euroRate() ersetzen, damit ihr Aufruf einen einfachen, vordefinierten Wert zurück gibt, statt die Netzwerkkommunikation durchzuführen.
In Java kann Stubbing über dynamische Proxies umgesetzt werden. Dynamische Proxies sind Wrapper um die eigentlichen Java-Objekte, die zur Laufzeit erstellt werden und die Funktionalität der Objektmethoden ersetzen. Wir können solche Proxies in den Modultestklassen verwenden, um die ungewünschte Funktionalität aus der Testausführung auszuschliessen. So lässt sich die Netzwerkkommunikation in unserem obigen Beispiel vermeiden, indem wir den Bezug von Währungskursen durch die Nutzung der hartkodierten Werte ersetzen.
Modultestframeworks, wie PowerMock, nutzen dieses Prinzip, um Stubbing zu ermöglichen. In dem folgenden Beispiel wird die private Methode euroRate dynamisch umgeschrieben, damit sie den konstanten Wert 0.5 zurück gibt:
@Test
public void testCalculateEuroAmountWithStubbing() throws Exception {
= new RateCalculator();
RateCalculator rc
= PowerMockito.spy(rc);
rc
.doReturn(0.5f).when(rc, "euroRate", "USD");
PowerMockito
float result = rc.calculateEuroAmount(100f, "USD");
assertEquals(50, result, 0.001);
}
Zuerst erstellen wir ein Objekt als Instanz der Klasse, die wir testen wollen. Als nächstes wird dieses Objekt mit Hilfe der spy-Methode von PowerMockito in einen dynamischen Proxy "eingewickelt". Dann weisen wir PowerMockito an, den konstanten Wert 0.5 zurückzugeben, wenn die Methode euroRate() mit dem Argument "USD" über den dynamischen Proxy aufgerufen wird. Schließlich rufen wir die eigentliche Methode auf und validieren ihre Ergebnisse.
Eine spezielle Art von Modultests sind Tests über Referenztabellen. Dabei wird die Korrektheit des Codes über eine große Menge von quasi beliebig ausgewählten Eingabesätzen mit den zugehörigen Ergebniswerten validiert. Eine solche Art des Testens ist in der Ausführung sehr ineffizient, denn es müssen dabei viele redundante Testfälle durchlaufen werden, deren Eingaben zu gleichen Äquivalenzklassen gehören, und deren Kontrollflüsse gleiche Pfade abdecken. Nichtsdestotrotz spielen Tests über Referenztabellen eine große Rolle in der Praxis und können immens hilfreich sein, vor allem in den Fällen, wo die Spezifikation einer Anforderung vage oder unvollständig ist.
Nehmen wir an, dass unsere Anwendung einen maschinell lesbaren Bericht im csv-Format von einem kostenpflichtigen Internet-Dienst bezieht, um Informationen über europäische Großstädte (Einwohnerzahl, Fläche, Koordinaten usw.) in ihrer Geschäftslogik verwenden zu können. Der Dienst erwartet eine Eingabe in Form eines europäuschen Landescodes und liefert daraufhin ein Dokument in der folgenden Form:
City;Population;Area
London;14241k;1572
Birmingham–Wolverhampton;3058k;69.4
Manchester;3027k;115.6
Leeds–Bradford;2177k;437
Glasgow;1685k;175
Da es sich um relativ statische Daten handelt, könnten wir unsere eigene Großstadt-Datenbank aufbauen und unseren eigenen Web-Dienst darüber betreiben, um Lizenzkosten zu sparen. Allerdings bestünde dann das Risiko einer Regression, falls wir Daten anders formatierten oder ihre Qualität ungenügend wäre. Immerhin ist uns die genaue Spezifikation des externen Dienstes nicht bekannt. Es könnte sein, dass der Dienst-Algorithmus bestimmte Städte mit ihrer englischen Bezeichnung angibt (Munich) oder Umlaute ersetzt (Muenchen). Solche Nuancen sind zahlreich und schwer zu überblicken. Glücklicherweise besitzen wir genügend Beispielergebnisse aus den vorherigen Aufrufen des Dienstes, die wir als Referenzwerte nehmen können. Statt Testfälle zu definieren, indem wir mögliche Codepfade und Äquivalenzklassen der Eingaben abzudecken versuchen, können wir einfach validieren, dass die von unserem Dienst produzierten Ergebnisse den Referenzwerten entsprechen.
Ein weiterer Anwendungsfall ist der Nachweis der Korrektheit des Codes nach einer technischen Migration oder einem Refactoring. Das kann eine Datenbankmigration sein, eine Aktualisierung eines Frameworks auf eine neuere Version oder ein technischer Release mit dem Ziel der Verringerung von technischen Schulden. In solchen Fällen ist die fachliche Spezifikation des Codes wenig relevant, denn es handelt sich um keine funktionale Änderung. Die einzige fachliche Anforderung besteht darin, dass das System sich nach der technischen Anpassung genauso verhält, wie zuvor. Auch hier empfehlen sich Modultests über Referenztabellen.
Einige von uns werden sich vielleicht an ihre Mathe-Schulhefte erinnern, auf deren Rückseiten das kleine Einmaleins abgedruckt wurde. Mussten wir in einer Aufgabe beispielsweise 9x7 rechnen, konnten wir diese Tabelle verwenden, um unser Ergebnis zu überprüfen. Auch das war ein Beispiel für ein Test über Referenzwerte.
Testvorbereitungsphase
Die Vorbereitungsphase beim Testen mit Referenztabellen unterscheidet sich von anderen Testskripten insofern, dass hier viele Eingabesätze und meistens eine ganze Liste von erwarteten Ausgaben berücksichtigt werden müssen. Diese Werte müssen dem Testskript irgendwie übergeben werden. Das kann über eine im Testskript selbst kodierte Datenstruktur passieren, sinnvoller ist es aber oft, eine Ressourcendatei einzubinden, welche die Daten in einem leicht zu lesenden Format enthält (z.B. csv, Comma-Separated Values). Referenztabellen sind keine statischen Testfälle, sie enthalten praxisrelevante Daten und können sich dadurch relativ schnell ändern. Eine in den Test eingebundene Ressourcendatei bietet den Vorteil, leicht ersetzt werden zu können, wenn neue Referenzdaten vorhanden sind.
Die Vorbereitung des zu testenden Moduls unterscheidet sich bei Referenztabellentests wiederum nicht von allen anderen Modultests. Auch hier erstellen wir eine Modulinstanz, bereiten ihren Zustand vor und mocken Abhängigkeiten, falls diese vorhanden sein sollten.
Testausführungsphase
In einem normalen Modultest empfiehlt es sich, genau einen Testfall pro Testmethode zu implementieren. In den meisten Fällen wird dabei das Modul während der Testausführung nur ein einziges Mal aufgerufen. Referenztabellentests unterscheiden sich dabei von den normalen Modultests in dieser Hinsicht insofern, als der getestete Code einmal pro Tabelleneintrag ausgeführt wird. Die Implementierung der Testausführung enthält also eine Schleife, die über alle Ein- und Ausgabensätze iteriert und jeden davon auf das getestete Modul anwendet.
Validierungsphase
Die Validierungsphase sorgt bei Referenztabellentests dafür, dass die von dem Modul produzierten Ergebnisse mit den Erwartungswerten in der Referenztabelle übereinstimmen. Hier stellt sich vor allem die Frage der Nachvollziehbarkeit, falls die Testausführung fehlschlagen sollte. Wenden wir den Test beispielsweise auf eine Referenztabelle mit Hunderten von Einträgen an, und schlägt dieser fehl, wissen wir unter Umständen nicht, wo der Fehler genau liegt. Sinnvolle Fehlerbeschreibungen sind daher wichtig - vor Allem der Index des Referenztabelleneintrages soll in einem Testfehler protokolliert werden. Ebenfalls von Vorteil kann die komplette Durchführung des Tests unabhängig von Testfehlern sein. Führt also der Datensatz Nummer 12 von 300 in der Referenztabelle zu einem Fehler, sollte sich der Test das merken, die restlichen Datensätze aber trotzdem verarbeiten. In einem solchen Fall kommt es am Ende der Ausführung zu einem Testfehlschlag, Die Testentwicklerin erhält aber das komplette Protokoll über alle gefundenen Testfehler (z.B. in Form von Log-Einträgen oder Konsolenausgaben). Dieses kann sie nutzen, um den getesteten Code effizienter zu korrigieren, indem die gesamte Fehlersituation berücksichtigt werden kann.
Modultests sind auch ohne spezielle Frameworks relativ leicht zu implementieren, das gilt auch für Referenztabellentests. Im folgenden Beispiel wollen wir einen Test für die simple Java-Methode entwickeln, welche zwei Ganzzahlen miteinander multipliziert und das Ergebnis zurückgibt.
public long multiply(long a, long b) {
return (int)(a * b);
}
Die Implementierung ist trivial. Falls Sie sich allerdings wundern, warum hier das Ergebnis von long nach int konvertiert wird, tun Sie es zurecht - es ist nämlich keine gute Idee. Wir tun es in dem Beispiel, um später einen Fehler im Test zu demonstrieren.
Nun entwickeln wir eine Testklasse, welche die Methode multiply über eine Referenztabelle validieren soll. Diese Referenztabelle ist nichts anderes als das erweiterte Einmaleins. Zwecks Übersichtlichkeit beschränken wir uns auf nur wenige Einträge und definieren die Referenztabelle direkt im Code als ein multidimensionales Array.
public void testMultiply() {
//Allgemeine Testvorbereitung
String[][] referenceData = {
{"2", "2", "4"},
{"3", "3", "9"},
{"4", "4", "16"},
{"6", "8", "48"},
{"1000000", "1000000", "1000000000000"}
};
= new MultiplicationTesting();
MultiplicationTesting objectUnderTest boolean testRunSuccessful = true;
for (int i = 0; i < referenceData.length; i++) {
//Testvorbereitung
String[] referenceDataset = referenceData[i];
long firstOperand = Long.valueOf(referenceDataset[0]);
long secondOperand = Long.valueOf(referenceDataset[1]);
long expectedResult = Long.valueOf(referenceDataset[2]);
//Testausführung
long actualResult = objectUnderTest.multiply(firstOperand, secondOperand);
//Validierung der Ergebnisse
if (actualResult != expectedResult) {
System.out.println(String.format("Text execution error at index %s. Expected %s but received %s", i, referenceDataset[2], actualResult));
= false;
testRunSuccessful }
}
System.out.println(testRunSuccessful ? "Success" : "Failure");
}
Als Erstes erstellen wir die Referenztabelle und speichern sie in der Variablen referenceData. Die Tabelle besteht aus mehreren Einträgen, von denen jeder drei Werte enthält: die beiden Operanden und das erwartete Ergebnis. So erwarten wir, dass Zwei mal Zwei gleich Vier ist, Drei mal Drei - Neun usw. Da solche Referenztabellen in der Praxis oft den Textdateien entnommen werden, haben wir uns in diesem Beispiel für Werte vom Typ String entschieden, wenngleich die Multiplikation auf Ganzzahlen operiert.
Als Nächstes erstellen wir eine Instanz der Klasse, welche die zu testende Methode enthält und definieren die Variable testRunSuccessful. Diese Variable benötigen wir, weil wir den Test nicht gleich beim ersten Fehler abbrechen, sondern in jedem Fall alle Referenztabelleneinträge durchgehen wollen. Erst am Ende wird der Wert dieser Variable entscheiden, ob unser Test erfolgreich war oder nicht.
Nun gehen wir jeden Eintrag in der Referenztabelle durch und führen in jeder Iteration der Schleife die drei Testphasen aus: Vorbereitung, Ausführung und Validierung. In der Vorbereitungsphase ziehen wir die drei Werte aus dem jeweiligen Tabelleneintrag und weisen sie entsprechend den Variablen für die beiden Operanden und das erwartete Ergebnis zu. In der Ausführungsphase wenden wir die Methode multiply auf die beiden Operanden an und speichern das produzierte Ergebnis in der Variablen actualResult. Die Validierungsphase vergleicht den Wert von actualResult mit dem Referenzwert, der in der Variablen expectedResult abgespeichert ist. Sind beide Werte gleich, verlassen wir die Iteration, sind sie unterschiedlich, melden wir über die Konsolenausgabe einen Fehler und markieren den Test schon mal als Fehlschlag, indem wir der Variablen testRunSuccessful den Wert false zuweisen. Beachten Sie die produzierte Fehlermeldung. Diese gibt nicht nur den erwarteten und den abweichenden Wert an, sondern auch den Index des Referenztabelleneintrages. Das erleichtert die Fehleranalyse in Tests, die mit großen Referenztabellen arbeiten.
Wurde die ganze Referenztabelle abgearbeitet, signalisiert der Test seinen Erfolg oder Fehlschlag basierend auf dem Wert der Variablen testRunSuccessful und endet danach. In unserem konkreten Beispiel wird Folgendes ausgegeben:
Text execution error at index 4. Expected 1000000000000 but received -727379968
Failure
Unser Test schlug also fehl, weil die Ausführung der Methode multiply für den letzten Eintrag der Referenztabelle in einem von dem Referenzwert abweichenden Ergebnis resultierte. Das lag übrigens daran, dass wir das Ergebnis der Multiplikation nach int konvertiert hatten. Große Zahlen, wie sie im letzten Eintrag der Referenztabelle vorkommen, passen nicht in den Datentyp int, was zum Integer-Überlauf und somit zum Fehler führte.
Referenztabellentests sind so gängig, dass große Modultest-Frameworks diese Technik von Haus aus anbieten. In diesem Abschnitt betrachten wir, wie JUnit solche Tests unterstützt.
In JUnit werden Referenztabellentests parametrisierte Tests genannt. Sie werden ab JUnit 5 unterstützt und setzen die zusätzliche Bibliothek junit-jupiter-params voraus. Der von uns manuell implementierte Referenztabellentest für die multiply()-Methode würde in JUnit 5 folgendermaßen aussehen:
@ParameterizedTest
@CsvSource(value = {"2:2:4", "3:3:9", "4:4:16", "6:8:48", "1000000:1000000:1000000000000"}, delimiter = ':')
public void testMultiply(int operand1, int operand2, long expected) {
long actual = (new Calculator()).multiply(operand1, operand2);
assertEquals(expected, actual);
}
Die Annotation ParameterizedTest ersetzt die übliche Test-Annotation und kennzeichnet die Methode testMultiply als Referenztabellentestmethode. Normalerweise sind JUnit-Test-Methoden in sich abgeschlossen und somit parameterlos. Parametrisierte Testmethoden heißen so, weil sie Parameter definieren. In unserem Fall legen wir zwei Operanden vom Typ int und das erwartete Ergebnis als Typ long als Parameter fest.
Die Argumente für diese Parameter werden von der Annotation CsvSource bereitgestellt. Diese enthält mehrere Datensätze, deren Elemente von Sonderzeichen, in unserem Fall :, miteinander verknüpft werden. Das Testframework geht durch alle Datensätze durch und ruft die Testmethode mit den entsprechenden Argumenten auf. Der Datensatz "2:2:4" resultiert im Aufruf testMultiply(2, 2, 4) usw. Beachten Sie die automatische Datentypkonvertierung. Obwohl Datensätze als CSV-Strings bereitgestellt werden, kann JUnit ihre Elemente auf die Parameterdatentypen int und long anwenden.
Die Implementierung der Testmethode selbst ist trivial. Wir wenden die bereitgestellten Argumente auf die zu testende Methode multiply() an und validieren, dass das von ihr gelieferte Ergebnis dem erwarteten Wert entspricht, welcher ebenfalls als Argument an die Testmethode übergeben wurde. Wie auch in unserer manuellen Testmethode wird die Ausführung dieses Tests beim letzten Datensatz fehlschlagen. Die Fehlermeldung zeigt genau, welcher Datensatz zum Problem führte. Ausserdem bekommen wir die übliche Beschreibung der Abweichung vom erwarteten Wert.
org.opentest4j.AssertionFailedError: expected: <1000000000000> but was: <-727379968>
Bei großen Referenztabellen empfiehlt es sich, eine Datei anzulegen und die Datensätze dort zu pflegen. JUnit erlaubt das Vorgehen über die Annotation CsvFileSource, wie das folgende Beispiel zeigt:
@ParameterizedTest
@CsvFileSource(resources = "/data.csv", numLinesToSkip = 1, delimiter = ';')
public void testMultiply(int operand1, int operand2, long expected) {
long actual = (new Calculator()).multiply(operand1, operand2);
assertEquals(expected, actual);
}
Hier implementieren wir dieselben Tests wie im Beispiel vorher, lagern aber die Datensätze in die Datei data.csv, die im Klassenpfad liegen muss. Da die erste Zeile dieser CSV-Datei die Kopfzeile ist, lassen wir die Testmethode sie mit numLinesToSkip = 1 überspringen. Außerdem definieren wir das Trennzeichen für die Elemente jedes Datensatzes, das in unserem Fall ein Semikolon ist.
JUnit bietet noch viele weitere Möglichkeiten der Parametrisierung. Unter anderem kann man spezielle Methoden implementieren, die flexibel Datensätze bereitstellen können. Wir beschränken uns in diesem Abschnitt jedoch auf das Grundkonzept dieser Testart. Weiterführende Informationen finden Sie in der Referenzdokumentation des JUnit 5-Frameworks.
Wie bereits mehrmals hervorgehoben, ist eine der wichtigsten Eigenschaften von Modultests ihre Isoliertheit und Leichtgewichtigkeit. Modultests sollen von keinen Bibliotheken oder Services abhängen, damit sie in jeder Umgebung ausgeführt werden können. Insbesondere gilt das für schwergewichtige Dienste wie das Datenbankmanagementsystem. In vorigen Rezepten zeigten wir bereits, wie Modultests entworfen werden sollen, um den zu testenden Code von der Datenbank abzukoppeln. In den meisten Fällen ist das eine sinnvolle Vorgehensweise.
Wie testet man aber SQL-Anweisungen, die in der Persistenzschicht einer Anwendung vorkommen? Immerhin können sie komplex und unübersichtlich werden. Wir könnten sie aus dem Code rauskopieren und mit einem Datenbanktool ausführen, um zu sehen, ob sie erwartete Werte zurückliefern und Seiteneffekte produzieren, die wir haben wollen. Allerdings ist diese Vorgehensweise aufwändig und nicht automatisierbar.
Ein ähnliches Problem ergibt sich, wenn die Persistenzschicht unserer Anwendung aus Datenbankzugriffscode besteht, der nicht ausprogrammiert, sondern deklarativ erstellt wurde. Das ist beispielsweise bei den ORM-Mappern meist der Fall. Statt die Konvertierung von Code-Objekten auf Datenbanktabellen und umgekehrt selbst in mühsehliger Arbeit zu implementieren, verlassen sich Entwickler in der Regal auf Frameworks und Spezifikationen, die das für sie erledigen. Ein Beispiel ist Java Persistence API (JPA) und ihre Implementierung Hibernate. Diese verwenden Metadaten über dem Sourcecode (sogenannte Annotations), um die Konvertierung deklarativ umzusetzen. Das sieht dann so aus:
@Entity
@Table(name = "user")
public class User implements Serializable {
@Id
@Column(name = "id")
private int id;
}
Hier wurde eine simple Java-Klasse User so annotiert, dass das ORM-Framework genug Informationen hat, um die Konvertierung zwischen Objekten dieser Klasse und den entsprechenden Datensätzen in einer Datenbanktabelle vorzunehmen. Die Annotation @Entity sagt dem Framework demnach, welche Tabelle für die Benutzereinträge genutzt werden soll; @Id und @Column bilden die Objekteigenschaft id auf die entsprechende Tabellenspalte ab und markieren sie als Primärschlüssel.
Eine Methode, die einen Benutzer in der Datenbank speichern würde, bestünde als nicht viel mehr als dem folgenden Code:
= Persistence.createEntityManagerFactory("my-persistence-unit");
EntityManagerFactory entityManagerFactory = entityManagerFactory.createEntityManager();
EntityManager entityManager .persist(user); entityManager
Ein klassischer Modultest dieser Methode brächte auch hier keinen Mehrwert, denn ihr Code besteht zum allergrössten Teil aus der Kommunikation mit der Datenbank über JPA und müsste komplett gemockt werden. Auf der anderen Seite wäre es interessant nachzuweisen, dass unsere Annotationen über den Elementen der User-Klasse korrekt sind und wie erwartet funktionieren.
Ähnlich verhält es sich mit der Bibliothek spring-data aus der Spring-Boot-Familie. Nutzt man sie, entfällt gar die Notwendigkeit, DAO-Klassen zu implementieren. Stattdessen definiert man lediglich ihre Interfaces. Die Bibliothek erstellt die Implementierungen zur Laufzeit, basierend auf den Namenskonventionen bei den Schnittstellenmethoden. Das folgende Beispiel zeigt ein Interface, das solchen Konventionen folgt:
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
findByUsername(String username);
User List<User> findByAgeGreaterThanEqual(int age);
List<User> findByCountry(String country);
}
Aus dem Namen findByAgeGreaterThanEqual leitet die Bibliothek beispielsweise den kompletten Code ab, der alle Benutzer aus der Datenbank lädt, deren Alter größer als oder gleich dem angegebenen Argument für den Parameter age sind.
Auch hier stellt sich die Frage, wie man durch Modultests nachweisen könnte, ob die Namenskonventionen eingehalten wurden, so dass die spring-data-Bibliothek korrekte Implementierungen generieren kann.
Man könnte argumentieren, bei den Tests der obigen Beispiele handelte es sich bereits zwangsläufig um Integrationstests, da die Integration des Anwendungscodes mit dem Datenbankmanagementsystem elementarer Bestandteil des getesteten Codes sei. Das mag stimmen, nichtsdestotrotz wäre es von Vorteil, wenn wir bereits auf der Modultestebene (also frühzeitig und günstig) die meisten der Fehler erkennen würden. Können wir es irgendwie tun, ohne die Isolierung des Modultests zu gefährden? Unter Umständen, ja.
Der zu testende Code ist mit der Datenbank gekoppelt. Aber es muss keine Datenbank sein, die auf einem externen Server betrieben wird. Alles, was unser Beispiel verlangt, ist ein Datenbankmanagementsystem, welches der JDBC/JPA-Schnittstelle von Java genügt. Wir könnten beispielsweise eine "Datenbank" anbinden, die lediglich im Hauptspeicher läuft, minimal aufgebaut ist und trotzdem SQL-Queries ausführen kann. In diesem Sinne ist das Datenbankmanagementsystem eine Attrappe, die allerdings trotzdem die Fähigkeit besitzt, Lese- und Schreiboperationen der Anwendung umzusetzen. So können wir in unserem Modultest eine Nutzerin erstellen, sie in der "Datenbank" speichern, dann wieder auslesen und validieren, ob ihre Eigenschaften immer noch die erwarteten sind. Da die leichtgewichtige Datenbank als Teil des Modultests hochgefahren und danach wieder abgebaut wird, bestehen hier keine externen Abhängigkeiten; unser Modultest ist nach wie vor isoliert.
Natürlich ist es nicht praktikabel, ein komplettes Datenbankmanagementsystem zu programmieren, um eigene Modultests mocken zu können. Wir sind daher auf vorhandene Bibliotheken angewiesen, die genau für diesen Zweck entwickelt wurden. Eine solche Bibliothek ist die in Java geschriebene H2 Database Engine. Sie wird unter anderem in Spring Boot eingesetzt, um Modultests auf der Persistenzebene zu entwickeln. Wir wollen uns an dieser Stelle nicht in die Details der Bibliothek vertiefen; diese können in der Spring Boot-Referenz nachgelesen werden. Es sei hier lediglich gesagt, dass die H2-Datenbank als eine Datenquelle in dem Testprogramm konfiguriert und ab da von dem zu testenden Code nicht von einer echten Datenbank unterschieden werden kann.
Der Testcode kann im Falle eines Spring Boot-Programms folgenderweise aussehen:
@Test
public void testUser() {
= new User(1, "Max", "Müller");
User user .save(user);
userRepository
= userRepository.findOne(1);
user assertEquals("Max", user.getFirstName());
}
Hier haben wir nachgewiesen, dass die von uns definierte Repository-Schnittstelle UserRepository den Namenskonventionen entspricht und dass die Annotationen über der Klasse User korrekt sind.
Bitte beachten Sie, dass die hier vorgestellte Vorgehensweise keinerlei Ersatz für einen späteren Integrationstest ist. Sie ist lediglich dazu da, um die Fehler aufzudecken, die auf der Modultestebene erkannt werden können. Integrationstests sind nach wie vor nötig, um zu validieren, ob die Anwendung mit der echten Datenbank kompatibel ist und dort korrekt funktioniert.
Eine typische Herausforderung beim Entwerfen von Modultests ist der Umgang mit dem volatilen Zustand des Testausführungssystems. Problematisch ist hierbei grundsätzlich die unzuverlässige Umgebung des Testsystems, welche die Tageszeit, das kalendarische Datum, die geographische Position des Servers, die Spezifikation des Betriebssystems sowie der Hardware und viele weitere, aus der Testausführungssicht unkontrollierbare Elemente umfasst.
Wir definierten den Zustand des Testsystems bereits als eine Art Eingabe für die Testausführung. Solche Eingaben sind insofern problematisch, da sie instabil sind. Ihre Werte können abhängig von der Umgebung variieren. Die Portabilität und die Zuverlässigkeit von Modultests werden dadurch stark eingeschränkt. Ein Modultest, der auf dem System der Entwicklerin erfolgreich durchläuft, kann auf dem Testserver fehlschlagen, weil dieser ein anderes Betriebssystem verwendet. Eine CI-Pipeline, die tagsüber funktioniert, kann im nächtlichen Build mit einem Fehler abbrechen, weil ein Modultest um Mitternacht anders funktioniert als sonst.
Abgesehen von der Unzuverlässigkeit der Testausführung, stellen zustandsbezogene Testeingaben eine weitere Herausforderung dar: Bestimmte Testfälle lassen sich nur schwer realisieren, wenn der getestete Code von dem Zustand des Ausführungssystems abhängt. Verlässt sich der Code beispielsweise auf die aktuelle Uhrzeit des Testsystems, können wir nicht explizit testen, wie dieser Code sich um Mitternacht verhalten wird, es sei denn, wir würden den Test exakt um Mitternacht ausführen.
Wir demonstrieren die Problematik am Beispiel einer einfachen Java-Methode, welche das aktuelle Datum als Text repräsentieren soll.
public String formatCurrentDate() {
Date date = new Date();
DateFormat dateFormat = new SimpleDateFormat();
String dateRepresentation = dateFormat.format(date);
return dateRepresentation;
}
Wie zuverlässig ist dieser Code im Sinne der Testbarkeit? Auf den ersten Blick handelt es sich um eine einfache Methode, die keine Parameter definiert und somit leicht getestet werden kann. Allerdings wird bei genauer Betrachtung schnell klar, dass wiederholte Ausführung der Methode unterschiedliche Ergebnisse liefern würde. Das liegt daran, dass die Methode den Zustand des Testsystems, konkret die aktuelle Systemzeit, verwertet. Ergebnisse sind also davon abhängig, wann der Test ausgeführt wird. Aber das ist noch nicht alles. Die Repräsentation des Datums und der Uhrzeit wird in verschiedenen Ländern unterschiedlich gehandhabt. "09/11/2001" ist für Amerikaner der elfte September 2001. In Deutschland würden wir dasselbe Datum mit "11.09.2001" repräsentieren. Die Methode SimpeleDateFormat::format(Date) formatiert das übergebene Datum-Objekt abhängig von der geographischen Lage des Systems, auf dem der Code ausgeführt wird. (In Java kann diese Information über Locale.getDefault() abgefragt werden.) Das Ergebnis der Methodenausführung ist also nicht nur von der Zeit der Ausführung abhängig, sondern auch von der Regioneinstellungen des Servers.
Neben der Unzuverlässigkeit stehen wir als Testentwickler vor einem weiteren Hindernis: Es können keine speziellen Sonderfälle getestet werden, da wir keine Kontrolle über die Testeingaben, wie die Systemzeit und Server-Lokale, haben. Führen wir die Methode aus, bekommen wir ein zeit- und region-abhängiges Ergebnis zurück, zum Beispiel "30.11.23, 11:23". Es wäre aber vielleicht auch interessant zu sehen, wie Mitternacht dargestellt wird. Es könnte auf die amerikanische Art mit "12:00" angegeben werden, oder auf die europäische, mit "00:00". Der Test dieses Sonderfalls wäre ohne die Manipulation des Systemzustandes schwierig. Dasselbe gilt für Testfälle, die das Verhalten der Methode in einer bestimmten Region abdecken sollen. Dazu müssten wir die Regioneinstellungen des Systems ändern.
In solchen Fällen bleibt der Testentwicklerin nichts anderes übrig, als Stubbing einzusetzen. Mit bestimmten Frameworks könnte sie im Test das Verhalten der Methoden überschreiben, welche die aktuelle Systemzeit und die Lokale bereitstellen. Allerdings ist diese Vorgehensweise aufwändig und unelegant. Als Entwickler der Klasse können wir den Testentwicklern (also sehr wahrscheinlich uns selbst) die Arbeit erleichtern, indem wir auf die Testbarkeit dieser Klasse achten.
Die Testbarkeit kann verbessert werden, indem der Modulcode von dem Zustand des ausführenden Systems entkoppelt wird. Statt den Zustand direkt in dem Code abzufragen, soll dieser über definierte Eingabearten an die Methode übergeben werden. Dependency Injection ist dafür gut geeignet. Betrachten wir die angepasste Version der Methode von vorher.
public class DateToString {
private Supplier<Date> currentDateSupplier = () -> new Date();
private Supplier<Locale> localeSupplier = () -> new Locale("en", "US");
public String formatCurrentDate() {
int style = DateFormat.MEDIUM;
DateFormat dateFormat = DateFormat.getDateTimeInstance(style, style, localeSupplier.get());
String dateRepresentation = dateFormat.format(currentDateSupplier.get());
return dateRepresentation;
}
public Supplier<Date> getCurrentDateSupplier() {
return currentDateSupplier;
}
public void setCurrentDateSupplier(Supplier<Date> currentDateSupplier) {
this.currentDateSupplier = currentDateSupplier;
}
public Supplier<Locale> getLocaleSupplier() {
return localeSupplier;
}
public void setLocaleSupplier(Supplier<Locale> localeSupplier) {
this.localeSupplier = localeSupplier;
}
}
Die Methode formatCurrentDate() nutzt in dieser Version keine konkrete Date-Instanz mehr. Stattdessen verlässt sie sich auf die Abhängigkeit currentDateSupplier, die als eine Instanzeigenschaft der Klasse definiert wurde. Diese Eigenschaft kann von außen über Getter- und Setter-Methoden auf eine Instanz der Klasse gesetzt werden. Das ist für die Entwicklung von Modultests von Vorteil, denn diese können nun die Bereitstellung der aktuellen Zeit beliebig konfigurieren, so dass auch beliebige konstante Zeitpunkte getestet werden können.
Bitte beachten Sie, dass wir nicht Date selbst, sondern seinen Bereitsteller (supplier) als Eigenschaft deklariert haben. Somit entfällt für uns die Notwendigkeit, vor jeder Formatierung das benötigte Date-Objekt neu zu setzen. Stattdessen entscheiden wir uns direkt nach dem Start der Anwendung für den Bereitstellungalgorithmus und setzen diesen (als Implementierung des Interface Supplier) als Wert der Variable currentDateSupplier. In einem Test können wir die Implementierung schnell über einen Lambda-Ausdruck vorgeben: dateToString.setCurrentDateSupplier(() -> new Date(1)). Diese liefert immer eine konstante Zeit zurück, so dass der Test von einem stabilen Zustand ausgehen kann.
Von Modultests abgesehen wird von currentDateSupplier immer die aktuelle Systemzeit erwartet. Daher setzen wir die Default-Implementierung () -> new Date() direkt auf die Variable. Auf diese Weise ersparen wir den Nutzern der Klasse in den allermeisten Fällen das explizite Setzen der Variable.
Ähnlich verhält es sich mit der Lokalen des Systems. Auch für diese gibt es jetzt einen Bereitsteller (localeSupplier), der in seiner Default-Implementierung die US-Lokale zurückgibt, aber überschrieben werden kann.
Jetzt kann die Methode formatCurrentDate() bequem und einfach getestet werden, wie das Beispiel unten zeigt.
@Test
void testMidnight() {
//Erstelle einen Mitternacht-Zeitstempel.
Calendar day = Calendar.getInstance();
.set(Calendar.MILLISECOND, 0);
day.set(Calendar.SECOND, 0);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.HOUR_OF_DAY, 0);
day.set(Calendar.DAY_OF_MONTH, 1);
day.set(Calendar.MONTH, Calendar.JANUARY);
day.set(Calendar.YEAR, 2023);
dayDate midnight = day.getTime();
//Konfiguriere das zu testende Objekt mit Mocks.
= new DateToString();
DateToString dateToString .setCurrentDateSupplier(() -> midnight);
DateToString.setLocaleSupplier(() -> new Locale("de", "DE"));
DateToString//Führe die zu testende Methode aus.
String currentDateRepresentation = dateToString.formatCurrentDate();
//Validiere das Ergebnis.
assertEquals("01.01.2023, 00:00:00", currentDateRepresentation).
}
Hier haben wir den Fall getestet, bei dem ein Mitternacht-Zeitstempel in der deutschen Region formatiert wird. Der Test wird zuverlässig durchlaufen, ungeachtet der Zeit der Ausführung noch der Region des Servers.
Entkoppeln Sie grundsätzlich den zu testenden Code von dem Zustand des Systems, um bessere Testbarkeit zu erreichen. Achten Sie neben Zeit und Region auf folgende Zustandelemente: