Unit-Tests (Komponententests)

Das Testen als Folge des Auftretens oder zur Vermeidung von Fehlern ist in der Softwareentwicklung seit jeher das „harte Brot” des Software-Entwicklers. Fehlerfreie Software ist aufgrund der Komplexität der heutigen Softwaresysteme ein erstrebenswertes, aber nahezu unerreichbares Ziel. Wo gearbeitet wird, da werden eben auch Fehler gemacht – und das gilt auch für den Prozess der Software-Entwicklung.

Um eben diese Fehler zu finden, muss der Programmcode getestet werden, wofür es eine ganze Reihe von Möglichkeiten gibt. Primär werden zur Suche von Fehlern im Programmcode Werkzeuge, wie beispielsweise Debugger, genutzt. Aber auch Debug-Ausgaben auf der Console können zu den ensprechenden Fehlern führen und somit ihren Zweck erfüllen. Mit Debuggern gestaltet sich die Suche recht mühsam, da man sich meist nur sehr langsam und Schritt für Schritt an den Fehler „herantracen” kann. Debug-Ausgaben machen den Code unübersichtlich und führen letztlich dazu, dass mit zunehmender Komplexität des Programmcodes auch die zur Fehlersuche eingebauten Kontrollausgaben nicht mehr zu übersehen sind.

Aber auch mit dem einmaligen Testen von Software ist es noch lange nicht getan. Der Entwicklungsprozess ist mit der Fertigstellung von Programmteilen längst nicht beendet. So führen Änderungen am Programmcode – sei es nun zu Erweiterungszwecken oder im Zuge einer „Optimierungsmaßnahme” – immer wieder dazu, dass Funktionen oder Methoden, die zuvor ausgetestet wurden und fehlerfrei funktionierten, plötzlich nicht mehr korrekt arbeiten. Auch kommt es vor, dass sich Änderungen, die bei der Ausmerzung von Programmfehlern getätigt werden, auf andere Programmteile auswirken und somit an anderen Stellen im Code zu Fehlfunktionen führen. Wohl dem, der in solchen Fällen durch automatisierte Tests genau auf die Stelle aufmerksam gemacht wird, an der nun Fehlfunktionen auftreten, denn erfahrungsgemäß sucht man an jenen Stellen, die zuvor mühsam „beackert” wurden und endlich laufen, zuletzt.

Automatisiert und wiederholbar Testen durch Unit-Tests

An dieser Stelle setzen nun die sogenannten Unittests (Komponententests) ein. Mit ihrer Hilfe lassen sich solche automatisierten Tests implementieren und jederzeit wiederholen. Ein Unit-Test prüft dabei immer nur einen sehr kleinen und autarken Teil des Software-Systems – wie beispielsweise eine einzelne Funktion oder Klassenmethode. Dabei wird bei jedem Test die zu testende Funktion oder Methode mit Testdaten (Parametern) konfrontiert und deren Reaktion auf diese Testdaten geprüft. Die zu erwartenden Ausgabewerte werden nun mit den von der jeweiligen Funktion oder Methode gelieferten Ergebnisdaten verglichen. Stimmt das erwartete Ergebnis mit dem gelieferten Ergebnis der Funktion oder Methode überein, so gilt der Test als bestanden. Bei der Wahl von zu testenden Methoden sollte man beachten, dass nicht jede Methode eines Tests bedarf. So ist es relativ sinnlos Set- und Get-Methoden zu testen, da diese meist nur eine recht triviale Funktionalität aufweisen. Private Methoden von Klassen können dagegen, aufgrund ihrer Sichtbarkeit, ohnehin nur indirekt getestet werden.

