Unit Tests

Für die meisten, wenn nicht gar für alle, Softwareprojekte ist das Testen unentbehrlich. Egal ob zum Schluss oder während des Projektes. Michael Inden hat in seinem Buch „Der Weg zum Java Profi“ ein ganzes Kapitel dem Thema Unit Tests gewidmet. Dort behandelt er unter anderem die Gründe des Testens, gibt Tipps und stellt einige Testframeworks vor, die dem Entwickler beim Schreiben von Tests unterstützen soll.

Warum testen wir?

Eine Frage, die sich eigentlich jedem Entwickler stellt: Warum wird überhaupt getestet und warum sollte dies mit Unit Tests geschehen? Eigentlich könnte man viele Fälle auch manuell testen. Eigentlich. Doch so einfach, wie man es sich dann doch ausmalt ist es bei Weitem nicht. Man stelle sich vor, dass man ein Stück Code entwickelt. Nach erfolgreichem manuellen Testen wird dieses Codefragment in das Gesamtsystem integriert. Die Frage die man sich dann stellen kann ist, ob jede noch so kleine Funktionalität des Systems auch noch einwandfrei funktioniert. Hat man wirklich alles beachtet? Die nachfolgenden vier Punkte sprechen für das Testen mit Unit Tests.

  • Durch Unit Tests wird jeder noch so kleine Fehler aufgespürt. Dies kann durch Fehlschlagen des Tests oder durch Auslösen einer Exception passieren. Bei fehlgeschlagenen Tests weicht das Ergebnis von der Erwartung ab. Natürlich muss sich der Entwickler überlegen, welche Parameter in seinen Test einfließen, so dass möglichst viele Testfälle abgedeckt sind.
  • Tests, die die Funktionen der Software prüfen, stellen ein Qualitätskriterium. Es macht einen Unterschied, ob man Funktionalität durch reine Aussagen oder durch geschriebene Tests belegen kann. Zudem kann man durch Unit Tests aufzeigen, welche Fälle geprüft wurden. Oftmals erkennt man beim manuellen Testen die äußere Qualität von Software. Die innere Qualität, also die Qualität der Technik dahinter, bleibt verborgen. Nur durch die äußere Qualität lassen sich keine Rückschlüsse auf die Technik ziehen.
  • Ein weiterer Grund, warum Testen wichtig ist, ist der Abgleich zwischen Spezifikation und Resultat der Entwicklung. Durch Testen von Werten kann einfach geprüft werden, ob das Stück Software auch das liefert, was in den Anforderungen festgelegt wurde.
  • Tests sind ein Mittel zur Provokation, was ein Entwickler einsetzen kann, um die Stabilität der Software zu prüfen. Wie reagiert die Software bei böswilliger Nutzung? Stürzt das komplette System ab oder werden gewollte Fehleingaben abgefangen?

Arten von Tests

Die Kategorisierung von Tests lässt sich anhand von ihrer Automatisierbarkeit und ihrer Einfachheit  vornehmen. Die kleinsten Tests bilden die sogenannten Unit Tests, auch Komponententests genannt. Diese umfassen meist einzelne Funktionen oder Funktionsbibliotheken von Klassen oder Softwarestücken. Die Größe des Tests hängt natürlich auch von der Größe der jeweiligen Funktionalität ab.

Integrationstests bilden die nächste Stufe ab. Sie beschreiben das Zusammenspiel der einzelnen Komponenten. Ist nach einem Einbau zum Beispiel noch eine Funktionstüchtigkeit des Systems gewährleistet?

Die nächsthöhere Stufe bilden Funktionstests ab. Für diese Tests werden  komplette Szenarien abgebildet und durchgespielt. Ab dieser Stufe beginnt langsam das Abwenden von automatisierten Tests und hinwenden zu manuellen Tests.

Die komplette Anwendung wird in Applikationstests geprüft. Wie funktioniert das komplette System unter nahezu realen Umständen. Oftmals versetzen sich Entwickler in Rollen und testen die Software auf Herz und Nieren. Falls der Kunde die Applikation mit der Entwicklung gemeinsam Testen möchte, spricht man von Abnahmetests.

Auswirkungen von Unit Tests

