Der pragmatische Weg zum Unit-Test

Gregor Ottmann | Februar 23, 2006 on 10:53 am | In Artikel, Know-How |

Unit-Tests sind ein guter Weg, um Software robust zu halten und eine zumindest für Entwickler brauchbare Dokumentation zu schaffen - da ein guter Test alle relevanten Funktionen und Annahmen einer Klasse prüft, stellt er automatisch ein geeignetes Sourcecodebeispiel dar, anhand dessen man die Funktion der getesten Klasse prüfen kann.

In diesem Artikel soll beschrieben werden, wie man Unit-Tests so ansetzen kann, dass sie tatsächlich einen Mehrwert bringen, dabei aber keine überproportionalen Aufwände erzeugen, die in einem regulären Projekt nicht tragbar wären.

Was ist ein Unit-Test und welche Vorteile bringt so etwas?

Entwickler entwickeln gerne zuende, bevor sie tatsächlich testen. Das sieht meist so aus, dass eine bestimmte Funktion des Systems vom Anfang bis zum Ende implementiert wird, bevor die ersten Tests durchgeführt werden - bei diesen Tests handelt es sich dann praktisch immer um Integrationstests, d.h. den Versuch, das komplett zusammengestellte System durch eine Eingabe zu einer bestimmten Reaktion zu bewegen. Diese Vorgehensweise hat einige ganz entscheidende Nachteile:

  • Wenn ein Problem auftritt, ist es schwer, dieses zu lokalisieren. Es ist nicht sofort klar, in welcher Klasse der Fehler ursprünglich aufgetreten ist, der sich dann durch die gesamte Aufrufkette fortgepflanzt hat.
  • Die Testabdeckung ist selten ausreichend, da ein menschlicher Benutzer praktisch nie wirklich alle Funktionen eines Systems prüft, wenn er testet.
  • Die Reproduzierbarkeit der Tests ist nicht gegeben, da es selten gelingt, nach einiger Zeit wirklich exakt nachzuvollziehen, wie man getestet hat.
  • Das Durchführen der Tests kann mit erheblichem Aufwand verbunden sein.

Diese Probleme führen praktisch automatisch zu der Erkenntnis, dass Tests benötigt werden, die automatisierbar, reproduzierbar, einheitlich und auf Einzelkomponenten bezogen sind. Dies ist auch schon eine Definition der Unit-Tests als programmierte Tests, die auf der Ebene einzelner “Code Units” (typischerweise Klassen) die Funktionen eines Systems überprüfen.

Solche Tests haben den großen Vorteil, dass sie “mal eben” ausgeführt werden können und nie vergessen, eine bereits definierte Testfunktion erneut zu testen. So können Seiteneffekte schnell erkannt werden, also Probleme, die an einer Stelle des Codes aufrtreten, die auf den ersten Blick mit einer geänderten Stelle gar nicht zusammenzuhängen scheinen. Diese ständige Konsistenzprüfung des Gesamtsystems sorgt dafür, dass der Entwickler die Angst vor Umbauten verliert, weil er nie befürchten muss, dass eine Korrektur oder Veränderung an einer Stelle des Systems an anderen Stellen zu Katastrophen führt.

Bei all diesen Vorteilen der Unit-Tests darf man jedoch nicht annehmen, dass sie die Integrationstests überflüssig machen. Nur ein Integrationstest kann Fehler aufzeigen, die erst im Zusammenspiel gewisser Komponenten auftreten - jedoch sind die Ergebnisse dieses Tests weitaus leichter zu interpretieren, wenn durch Unit-Tests bereits im Vorfeld sicher gestellt wurde, dass die eigentlichen Komponentenfunktionen nicht die Ursache des Problems sind.

Theorie und Praxis: Das Test-First-Prinzip und seine Grenzen in der Realität

Die Idee der Unit-Tests als Ausgangspunkt der Softwareentwicklung stammt aus der Extreme-Programming-Philosophie (XP) und ist an sich sehr gut. Der reinen Lehre zufolge sollte immer erst das Interface einer Klasse definiert werden, gegen dieses dann ein vollständiger Unit-Test geschrieben wird. Anschließend wird Funktion für Funktion implementiert, bis der Test komplett fehlerfrei durchläuft. So kann sicher gestellt werden, dass tatsächlich für jede Klasse ein kompletter Test existiert, der die Funktion der Klasse lückenlos spezifiziert und prüft.