Ein Test besteht im Allgemeinen aus einer ganzen Reihe von Testfällen, die nicht nur ein Parameter-Ergebnis-Paar prüfen, sondern gleich mehrere. Welche Szenarien der Entwickler nun als „testwürdig” erachtet, bleibt ihm überlassen. Sinnvoll ist es im Allgemeinen, Funktionen und Methoden mit Parametern zu testen, die typischerweise bei deren Aufruf auftreten. Auch die Betrachtung von Grenzwerten (extrem große oder kleine Werte ...) oder besonderen Werten (Null-Zeiger, Objekte in speziellen Zuständen ...), bei den unterschiedlichen als Parameter genutzten Datentypen, ist sinnvoll. Liefern alle diese Testszenarien erwartungsgemäß die korrekten Werte, so kann der Entwickler davon ausgehen, dass seine Implementierung der Funktion oder Methode korrekt ist. Der Entwickler implementiert meist zu jeder Klasse eine entsprechende Testklasse, welche die jeweiligen Funktionalitäten der zu testenden Methoden verifiziert. Einzelne Testklassen werden zusätzlich zu sogenannten Test-Suiten zusammengefasst, mit denen dann nicht nur einzelne Funktionen oder Klassen automatisch getestet werden, sondern ganze Softwaresysteme auf einmal.

Diese Vorgehensweise führt dazu, dass der Entwickler Funktionen und Methoden möglichst einfach hält und Objektstrukturen sauber entwirft, um entsprechend einfache Tests dazu implementieren zu können. Bei der Entwicklung solcher Tests fallen auch unglücklich gewählte Schnittstellen sofort auf, wodurch die Qualität und auch die Wartbarkeit des Codes steigt. Wird der Code zu komplex, so muss er gegebenenfalls vereinfacht (refaktorisiert) werden. Somit ist die Implementierung von Unittests kein notwendiges Übel, sondern sorgt für einen verbesserten und effektiveren Entwicklungsprozess.

Ein Nebeneffekt psychologischer Art ist der, dass es dem Entwickler weniger „Kopfschmerzen” bereitet komplexe Änderungen an seinem Code vorzunehmen. Schliesslich kann er nach derartigen Änderungen durch seine implementierten Tests das System jederzeit überprüfen. Treten also nach einer solchen Änderung keine Fehler auf, so ist sie höchstwahrscheinlich geglückt.

Auch für den Fall, dass ein anderer Entwickler einmal an den Sourcen Änderungen vorzunehmen hat, bieten Unittests den Vorteil, dass mit den Testfällen zugleich Anwendungsbeispiele für die jeweiligen Klassen und Funktionen zur Verfügung stehen. Somit ist die (hoffentlich) vorhandene Klassendokumentation gleich mit einem adäquaten Beispiel aufgewertet.

Man darf dabei allerdings die Gefahr einer unglücklichen Testauswahl nicht außer Acht lassen. Sind die vom Entwickler gewählten Tests unzureichend oder gar falsch, liefern aber ein positives Ergebnis, so führt diese trügerische Sicherheit zu großen Problemen. Der Entwickler vertraut seinem Code so sehr, dass er erst nach langer Suche auf die Idee kommen wird, in seinem positiv getesteten Code nach Fehlern zu suchen.

Unit-Tests und Test-Driven Development

Die Verwendung von Unit-Tests ermöglicht zudem die Realisierung einer gänzlich andersartigen Vorgehensweise in der Softwareentwicklung. Im Allgemeinen verläuft der Software-Entwicklungs-Prozess so, dass – nach einigen Vorarbeiten, wie der Erstellung einer Systembeschreibung durch ein Pflichtenheft mit anschließender System-Modellierung mit Hilfe von UML-Tools – zunächst eine Software implementiert wird und im Anschluss daran die einzelnen Systemteile getestet werden. Nach dem Prinzip des Test-Driven Development (TDD) wird eine gänzlich andere Vorgehensweise genutzt. Der Entwickler schreibt hier zunächst die Programme, die zum Testen der später zu implementierenden Funktionen oder Klassen benötigt werden. Wenn die Tests, die man auch als Entwickler-Tests bezeichnet, erst implementiert sind, kann der Entwickler sich an die Implementierung der eigentlichen Funktionen und Klassen machen. Dieses Vorgehen führt dazu, dass sich der Entwickler vor der eigentlichen Programmierarbeit gründlich darüber im Klaren sein muss, welche Schnittstellen das zu implementierende System später haben soll. Er geht dadurch quasi von der Anwendersicht an die später entstehende API heran, was beim Design von Vorteil ist. Darüber hinaus hat er durch die implementierten Testfunktionen eine stets wiederholbare Kontrolle darüber, ob der von ihm implementierte Code auch korrekt arbeitet.