Durch Anwenden von Unit Tests ergeben sich Konsequenzen für das Entwicklerteam:

  • Klares Ergebnis: Tests liefern immer ein Ergebnis. Entweder wird ein Test erfolgreich ausgeführt oder ein Test schlägt fehl. Grauzonen gibt es hierbei nicht.
  • Messbarkeit: Man kann durch Schreiben von Tests Aussagen über die Menge von Tests machen. Dies ist eine konkrete Zahl. Darüber hinaus lassen sich mit Hilfe von Tools Aussagen über die Testabdeckung machen.
  • Wiederholbarkeit: Unit Tests lassen sich beliebig oft wiederholen. Wird ein Stück Software in ein bestehendes Projekt mit Tests eingebaut ist es möglich, die Tests nochmal auszuführen und zu prüfen ob vielleicht ein „defektes“ Codefragment eingebaut wurde.
  • Fokus um den Fehler: Wenn man gerade an einer Funktion schreibt dann befindet man sich noch in der Materie. Auch wenn man noch beim Entwickeln von Tests ist, so weiß oftmals ein Entwickler noch, was die Funktionalität macht und wo der Fehler auftritt. Dies vereinfacht die Fehlersuche und somit die Beseitigung des Fehlers.
  • Know-How: Neben den eben genannten Auswirkungen sorgen Tests für einen Qualitätsschub beim Entwickler selbst. Oftmals reflektiert dieser seine Entwicklung und prüft, ob es nicht noch einen besseren Weg gibt, seinen Code zu implementieren. Daneben helfen Tests auch Projekteinsteigern die Software einfacher zu verstehen, da es fast wie eine Codedokumentation wirkt.

Kleine Tipps für das Testen

  • Jede noch so kleine Änderung sollte getestet werden. Denn es ist nie sicher, welche Resultate die Software liefert. Mit Tests kann man die Erwartungen mit der implementierten Funktion abgleichen.
  • Ein gutes Hilfsmittel bei der Fehlersuche ist das Logging. Durch Ausgeben von Ein- und Ausgabeparameter einer Funktion und zudem durch die Ausgabe selbst lassen sicher Fehler einfacher Lokalisieren und Beheben.
  • Kein Entwickler kann auf Anhieb alle Testfälle abdecken. Das muss er auch nicht. Viele kleine Schritte führen demnach auch zum Ziel. Man kann von klein Anfangen und die Testfälle nach und nach Vergrößern. Natürlich ist hier wichtig, dass der Entwickler die Iterationsschritte abgrenzt.

Iterative Testentwicklung

Man fragt sich oftmals, was eine gute Vorgehensweise bei der Entwickelung von Unit Tests ist. Eine Herangehensweise ist die iterative Testentwicklung. Anstatt einen großen, allumfassenden Test zu schreiben, fängt man mit einem ziemlich kleinen und einfachen Testfall an und steigert sich nach jedem erfolgreichen Test bis so ziemlich alle Fälle abgearbeitet sind. Am besten lässt sich dies durch eine Beispielaufgabe erklären.

Hierbei soll nun nicht die Richtigkeit des Codes demonstriert werden, sondern wie man Schritt für Schritt Tests entwickelt.
Aufgabe ist es eine Verschlüsselung von Zeichenketten zu realisieren, die auf die Caeser-Chiffrierung (ROT-13) zurückgreift. Dabei seien folgende Schnittstellen gegeben:

Der einfachste Fall wäre demnach ein einzelnes Zeichen zu prüfen. Sind die Tests erfolgreich kann man hier weiter ansetzen und ein ganzes Wort prüfen:

So würde man Schritt für Schritt weiter vorgehen und sich in komplexere Fälle steigern (Texte, ungewollte Zeichen, Gemischtes, etc. …). Allerdings muss man abwägen, wie klein man die Iterationen wirklich hält.

Stubs und Mock objects

Sucht man nach Unterstützung in Sachen Unit Tests, so trifft man häufig auf die Begriffe „stubs“ und „mock objects“. Häufig werden die beiden Begrifflichkeiten im Zusammenhang vermischt. Es handelt sich jedoch um zwei verschiedene Ansätze. Martin Fowler hat über die beiden Ansätze auch einen Essay verfasst, wo er auf Besonderheiten und Unterschiede eingeht.[1]

Stub bedeutet übersetzt „Stumpf“ und steht hauptsächlich für Hilfsobjekte oder Hilfsfunktionen die stellvertretend für andere Objekte oder Funktionen stehen. Sie gelten meist als Datenlieferanten, die einen Objektzustand darstellen. Einsatzgebiete sind Hardwaregeräte, aber auch Befehle, die eigentlich über das Netzwerk gesendet werden. Man kann sich eine einfache Funktion vorstellen, die bei einem bestimmten Parameter immer ein bestimmtes Ergebnis liefert.

Mock Objekte („to mock“ = etwas vortäuschen) ersetzen ebenfalls bestimmte Objekte oder Funktionen, allerdings mit einem anderen Ziel. Im Gegensatz zu Stubs konzentrieren sich Mocks eher auf das Objektverhalten. Sie implementieren die Schnittstellen und stellen sicher, dass Aufrufe vollständig und mit korrekten Parameter in der richtigen Reihenfolge durchgeführt werden. Bekannte Einsatzgebiete sind zum Beispiel nicht deterministische Ergebnisse, wie die Uhrzeit oder Temperatursensoren oder schwer auslösbares Verhalten, wie zum Beispiel das Auslösen eines Netzwerkfehlers.

