Was ist ein guter Unit-Test? – Und wie entwickelt man ihn?
Um diese Fragen zu beantworten, muss man zunächst den Begriff Unit-Test genauer definieren. Mit den agilen Methoden verbreitete sich die Metapher der Testpyramide, welche in Abb. 1 dargestellt ist [1]: Sie veranschaulicht die optimale Verteilung verschiedener Testarten zur Verifikation von Software. Je weiter nach oben man in der Pyramide geht, desto teurer und langsamer werden die Tests. Die Basis der Pyramide bilden Unit-Tests, weil man sie schnell entwickeln kann und schnelle Rückmeldung über die Qualität des Quellcodes erhält. Beim Vorgehen nach TDD (Test Driven Development) nutzt man diese kurze Feedbackschleife etwa, indem man nach jeder kleinen Änderung am Quellcode alle Unit-Tests ausführt.
Ein Unit-Test deckt eine möglichst kleine Einheit wie etwa eine Methode, ein Modul oder eine Klasse ab. Die klassische Definition, der auch Kent Beck in seinem Buch "Test-driven Development by Example" [2] folgt, erlaubt auch größere Einheiten durch Unit-Tests zu testen. Vladimir Khorikov verwendet den Begriff "Unit of Work", um eine Menge an Klassen und Methoden zu bezeichnen, die ein Unit-Test abdeckt. Die "London School" hingegen verlangt, dass das SUT (System Under Test) nur von Wertobjekten abhängt [3]. Alle anderen Abhängigkeiten muss der Testentwickler durch Test-Doubles [4] ersetzen [5].
Die nächste Stufe der Pyramide bilden Integrationstests, die das Zusammenspiel zwischen Komponenten in einem (Sub-)System testen. Abhängigkeiten zu anderen Systemen, die nicht exklusiv dem getesteten System gehören, muss der Testentwickler durch Test-Doubles ersetzen [5]. Weil die Menge an Abhängigkeiten, die man ersetzen muss, groß sein kann, sind Integrationstests in der Regel aufwändiger zu entwickeln. Das Ausführen exklusiv genutzter externer Abhängigkeiten wie etwa Datenbanken kostet Laufzeit und führt zu einer längeren Feedbackschleife. Deswegen sollte man möglichst viele Szenarien mit Unit-Tests abdecken, um die Menge an Integrationstests gering zu halten.
Die Spitze der Testpyramide bildet der End-to-End-Test. Diesen führt man in einer möglichst realitätsnahen Umgebung durch. Externe Abhängigkeiten ersetzt man nicht mehr durch Test-Doubles, sondern verwendet jene Implementierung, die auch im Produktivsystem zum Einsatz kommt. Durch die vielen externen Abhängigkeiten ist ein End-to-End-Test sehr aufwändig und langsam.
Qualitätskriterien von Tests
Die Basis für gute Unit-Test ist das Erfüllen des F.I.R.S.T-Prinzips [6]:
- Fast: Die Testausführung soll schnell sein, damit man sie möglichst oft ausführen kann. Je öfter man die Tests ausführt, desto schneller bemerkt man Fehler und desto einfacher ist es, diese zu beheben.
- Independent: Unit-Tests sind unabhängig voneinander, damit man sie in beliebiger Reihenfolge, parallel oder einzeln ausführen kann.
- Repeatable: Führt man einen Unit-Test mehrfach aus, muss er immer das gleiche Ergebnis liefern.
- Self-Validating: Ein Unit-Test soll entweder fehlschlagen oder gut gehen. Diese Entscheidung muss der Test treffen und als Ergebnis liefern. Es dürfen keine manuellen Prüfungen nötig sein.
- Timely: Man soll Unit-Tests vor der Entwicklung des Produktivcodes schreiben.
Zudem sollen Unit-Tests den bestehenden Quellcode gegen Regression schützen, wartbar sein und robust gegenüber Restrukturierung (Refactoring) sein [5]. Zuletzt stellt ein guter Unit-Test sicher, dass sich das SUT so verhält, wie es die Spezifikation vorschreibt.
Schutz vor Regression bedeutet, dass Änderungen oder neue Features die bestehende Funktionalität nicht beeinträchtigen. Man will verhindern, dass sich Fehler einschleichen. Unit Tests, die "robust gegenüber Restrukturierung" sind, helfen Entwicklern beim Refactoring bestehenden Quellcodes. Anstatt "False Positives" zu erzeugen, weisen sie den Entwickler auf echte Fehler hin.
Schlecht wartbare Tests bremsen den Projektfortschritt ebenso, wie schlecht wartbarer Produktivcode. Deswegen sollten Tests auch die Clean-Code-Regeln befolgen [6]. Gut wartbare Unit-Tests folgen dem AAA-Muster [6]: Arrange, Act, Assert. In der Arrange-Phase bereitet man das SUT und all seine Abhängigkeiten für den Test vor. In der Act-Phase führt man Operationen am SUT durch und in der Assert-Phase prüft man das Ergebnis der Operationen und entscheidet, ob der Test erfolgreich war.
Der folgende Test demonstriert das AAA-Muster anhand eines Beispiels, das uns von nun an durch den Artikel begleiten wird: Die Methode bool isDecimal(String text, int index) soll true zurückgeben, falls die Zeichenkette in text beginnend an Position index eine gültige Dezimalzahl darstellt.
@Test
void isDecimal_negativeDecimal_ReturnsTrue() {
// Arrange
String text = "-1.5";
int index = 0;
// Act
boolean result = MyDecimal.isDecimal(text, index);
// Assert
assertThat(result, is(true));
}
Roy Osherove führt zudem noch die Eigenschaft Vertrauenswürdigkeit als Kriterium für gute Tests an [7]. Das bedeutet, dass ein Test genau dann fehlschlagen soll, wenn das, was er testet, nicht funktioniert.
Mutation-Testing
Mutation-Testing ist seit fast 50 Jahren bekannt und nach wie vor ein großes Forschungsfeld [8]. Die Idee von Mutation-Testing ist, den Code automatisiert und zufällig zu transformieren. Beispiele für Transformationen sind etwa das Ersetzen von + durch - oder das Löschen von Else-Zweigen. Dann prüft man, ob die vorhandenen Unit-Tests den so erschaffenen Mutanten erkennen. Tun sie das, sagt man, dass sie den Mutanten "töten". Die Qualität von Unit-Tests lässt sich durch das Verhältnis zwischen erschaffenen und getöteten Mutanten messen. Im Gegensatz zu den anderen vorgestellten strukturorientierten Testverfahren misst Mutation-Testing auch, wie gut die Nachbedingungen der Unit-Tests sind. Man kann mit Unit-Tests 100 Prozent Entscheidungsabdeckung erreichen, ohne eine einzige Nachbedingung zu prüfen. Mutation-Testing deckt solche Schwächen auf.
Welche Testverfahren für Unit-Tests gibt es?
Testverfahren für Unit-Tests lassen sich in zwei Kategorien einteilen: Spezifikationsorientierte Testverfahren (Black-Box-Tests) und strukturorientierte Testverfahren (White-Box-Tests). Bei spezifikationsorientierten Verfahren betrachtet man nur die Schnittstelle des SUT und deren Spezifikation. Abb. 2 veranschaulicht diese Betrachtungsweise.
Bei strukturorientierten Testverfahren verwendet man die Information, die man aus dem Quelltext erhält, um möglichst hohe Testabdeckung (in der Literatur auch Testüberdeckung genannt) zu erreichen. Abb. 3 veranschaulicht diese Idee. Es gibt viele verschiedene Metriken zur Bestimmung der Testabdeckung. Der folgende Abschnitt beschreibt einige häufig verwendete Metriken und deren Eigenschaften.
Strukturorientierte Testverfahren
Um strukturorientierte Verfahren anhand eines Beispiels erklären zu können, verwenden wir nun die Implementierung unserer Beispielmethode isDecimal. Der Quelltext enthält einige Fehler. Im Folgenden wird man sehen, welche dieser Fehler man mit welchen Testverfahren aufdecken kann. Abb. 4 zeigt den Kontrollfluss der Methode isDecimal. Das Diagramm hilft bei der Veranschaulichung der vorgestellten Metriken.
public static boolean isDecimal(String text, int index) {
if (text.charAt(index) == '-') {
index++;
}
for (int i = index; i < text.length(); i++) {
final char c = text.charAt(i);
if (c != '.' && (c < '0' || c > '9')) {
return false;
}
}
return true;
}
Anweisungsüberdeckung
Die Anweisungsüberdeckung misst das Verhältnis zwischen ausgeführten Anweisungen und Anweisungen insgesamt. Um 100 Prozent Anweisungsüberdeckung zu erreichen, muss man genug Tests schreiben, um jede Anweisung im SUT einmal auszuführen [9]. Kann man Anweisungen nicht durch Unit-Tests ausführen, so handelt es sich um toten Code. Im konkreten Beispiel isDecimal reichen zwei Tests, um 100 Prozent Anweisungsüberdeckung zu erreichen:
isDecimal("-1", 0)
isDecimal("x", 0)
Entscheidungsüberdeckung
Bei der Entscheidungsüberdeckung misst man das Verhältnis zwischen ausgeführten und möglichen Entscheidungsausgängen [9]. Man muss jeden Pfad eines Programms mindestens einmal betreten, um 100 Prozent Entscheidungsüberdeckung zu erreichen. Dies impliziert 100 Prozent Anweisungsüberdeckung. Auch leere Pfade ohne Anweisungen wie etwa nicht explizit angeführte Else-Zweige müssen einmal betreten werden. Pfad A in Abb. 4 ist ein Beispiel für einen solchen leeren Pfad. Für isDecimal reichen noch immer zwei Tests, um 100 Prozent Entscheidungsüberdeckung zu erreichen:
isDecimal("-1", 0)
isDecimal("x", 0)
Modifizierte Bedingungs-/Entscheidungsüberdeckung
Entscheidungsüberdeckung berücksichtigt keine atomaren Bedingungen. Beispielsweise prüft man bei isDecimal nur den Ausgang der gesamten Entscheidung c != '.' && (c < '0' || c > '9'). Die Ergebnisse der atomaren Bedingungen c != '.', c < '0' und c > '9' beachtet man nicht. Um die Abdeckung dieser atomaren Bedingungen zu messen, verwendet man häuft MC/DC (Modifizierte Bedingungs-/Entscheidungsüberdeckung): Man prüft, ob jede atomare Bedingung die gesamte Entscheidung beeinflussen kann. Die Normen DO-178B und DO-178C für die Luftfahrt verwenden etwa dieses Kriterium. MC/DC ist erfüllt, wenn folgende Bedingungen zutreffen:
- 100 Prozent Anweisungsüberdeckung,
- 100 Prozent Entscheidungsüberdeckung,
- jede atomare Bedingung ergab mindestens einmal wahr und falsch und
- man kann zeigen, dass jede atomare Bedingung das Ergebnis der Entscheidung beeinflusst.
Für isDecimal kann man mit folgenden Tests MC/DC erfüllen:
isDecimal("-1", 0)
isDecimal(".1!", 0)
isDecimal(":", 0)
Anhand der Entscheidungstabelle für die zusammengesetzte Bedingung kann man das nachvollziehen:
\# | c | c != '.' | c < '0' | c > '9' | Entscheidung |
---|---|---|---|---|---|
1 | '1' | Wahr | Falsch | Falsch | Falsch |
2 | '.' | Falsch | Wahr | Falsch | Falsch |
3 | '!' | Wahr | Wahr | Falsch | Wahr |
4 | ':' | Wahr | Falsch | Wahr | Wahr |
- Zeilen 2 und 3 zeigen, dass c != '.' die Entscheidung beeinflussen kann.
- Zeilen 1 und 3 zeigen, dass c < '0' die Entscheidung beeinflussen kann.
- Zeilen 1 und 4 zeigen, dass c > '9' die Entscheidung beeinflussen kann.
Für die beiden anderen Bedingungen ist der Beweis trivial.
Hohe Kopplung zwischen Test und Implementierung
Strukturorientierte Testverfahren haben eine gemeinsame Schwäche: Sie sind sehr eng an das SUT gekoppelt und brechen daher oft bei Umstrukturierung des Quellcodes. Folgendes Beispiel zeigt dieses Problem: Die Methode sortByName(List<User> users) soll Benutzerobjekte alphabetisch nach deren Namen sortieren. Ein Benutzerobjekt besteht aus einer numerischen Benutzer-Id und einem Namen. Nehmen wir nun an, dass wir einen Test mit folgendem Wert für Parameter users schreiben:
(1, "Sandra"), (2, "Xavier"), (3, "Betty"), (4, "Betty"), (5, "Donald")
Nehmen wir weiter an, dass die Implementierung wie folgt aussieht:
public List sortUsers(List users) {
return bubbleSort(users);
}
Da man die Struktur der Implementierung kennt und weiß, dass Bubblesort ein stabiler Sortieralgorithmus ist, kennt man auch die exakte Reihenfolge der zurückgegebenen Liste:
(3, "Betty"), (4, "Betty"), (5, "Donald"), (1, "Sandra"), (2, "Xavier")
Ein Unit-Test, der das Ergebnis mit genau dieser Liste vergleicht ist fragil. Wenn man nun etwa Bubblesort durch Quicksort ersetzt, kann das Ergebnis wie folgt aussehen, weil Quicksort nicht stabil ist. Die Reihenfolge der beiden Betties wurde vertauscht:
(4, "Betty"), (3, "Betty"), (5, "Donald"), (1, "Sandra"), (2, "Xavier")
Der Unit-Test würde brechen, obwohl das Ergebnis laut Spezifikation korrekt ist: Die Einträge sind alphabetisch nach Namen sortiert.
Spezifikationsorientierte Testverfahren
Spezifikationsorientierte Testverfahren betrachten nur die Schnittstelle des SUT und dessen Spezifikation, um daraus Tests abzuleiten. Die folgenden Abschnitte beschreiben ein paar häufig unterrichtete Testverfahren [10].
Äquivalenzklassenbildung
Ziel des Verfahrens ist es, mit möglichst wenig Test eine hohe Abdeckung zu erreichen. Dafür definiert man für jeden Parameter disjunkte Klassen von Wertebereichen, für die man gleiches Verhalten des SUT erwartet. Dann sucht man für jede gefundene Klasse einen Repräsentanten. Äquivalenzklassen und Repräsentanten für den Parameter text der Methode isDecimal wären etwa:
- Der leere String - ""
- Eine Nullreferenz - null
- Ein positiver ganzzahliger Wert - "1"
- Ein negativer ganzzahliger Wert - "-2"
- Eine Zeichenkette mit ungülten Zeichen "x"
- Eine Zeichenkette mit mehreren Minuszeichen "----1"
- Eine Zeichenkette mit mehreren Kommas "1..3"
- usw.
An diesem Beispiel sieht man, dass es Äquivalenzklassen mit gültigen und ungültigen Werten gibt. Man erzeugt nun Testfälle, sodass jeder gültige Repräsentant einmal verwendet wird. Hat eine Methode mehrere Parameter, sollte man immer nur einen mit einem ungültigen Repräsentanten belegen und für die anderen gültige Repräsentanten auswählen. So stellt man sicher, dass eine Fehlerbehandlung nicht durch eine andere verdeckt wird. Um die Verbindung zur Spezifikation vollständig herzustellen, definiert man zuletzt für jeden erzeugten Test den erwarteten Rückgabewert oder Zustand.
Erst durch diese spezifikationsorientierte Methode decken wir Fehler in isDecimal auf: Die leere Zeichenkette und jene mit mehreren Kommata akzeptiert die Methode fälschlicherweise als gültige Dezimalzahlen. Zudem existiert keine Prüfung auf Nullreferenzen, weshalb die Eingabe von null zu einer NullPointerException führt.
Grenzwertanalyse
Die Grenzwertanalyse ist eine Verfeinerung der Äquivalenzklassenbildung. Oft sind Fehler innerhalb einer Äquivalenzklasse nicht gleich verteilt, sondern treten eher an deren Grenzen auf. Derartige Fehler passieren etwa durch eine Verwechslung von > und >=, durch Überlauf des Wertebereichs oder durch falsche Prüfung von Indizes. Fixiert man etwa den Parameter text von isDecimal auf 123.5 und betrachtet den Parameter index, ergeben sich drei Äquivalenzklassen, von denen nur die mittlere gültig ist:
- index < 0
- index >= 0 && index <= 4
- index > 4
Dadurch ergeben sich sechs Werte, welche entweder die Grenze bilden oder direkte Nachbarn eines Grenzwertes sind: -1, 0, 1, 3, 4 und 5. Abb. 5 zeigt die Zahlengerade mit den Äquivalenzklassen und Grenzwerten.
Entscheidungstabellen
Entscheidungstabellen helfen beim Testen von transaktionsorientierter Funktionalität. Man erzeugt eine Tabelle mit Bedingungen (Ursachen) und erwarteten Handlungen (Wirkungen). Jede Spalte der Tabelle ergibt einen Testfall. Oft kann man Spalten zusammenführen, weil einzelne Bedingungen keine Auswirkung auf das erwartete Ergebnis haben. Nehmen wir als Beispiel die Prüfung einer Kreditkartenzahlung [9]:
Bedingungen (Ursachen) | 1 | 2 | 3 | 4 |
---|---|---|---|---|
Limit überschritten | N | J | N | J |
Karte gestohlen | N | N | J | J |
Handlungen (Wirkungen) | ||||
Limit erhöhen | X | |||
Zahlung erlauben | X | X | ||
Sicherheitsdienst informieren | X | X |
Wie man erkennen kann, ist das Limit der Karte unerheblich, wenn die Karte als gestohlen gemeldet wurde. Man kann daher Spalten 3 und 4 zu einem Testfall zusammenführen und die Bedingung "Limit überschritten" beliebig setzen, da sie keine Auswirkung auf das erwartete Ergebnis hat.
Zustandsbasierter Test
Oft lässt sich die Funktionsweise eines Softwaresystems gut durch einen endlichen Automaten beschreiben. Aus dem Zustandsdiagramm erzeugt man eine Zustandsübergangstabelle, die für jeden möglichen Zustandsübergang das auslösende Ereignis, Start- und Endzustand sowie die erwartete Wirkung aufzählt. Beim zustandsbasierten Testen misst man die Überdeckung als Verhältnis zwischen der Anzahl getesteter und möglicher Zustandsübergänge. Betrachtet man immer nur einzelne Übergänge, spricht man von 0-Switch-Überdeckung.
Häufig treten Fehler aber erst durch spezifische Folgen von Zustandsübergängen auf. 1-Switch-Überdeckung etwa misst das Verhältnis zwischen getesteten und möglichen Sequenzen mit zwei aufeinander folgenden Zustandsübergängen [9].
Strukturorientierte vs. spezifikationsorientierte Tests
Beide Kategorien von Testfahren können gegen Regression schützen. Für strukturorientierte Verfahren gibt es Werkzeuge, die Testfälle automatisch erzeugen. Allerdings sichert man nur den aktuellen Zustand der Software ab, stellt aber keine Verbindung zur Spezifikation her. Ein weiterer Nachteil von strukturorientierten Verfahren ist, dass sie oft fragil sind und bei Restrukturierung des Quellcodes brechen [9].
Spezifikationsorientierte Tests dagegen verifizieren den Quellcode gegen die Spezifikation und sind in der Regel robust gegenüber Restrukturierung. Allerdings gibt es momentan keine Werkzeuge zum automatischen Erzeugen spezifikationsorientierter Tests.
strukturorientiert | spezifikationsorientiert | |
---|---|---|
Stabil bei Umstrukturierung | Nein | Ja |
Schutz vor Regression | Ja | Ja |
Verifikation nach Spezifikation | Nein | Ja |
Automat. Erzeugen von Tests | Ja | Nein |
Um optimale Unit-Tests zu schreiben, sollte man für den Entwurf spezifikationsorientierte Verfahren verwenden. Nur um Abdeckung und Qualität der so entwickelten Tests zu erheben, sollte man auf strukturorientierte Verfahren zurückgreifen.
- M. Fowler: TestPyramid
- K. Beck; 2002: Test Driven Development: By Example. Addison-Wesley Longman Publishing Co., Inc., USA.
- Wikipedia: Wertobjekt
- Wikipedia: Test-Doubles
- V. Khorikov; 2020: Unit Testing Principles, Practices and Patterns. Manning Publications. New York. (Seite 30 ff; Seite 190; Seite 42)
- R. Martin; 2009: Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall. USA. (Seite 132)
- R. Osherove; 2014: The Art of Unit Testing, second edition. Manning Publications. New York. (Seite 151 ff)
- Y. Jia, M. Harman; 2011: An Analysis and Survey of the Development of Mutation Testing. IEEE Transactions on Software Engineering, vol. 37, no. 5. (Seite 649 ff)
- G. Bath, J. McKay; 2011: Praxiswissen Softwaretest – Test Analyst und Technical Test Analyst. Heidelberg. (Seite 77 ff; Seite 31 ff)
- R. Black, J. Mitchell; 2011: Advanced Software Testing - Vol. 3. USA.