Tolle Tests

schaut man in die Ansammlungen von Unit Tests, die sich im Laufe der Zeit in Projekten anhäufen, stellt man häufig folgendes fest:

  • Es ist nicht klar, was alles getestet wird. Weder der Name noch der Inhalt der Methode lassen direkt auf den Zweck schließen
  • Die Konstruktion der Testdaten ist in andere Klassen ausgelagert
  • Der Abgleich der Testausgabe gegen die Erwartungswerte ist spartanisch
  • Die Tests verwenden den zu testenden Code

Was zeichnet einen guten Test aus?

Eine Testmethode baut zuerst die Vorbedingung für den Test auf, indem die notwendigen Datenstrukturen initialisiert oder durch Mock-Objekte simuliert werden. Danach erfolgt der Aufruf der zu testenden Methoden um dann den erreichten Zustand oder die Ergebnisse der Berechnung zu prüfen.

Die Tests sollen kurz und kompakt sein, weil sonst die Verständlichkeit leidet. Testmethoden werden sehr selten angeschaut, daher ist die schnelle Verständlichkeit eine der wichtigsten Anforderungen.

Viele kleine Tests, die jeweils eine einzelne Eigenschaft prüfen, sind einigen großen Tests vorzuziehen, weil sich so das Problem für den Entwickler sehr viel schneller analysieren lässt. Schlagen mehrere Tests fehl, gibt das dem Entwickler mehr Anhaltspunkte für eine gezielte Fehlersuche.

Für die Voraussetzungen werden nur die notwendigsten Daten erzeugt, denn alle weiteren Daten sind unnötig und ihre Erzeugung kostet Zeit. Wenn mehrere Tests die gleiche Funktionalität testen und auf den gleichen Daten arbeiten, dann kann man diese in einer Tests Klasse gruppieren und eine gemeinsame Initialisierungsmethode nutzen. In JUnit sind dafür z.B. Methoden vorgesehen, die mit @Before und @BeforeClass annotiert sind.

Die Tests sollten vor der Entwicklung geschrieben werden, um die Anforderungen an den Produktionscode zu beschreiben. Eine Implementierung unter Beachtung dieser Tests, garantiert zum einem die Testbarkeit, aber man gewinnt dadurch gleichzeitig auch eine compilierbare Spezifikation des Produktionscodes.

Test Anti-Pattern

Auch bei der Entwicklung von Tests kann man eine Menge falsch machen. Ein paar gängige Fehler sind hier mal unter dem Label Test Anti-Pattern aufgelistet.

Code Reuse

Da die Unit Test meist von den Entwicklern der Software geschrieben werden, greifen diese auch immer wieder auf existierenden Programmcode zurück.  Dies kann fatale Folgen haben, wenn der Erwartungswert im Test identisch zum Programmergebnis erzeugt wird. Der Test ist erfolgreich, aber das Programm arbeitet dennoch fehlerhaft.

assertThat(document.getElementById(Constant.ID).getLocalName(), equalTo(Constant.START_TAG));

In diesem Beispiel wird geprüft, ob ein Element mit der ID aus Constant.ID in einem XML Dokument den Namen hat, der in der Konstanten Constant.START_TAG definiert wurde. Obwohl der Test erfolgreich ist, kann die XML Datei fehlerhaft sein, wenn die Konstanten fehlerhaft definiert wurden. Besser ist hier immer:

assertThat(document.getElementById("id").getLocalName(), equalTo("start"));

Uneindeutige Prüfungen

Mancher Entwickler freut sich schon, wenn seine Methoden ein Ergebnis liefern, das nicht null ist, oder die Anzahl der Ergebnisse einen bestimmten Wert hat. Im ersten Moment scheint dies meist ausreichend, bei späteren Erweiterungen tauchen dann aber die Probleme auf. In der Regel schlagen die Test bei Anpassungen fehl, weil es mehr oder weniger Einträge gibt. Aber wie lange bleibt ein Fehler unentdeckt, wenn die Liste die gleiche Länge behält, aber falschen Einträge beinhaltet?

assertEquals(3, getAdminUsers().size());

Liefert diese Methode nun die falschen drei User, dann fällt der Fehler im Produktionscode nicht auf. Hier sollte man besser die tatsächlich zurückgelieferten User prüfen. Der Einsatz der Hamcrest Bibliothek liefert hier das Handwerkszeug für sehr kompakte Prüfungen.

assertThat(getAdminUsers(), contains(hasName("Tick"), hasName("Trick"), hasName("Track")));

Tests in Schleifen und bedingenten Anweisungen

Manche Prüfungen in Testmethoden sind kompliziert und beinhalten Schleifen und Bedingungen. Sie erschweren nicht nur das Verständnis, sie sorgen häufig auch für fehlerhafte Prüfungen.

for (User user : getAdminUsers()) {
  assertTrue(user.isAdmin());
  assertEquals("T", user.getName().substring(0, 1));
}

Hier wird geprüft, ob die Namen der User in der Admin Liste alle mit einem “T” beginnen und ob das Admin Flag gesetzt ist. Leider wird nicht geprüft, ob überhaupt ein User in der Liste ist. Bei einer leeren Liste wird keine einzige Prüfung vorgenommen. Solche Prüfungen schreibt man besser um, damit keine Schleifen und Bedingen mehr vorkommen.

assertThat(getAdminUsers(),   
  both(notEmpty()).and(everyItem(both(isAdmin()).and(hasName(startsWith("T"))))));

Testdaten Generatoren

Wenn die Testdaten von verschiedenen Methoden verwendet werden sollen, dann kann man deren Generierung in eine Initialisierung Methode auslagern oder sich ein paar einfache Utility Methoden schreiben. Manches Mal mutiert aber dieser Ansatz wie ein Krebsgeschwür und aus den Hilfsmethoden erwächst ein ganzer Zoo von Hilfsklassen, der einem eigenen Framework gleichkommt. Dabei entgleitet den Teams der Überblick über die tatsächlich notwendigen Daten für einen einzelnen Tests und ein übergroßer universeller Satz wird für alle Tests verwendet. Das Team kann zwar immer sicher sein, dass für jeden Test die notwendigen Daten vorhanden sind, aber es verliert unnötig Zeit bei der Durchführung der Tests. Dadurch wird das Team ausgebremst und es ist außerdem ein Zeichen, dass ein Team die Kontrolle über die Software verliert.

Nur für die Tests geschrieben

Der Testcode ruft Methoden im Produktcode auf und prüft Ergebnisse die der Produktcode liefert. Dafür greift er an Stellen ein, die sich durch die lose Kopplung zwischen den Produktkomponenten ergeben. Bei Test Driven Development, also dem Schreiben von Test bevor der Produktcode entwickelt wird, verschränkt sich dieses Miteinander noch mehr. Den hier ergibt sich die lose Kopplung auch durch die Test.

Manche Entwickler fügen jedoch in den Programmcode öffentliche Konstanten, Methoden oder Konstruktoren ein, die Anfangs nur im Testcode verwendet werden. Meistens aus falsch verstandener Optimierung des Testcodes. Die Tests sind so zwar einfacher zu schreiben, aber es entsteht Produktcode, der nur im Testumfeld korrekt funktioniert.  Wird der Code nicht mehr benötigt, dann entsteht so schwer zu entdeckender toter Code oder dieser Code wird fälschlicherweise im Produktcode verwendet. Manchmal findet man ganze Klassen, die nur für die Tests im Produktcode vorgehalten werden.