Für nahezu jede Programmiersprache wurden schon Frameworks realisiert, die Mock Objekte und/oder Stubs. Bekannte Vertreter sind zum Beispiel EasyMock, jMock oder Mockito. Der nachfolgende Screenshot soll den kleinen Unterschied zwischen den Begriffen darstellen.

Hamcrest

Michael Inden stellt neben den Stub- und Mock-Frameworks noch Hamcrest als Testing-Tool vor. Das Hauptaugenmerk bei dieser Testing-Bibliothek liegt bei der Nutzung oder Implementierung von Matchern. Bekannt aus den Junit-Framework ist zum Beispiel assert, assertTrue, assertFalse, assertEquals, etc… . Das Tool Hamcrest setzt nur noch auf assertThat und bietet viele sogenannte Matcher an, auf die die Erwartungen geprüft werden können. Matcher sind einfache Funktionen, die dem Entwickler arbeiten abnehmen, da sie beispielsweise Vergleiche durchführen.  Ziel dabei ist es Unit-Tests noch lesbarer zu machen. So werden zum Beispiel Matcher-Funktionen für numerische Vergleiche oder logische Vergleiche angeboten. Darüber hinaus können Listen und Arrays auf einzelne Elemente überprüft werden ohne eine for-Schleife zu benutzen. Daneben gibt es auch einige Matcher für String-Tests. Und sollte mal etwas nicht im Fundus von Hamcrest vorliegen, so kann auch ein eigener Matcher implementiert werden.

Der nachfolgende Screenshot liefert einige Beispiel für Tests unter Hamcrest.

Tool-Unterstützung

Neben Bibliotheken, die die Entwicklung von Tests unterstützen ist auch eine Auswertung der Tests wichtig. So ist es äußerst komplex zu bestimmen, wie hoch die Testabdeckung ist und wo sich eventuell Schlupflöcher gebildet haben. Dafür gibt es allerdings auch Tools, die solche Aufgaben übernehmen und Statistiken für Unit Tests erstellen. Daraus lässt sich dann schließen, wo eventuell Bedarf weiterer Tests besteht und welche Programmteile ausreichend mit Tests versorgt sind. Tools mit CI-Unterstützung sind zum Beispiel Jacoco und Cobertura.

TDD und BDD

Zum Abschluss sollen noch einmal zwei Ansätze beschrieben werden, die in aller Munde sind. Wir haben einmal das Test Driven Development (TDD) und daneben das Behavior Driven Development (BDD).

TDD setzt an einem anderen Punkt an, als die konventionelle Testentwicklung. So werden nicht nach Entwicklung von Code die Tests implementiert sondern vor der eigentlichen Entwicklung. Man geht immer nach dem gleichen Schema vor: Es wird zuerst ein kleiner Test geschrieben, der noch fehlschlägt. Danach wird die Funktionalität soweit implementiert, so dass der Test erfolgreich ausgeführt wird. Als letzten Schritt wird der geschriebene Code reflektiert und gegebenenfalls Änderungsmaßnahmen vorgenommen (Refactoring). Diese Schritte werden solange wiederholt bis die Implementierung steht.

Ein recht ähnlicher Ansatz ist BDD. Der Schwerpunkt liegt allerdings eher im Verhalten der Software. Zu Beginn eines Projektes werden textuell die Anforderungen und Verhaltensweisen der Software erfasst. Die Software wird nach und nach in ihrer Verhaltensweise auch im Quellcode beschrieben. Durch Zuhilfenahme von Mock Objekten wird das Verhalten simuliert. Sukzessive wird danach die Software implementiert, so dass die Mock Objekte nach und nach aus dem Programm verschwinden. Beide Ansätze schließen sich nicht aus und liefern ständig eine Überprüfung der Korrektheit der Software.

Fazit

Testen bleibt nach wie vor ein aktuelles Thema und es liefert entscheidende Vorteile. Zum einen werden Fehler frühzeitig erkannt und behoben und zum anderen verbessert sich die Qualität der Software um einiges. Man setzt sich als Entwickler automatisch intensiver mit dem entwickelten Code auseinander, der noch häufig im Erinnerungsvermögen des Entwicklers ist. Zur Unterstützung kann man auf diverse Frameworks und Tools zurückgreifen, so dass viele Arbeiten stark vereinfacht werden.

Quellen

„Der Weg zum Java-Profi“ von Michael Inden

Links

[1] http://martinfowler.com/articles/mocksArentStubs.html