Der Nachteil des Ansatzes ist, dass er sehr aufwändig ist und von den Entwicklern nicht gut angenommen wird. Nach einer ersten Begeisterungsphase folgt meist die Erkenntnis, dass die Entwicklung der Tests einen größeren Teil der zur Verfügung stehenden Zeit einnimmt, als man für sie erübrigen kann. An diesem Punkt schleicht sich oft eine gewisse Frustration ein und es werden keine oder keine vollständigen Tests mehr implementiert. Spätestens dann, wenn gegen Projektende ein gewisser Zeitdruck hinzukommt, werden dann auch die bereits existierenden Tests nicht mehr an funktionale Änderungen angepasst, so dass die Tests im ersten Schritt nicht mehr richtig durchlaufen und irgenwann auf Grund von Interfaceänderungen nicht einmal mehr kompilierbar sind. Sobald dieser Punkt erreicht ist, wird die Teststrategie als unbrauchbar verworfen, die Testsourcen werden gelöscht und das Team kehrt zum reinen Integrationstest zurück.

Die Aussage des letzten Absatzes mag übermäßig pessimistisch erscheinen, basiert jedoch auf Erfahrungen. Spätestens dann, wenn die erste Klasse ohne Test erstellt wurde und nur ein Kommentar wie “todo: Später testen” im Quellcode erscheint, wird klar, dass das perfekte Modell nur selten mit den Anforderungen des Projektgeschäfts vereinbar ist. Genau wie die Quellcode-Dokumentation neigt der Test dazu, überhaupt nicht geschrieben zu werden, wenn er nicht sofort geschrieben wird. Ist das Konzept der Unit-Tests damit als gescheitert zu betrachten?

Pragmatisches Testen: Mut zur Lücke

Nein, Unit-Tests sind kein Konstrukt aus dem Elfenbeinturm, sofern man sie in pragmatischer Weise umsetzt. Sofern man die Erwartungen an die Testaussage modifiziert, können solche Tests durchaus einen enormen Nutzen in der Softwareentwicklung mit sich bringen, ohne jedes Budget und jede Zeitplanung zu sprengen. Sicherlich gehen dabei einige der theoretisch erreichbaren Vorteile des Test-First-Prinzips verloren, jedoch bleiben genug erhalten, um den Restaufwand zu rechtfertigen. Wie so oft gilt auch hier die 80/20 Regel: Mit 20% des denkbaren Aufwands lassen sich 80% der erreichbaren Nutzeffekte erzielen - um noch mehr Nutzen zu erreichen, steigt der Aufwand jedoch exponentiell.

Im Folgenden sollen einige Strategien zur Testerstellung beschrieben werden, die bei vertretbarem Aufwand einen erheblichen Nutzwert bringen können. Die Liste der Strategien erhebt dabei keinen Anspruch auf Vollständigkeit - das Prinzip sollte jedoch verständlich sein und dazu anregen, immer wieder abzuwägen, wo es sinnvoll sein könnte, einen Test zu implementieren.

Vertraue Deinem Code, doch prüfe Deine Korrekturen!

Jeder Entwickler geht davon aus, dass sein Code weitestgehend funktionstüchtig ist - insbesondere dann, wenn er wenig algorithmische Feinheiten enthält. Diese Einschätzung ist einerseits durch Erfahrung begründet, andererseits dadurch, dass viele Fehler bereits durch kleine Ad-Hoc-Tests während der Codierung gefunden und beseitigt werden. Die Annahme, dass der produzierte Code seine Funktion korrekt erfüllt, kann also meist als durchaus zulässige Arbeitshypothese angesehen werden.

In der Realität passiert es aber nun doch immer wieder, dass als korrekt eingeschätzter Code doch fehlerhafte Ergebnisse liefert - insbesondere dann, wenn unerwartete Eingabewerte übergeben werden oder sich die Infrastruktur nicht so verhält, wie man angenommen hatte. Oder aber einfach deshalb, weil sich eben doch ein Denkfehler eingeschlichen hat. In so einem Fall muss der Code korrigiert werden, um das System in den gewünschten Zustand zu bringen.

Dieser Zeitpunkt der Entwicklung eigent sich hervorragend für die Erstellung eines automatisierten Tests. Schließlich kann es aufgrund des Fehlverhaltens als erwiesen gelten, dass die jeweilige Codestelle als problematisch anzusehen ist. Eventuell sind Abhängigkeiten oder Annahmen offensichtlich geworden, die nicht geprüft wurden. Zuguterletzt muss die korrekte Funktion des Bugfixes ohnehin geprüft werden - und das kann auch gut mit einem Unit-Test erledigt werden.