Problemfälle für Unit-Tests

Unit-Tests eignen sich allerdings längst nicht für alle Bereiche der Software-Tests. So ist das automatische Testen von Benutzungsoberflächen nur sehr schwer möglich. Bei der Entwicklung von Bedienoberflächen sollte man sich an das Model-View-Controller-Konzept (MVC) halten, welches eine Unterteilung der Anwendung in ein Datenmodell, Views zur Darstellung der Daten und den Controller zur Benutzerinteraktion vornimmt. Werden Daten mittels Benutzerinteraktion geändert, so passt der Controller das Datenmodell an, was zur Folge hat, dass die Views dies mitgeteilt bekommen und diese wiederum ihre Darstellung aktualisieren. Auf diese Weise lassen sich bei der Erstellung von Graphical User Interfaces (GUI) zumindest automatische Unittests für das Datenmodell realisieren.

Überhaupt ist es problematisch, wenn durch die Nutzung vieler Bestandteile – beispielsweise einer API zur Datenbankanbindung – nicht nur die eigene Software getestet wird, sondern indirekt auch gleich die genutzten Bibliotheken. Auch bei Abhängigkeiten im direkten Umfeld – wie beispielsweise lokal gespeicherte Konfigurationsdateien, die zum korrekten Ablauf der Software benötigt werden, deren Struktur aber nicht in den Sourcen festgelegt ist – kann es zu Problemen kommen. Derartige Abhängigkeiten lassen sich nur vermeiden, indem man zuvor eine geeignete Test-Umgebung schafft. Zu diesem Zweck werden nun die sogenannten Mock-Objekte (to mock: engl. für Nachahmen) eingesetzt, die eine Art „gefakte” Schnittstelle zu schwer zu simulierenden oder noch nicht vorhandenen Systemteilen schaffen. Mock-Objects könnte man also auch als Attrappen- oder Dummy-Objekte bezeichnen. Diese Objekte verhalten sich nach außen hin wie die zu bedienende Schnittstelle und liefern – meist hard-coded – genau die Daten, die für den Test erwartet werden. Hinzu kommt, dass man diese Mock-Objekte so implementieren kann, dass sie – entsprechend parametrisiert – auch dazu genutzt werden können, bestimmte schwer reproduzierbare Fehler zu simulieren. Somit können Fehler gezielt produziert werden und damit auch die Reaktion auf derartige Bedingungen geprüft werden.

Auch Problemen bei Nebenläufigkeiten, wie sie bei der Verwendung von Threads oder gar bei verteilten Systemen auftreten, sind nur sehr schwer mit Hilfe von Unittests beizukommen. Die Nutzung von Bibliotheken, wie sie zum Abhandeln der Inter-Prozess-Kommunikation über ein Netzwerk genutzt werden (PVM oder MPI), erleichtern das Debugging auch nicht gerade.

Unit-Tests und das große Ganze

Unittests ersetzen aber in keinster Weise Integrations- und Akzeptanztests. Derartige Testmethoden validieren das System primär aus Anwendersicht und bewerten somit das „große Ganze” samt Benutzerinteraktion. Es ist auch unter Verwendung von Unit-Tests nötig, weiterhin das „Gesamtwerk” zu testen. Allerdings wird ein nicht unwesentlicher Teil potentieller Fehler schon im Vorfeld ausgeschaltet, was im Endeffekt eventuell mit Testaufgaben beauftragten Testern Arbeit (Aufwand für das Testen und die dabei anfallende Kommunikation) erspart.

Frameworks zur Implementierung von Unittests

Neben JUnit, dem bekanntesten und wohl auch meistgenutzten Vertreter unter den Frameworks für Unit-Tests, gibt es Implementierungen für die verschiedensten Programmiersprachen. CppUnit beispielsweise ist ein solches Framework zur Programmierung von Software-Tests nach dem Prinzip der Unittests für die Programmiersprache C++. Der Tutorien-Bereich enthält eine kurze Einführung in die Nutzung von Unittests unter C++ mit CppUnit. Im Link-Bereich unter Extreme Programming (XP) / Agile Softwareentwicklung findet sich ein Link, unter dem Test-Frameworks für die verschiedensten Programmiersprachen zu finden sind.