Diese Strategie zur Implementierung von Unit-Tests, die übrigens von MHenze an mich herangetragen wurde, hat den großen Vorteil, dass nur Stellen geprüft werden, von denen ganz sicher gesagt werden kann, dass ein Fehler denkbar ist. Der Aufwand hält sich in Grenzen und die Gesamttestmenge wächst auf natürliche Weise.

Teste, wenn Du Deinem Code nicht vertraust!

Auch wenn man als Entwickler einem Großteil seines Codes großes Vertrauen entgegenbringt, gibt es immer wieder Punkte, an denen man unsicher ist. Meist handelt es sich dabei um Programmstellen, die einen eher komplizierten Algorithmus enthalten, manchmal auch nur um Stellen, die irgendwie das Gefühl erzeugen, dass sie in gewissen Situationen Probleme machen werden.

Auch diese Codestellen sollte man mit einem Unit-Test abprüfen, statt sie nur im Debugger zu beobachten und anhand einiger Stichproben zu entscheiden, dass der Algorithmus funktioniert. Der Test kann rudimentär sein und nur genau diese Stichproben enthalten - falls man aber jemals an dieser Stelle auf einen Fehler stößt, hat man bereits eine Testgrundlage, die man im Fall des “Testens zur Fehlerprüfung” aus dem vorigen Abschnitt einfach erweitern kann.

Teste, wenn Du der Umwelt nicht vertraust!

Es kommt häufig vor, das man als Entwickler unsicher ist, was gewisse Frameworkfunktionalitäten oder Funktionen der Laufzeitumgebung angeht. In diesem Fall sollte man die Annahmen, die man über die Umgebung trifft, mit einem Unit-Test überprüfen. So kann man sich nicht nur sicher sein, dass die externen Funktionen genau das tun, was man erwartet, sondern man stellt es auch sofort fest, wenn durch ein Update externer Komponenten eine solche Annahme plötzlich nicht mehr zutrifft.

Analog sollte man auch Unit-Tests für externe Komponenten schreiben, wenn der Eindruck entsteht, dass deren Dokumentation nicht korrekt ist. So können die Dokumentationsfehler lokalisiert und die tatsächlichen Funktionen ermittelt werden. Zusätzlich werden die Annahmen, die man so trifft, wiederum durch den Test dokumentiert und bei Updates erneut verifiziert.

Aufbau der Tests

Zum Abschluss soll noch beschrieben werden, wie die Tests aufgebaut werden sollten - für die folgenden Beschreibungen wird als Testframework JUnit angenommen. Dafür braucht man zunächst ein eigenes Verzeichnis für die Testklassen im Projektverzeichnis. Wenn man für die normalen Java-Sourcen beispielsweise ein Source-Verzeichnis namens “src” vorgesehen hat, sollte das Testverzeichnis “src-test” heißen.

Im Testverzeichnis wird die Package-Struktur der zu testenden Klassen exakt nachgebildet, die Testklassen bekommen die Namen der zu testenden Klassen mit dem Suffix “Test”. Die folgenden beiden Dateinamen bezeichnen die Klasse “com.foo.MyClass” und ihre Testklasse:

  • src/com/foo/MyClass.java
  • src/com/foo/MyClassTest.java

Dieser Aufbau macht es extrem einfach, wahlweise nur die Produktivklassen, nur die Tests oder beide zu kompilieren bzw. als JAR zu verteilen.

Innerhalb der Testklasse werden die einzelnen Tests als Methoden angelegt, deren Name mit dem Präfix “test” beginnt. Diese Methoden haben keinen Parameter und keine Rückgabewerte - der Test für eine Methode “echo” könnte wie folgt aussehen:

public void testEcho()
{
MyClass myClass = new MyClass();

try
{
myClass.echo(”foo”);
}
catch(Throwable t)
{
fail(”Echo mit normalen String sollte keinen Fehler erzeugen”);
}

try
{
myClass.echo(null);
fail(”echo(null) sollte eine IllegalArgumentException auslösen!”);
}
catch(IllegalArgumentException ex)
{
// Korrekte Exception
}
}

Externe Verweise

Keine Kommentare vorhanden »

RSS-Feed für Kommentare zu diesem Beitrag. TrackBack URI

Eintrag vornehmen

You must be LOGGED IN um einen Kommentar zu erstellen.

Entries and comments feeds. Valid XHTML and CSS. ^Top^

xml :RSS2-Feed