Übersicht Inhaltsverzeichnis Vorwort 1 Einführung 2 Agile und UML-basierte Methodik 3 Kompakte Übersicht zur UML/P 4 Prinzipien der Codegenerierung 5 Transformationen für die Codegenerierung 6 Grundlagen des Testens 6.1 Einführung in die Testproblematik 6.2 Definition von Testfällen 7 Modellbasierte Tests 8 Testmuster im Einsatz 9 Refactoring als Modelltransformation 10 Refactoring von Modellen 11 Zusammenfassung und Ausblick Literatur |
6.2 Definition von Testfällen6.2.1 Operative Umsetzung eines TestfallsEin Testfall besteht laut Definition aus einem Satz von Testdaten, einer Beschreibung des Ausgangszustands des Testlings und seiner Umgebung und dem Sollergebnis des Tests. Um einen Testfall operativ umzusetzen, ist ein ausführbarer Testtreiber zu realisieren. Ein Testtreiber hat die Aufgabe den Testling inklusive der Testdaten aufzubauen und in eine Testumgebung einzusetzen, damit der Testling so ablaufen kann, als wäre er im Produktionssystem. Zugleich werden die für den Vergleich mit dem Sollergebnis relevanten Daten des Istergebnisses gespeichert. Dabei nutzt der Testtreiber eine Instrumentierung des Testlings, die ihm den notwendigen Zugriff auf die Daten des Testlings erlaubt, und die in Abschnitt 8.1 beschriebenen Dummy-Objekte, um die Umgebung des Testlings zu simulieren. Seiteneffekte wie Datenbankzugriff, Bildschirmausgabe oder externe Kommunikation werden dabei in Dummies abgefangen und gegebenenfalls protokolliert. Abbildung 6.7 demonstriert die für einfache Testtreiber typische Vorgehensweise. In Abschnitt 7.4 wird diese verfeinert, indem statt nur einer Methode eine Methodensequenz ausgeführt und die Reihenfolge der internen Abläufe dabei beobachtet wird. In Abbildung 6.7 ist eine Objektstruktur aus vier Objekten dargestellt, die als Testdatensatz an die aufgerufene Methode übergeben wird. Die aufgerufene Methode ist normalerweise selbst einem dieser Objekte (zum Beispiel o1) zugeordnet. Die weiteren Objekte o2 bis o4 sind notwendig, da die aufgerufene Methode beziehungsweise davon abhängige Methoden auf diese Objekte zugreifen und diese gegebenenfalls verändern. Weitere Objekte u1, u2 und u3 sind notwendig, um den Zugriff auf die Umgebung zu erlauben. Die Umgebung wird dabei durch eine Simulation mit den in Abschnitt 8.1 diskutierten Dummies ersetzt. Je nach Testziel wird eine unterschiedliche Abgrenzung zwischen dem Testling und der simulierten Testumgebung vorgenommen. Bei einem Methodentest besteht der Testling tatsächlich nur aus der aufgerufenen Methode. Deshalb ist es hier meist sinnvoll, bereits die direkte Umgebung durch Dummies zu ersetzen und gegebenenfalls sogar andere Methoden derselben Klasse zu simulieren. Auch bei Klassentests werden typischerweise die Objekte der Umgebung durch Dummies ersetzt. Für Integrations- und Systemtests wird stattdessen möglichst wenig, also oft nur die echte Systemgrenze ersetzt. Für die operative Umsetzung eines Testfalls wird ein Testtreiber benötigt, der letztendlich in einer Testmethode realisiert wird. Ein Testtreiber besteht aus drei wesentlichen Phasen:
Ist für einen Testfall das Öffnen einer Datei oder einer Internet-Verbindung, die Anpassung einer Datenbank oder ähnliches mehr notwendig, so müssen diese am Ende des Tests aufgeräumt werden. In C++ gehört dazu zum Beispiel auch die Freigabe angelegter Objektstrukturen. Das mittlerweile für viele Programmiersprachen verfügbare Java-Framework JUnit [JUn11, BG98, BG99, HL02] bietet eine hervorragende Unterstützung zur Definition von Tests. JUnit bietet eine Infrastruktur und methodische Richtlinien zur Definition von Testdaten und Durchführung von Tests in der in Abbildung 6.7 gezeigten Form. Neben internen Testtreibern gibt es Ansätze, Testtreiber als Skripte außerhalb des eigentlichen Systems zu definieren. Die Testdaten und -ergebnisse sind dann typischerweise in Dateien abgelegt. Der Vergleich von Soll- und Istergebnis reduziert sich dann auf einen Dateivergleich. Die Nutzung externer Testdaten und -treiber stellt nach heutiger Technik vor allem für dateiorientierte Systeme wie etwa Compiler, Generatoren oder XML-Transformatoren eine gute Ergänzung für interne Testfälle dar, ist aber nur für Systemtests wirklich geeignet. In [Den91] wird eine Variante für ein Testsystem vorgeschlagen, das nur die Ergebnisse extern in Dateien oder einer Datenbank ablegt und dort vergleicht, während die Testtreiber in der Programmiersprache des Systems erstellt werden. 6.2.2 Vergleich der TestergebnisseVergleichsmusterNeben der Erstellung von Testdaten ist der Vergleich von Soll- und Istergebnis von besonderer Komplexität. Die adäquate Unterstützung des Entwicklers zur Analyse des Istergebnisses eines Tests durch Vergleich mit einem expliziten Sollergebnis, Überprüfung einer Invariante oder ähnlichen Techniken hat wesentliche Auswirkungen auf die Effizienz bei der Testentwicklung. Dabei ist einerseits zu beachten, dass dieser Vergleich möglichst schnell und kompakt formuliert werden kann. Wichtig ist aber genauso, dass er eine gewisse Widerstandsfähigkeit gegenüber Änderungen von Elementen des Systems hat, die im Test zwar genutzt werden, aber eigentlich nicht Teil des Tests sind. Dafür bieten sich Äquivalenzvergleiche oder Abstraktionen an. Deshalb sind Vergleichsmuster hilfreich, die jeweils richtige Form des Vergleichs zu wählen. Zunächst wird in Abbildung 6.8 mit mathematischen Mitteln eine Klassifizierung möglicher Vergleiche vorgenommen. Diese in der Theorie ähnlichen Techniken haben bei der anschließend besprochenen praktischen Umsetzung sehr unterschiedliche Auswirkungen.
Seien A und B die Mengen behandelter Objektstrukturen (eine Objektstruktur beinhaltet
im Allgemeinen mehrere Objekte). Eine transformierende Methode f wird als Funktionen f : A → B verstanden. P stellt ein Prädikat dar. Vereinfachend seien
Parameter und Ergebnis in A beziehungsweise B enthalten.
Es lassen sich folgende Vergleichsmuster identifizieren und damit das Sollergebnis explizit oder implizit darstellen, wobei diese Muster teilweise ihre Vorgänger spezialisieren. Es sei x ∈ A:
Diese Vergleichstechniken lassen sich kombinieren. Wenn zum Beispiel eine Orakelfunktion nur ein äquivalentes, aber kein identisches Ergebnis produziert, ist ein Vergleich der Form g(x) ≡ f(x) mit einer geeigneten Äquivalenz sinnvoll. Vorteile und Nachteile der VergleichsmusterDer Differenzvergleich P(x,f(x)) kann gewünschte Veränderungen prüfen und unerwünschte Veränderungen erkennen. Aufgrund der Parametrisierung von P kann der Vergleich auf verschiedene Ausgangsdatensätze x angewandt werden, auf denen f ein uniformes Verhalten besitzen soll. Beispiel ist etwa die Prüfung, ob ein Zähler erhöht wurde. Solche Vergleiche können durch OCL-Methodenspezifikationen geeignet beschrieben werden. Die Eigenschaftsprüfung ermittelt zum Beispiel, ob Invarianten eingehalten wurden, oder ob bestimmte Attribute mit bestimmten Werten belegt sind. Meistens wird das Istergebnis nur selektiv geprüft und damit eine Abstraktion vorgenommen. Dies hat den Vorteil, dass bei einer Änderung des Systems an einer, für den Testfall nicht relevanten Stelle, der Testfall immer noch erfolgreich ist. Als Spezialfall der Eigenschaftsprüfung benutzt der Äquivalenzvergleich die explizite Darstellung y des Sollergebnisses. Beim Vergleich kann durch die Verwendung der Äquivalenz zum Beispiel auf die Prüfung irrelevanter Attribute oder Reihenfolgen in Containern verzichtet werden. Mathematisch äquivalent, aber in seiner Umsetzung unterschiedlich ist die Verwendung einer Abstraktion.7 Die Identitätsprüfung ist nur unter eng begrenzten Umständen möglich, wenn (a) eine Invertierbarkeit der Funktion vorliegt und (b) die Umkehrfunktion f-1 mit vernünftigem Aufwand realisierbar ist. Idealerweise ist die Umkehrfunktion f-1 ebenfalls in dem zu testenden System vorhanden und bereits durch andere Testfälle geprüft worden. Die Verwendung einer alternativen Implementierung, auch Orakelfunktion genannt, bietet sich zum Beispiel dann an, wenn eine bereits vorhandene Implementierung in einem Refactoring durch eine verbesserte Fassung ersetzt werden soll. Auf diese Weise kann zum Beispiel ein Sortieralgorithmus ausgetauscht werden. Eine andere Möglichkeit bietet die Verwendung einer ausführbaren Spezifikation als Orakel, wenn diese nicht bereits für die Implementierung verwendet wurde. Statecharts sind dafür beispielsweise geeignet. Wenn das durch die Orakelfunktion erhaltene Ergebnis vom Testergebnis in bestimmten Details abweicht, so kann eine explizite Vergleichsfunktion angegeben werden, die statt der Gleichheit eine Äquivalenz prüft. Anforderungen für eine operative UmsetzungAus der mathematischen Charakterisierung der Vergleichsmuster der Abbildung 6.8 lässt sich ableiten, welche Funktionalität ein Framework zur Verfügung stellen sollte, das deren operative Umsetzung unterstützt. Die Framework-Funktionalität wird als Teil der UML-Laufzeitumgebung nach Abbildung 4.6 verstanden. Beim Differenzvergleich P(x,f(x)) sind nach Testdurchführung die Ausgangsstruktur x und das Istergebnis f(x) gleichzeitig zur Verfügung zu stellen. Vor Ausführung von f ist daher eine Kopie (engl.: clone) der Ausgangsdaten anzulegen. Wenn ein Vergleich scheitert, so ist eine ausführliche, aber übersichtliche Ausgabe des Istergebnisses f(x) zur Analyse des Fehlers notwendig. Sowohl die Kopierfunktionalität als auch die Ausgabe müssen das zugrunde liegende Objektgeflecht bearbeiten. Der Äquivalenzvergleich f(x) ≡ y benötigt eine Implementierung des Vergleichsoperators. Die von Java standardmäßig zur Verfügung gestellten Vergleichsoperatoren == und equals() sind dafür nicht immer nutzbar. Der Operator == vergleicht für Objekte nur Objektidentität und ist daher für inhaltliche Vergleiche nicht nutzbar. equals() kann vom Entwickler für jede Klasse neu definiert werden, wird aber bereits im Produktionssystem benutzt. Zum Beispiel benutzen einige der Containerstrukturen den equals()-Operator. Deshalb kann die im Produktionssystem gewünschte Funktionalität dieses Operators inkompatibel zum Verhalten bei einem Testvergleich sein. Darüber hinaus können für verschiedene Tests jeweils individuelle Vergleiche ≡ notwendig sein. Beispielsweise können Vector-Objekte als Realisierungen von Sequenzen oder Mengen, also mit oder ohne Respektierung der Reihenfolge, zu vergleichen sein oder für den Vergleich von Objekten einer Anwendungsklasse ist nur eine Teilmenge der Attribute relevant. Es ist deshalb eine Unterstützung zur flexiblen Definition von Vergleichen für Objektstrukturen wünschenswert, die auch rekursive oder mit Zyklen behaftete Objektstrukturen vergleichen kann. Scheitert der Test, so sollte hier die rekursive Ausgabe des Istergebnisses f(x) zusätzlich mit Markierungen der Unterschiede zum Sollergebnis y versehen sein. Abstraktionen der Form Ab(f(x)) = Ab(y) beziehungsweise Ab(f(x)) = z vor einem Vergleich sind zum Beispiel die Selektion einzelner Attribute, eines einzelnen Objekts oder einer Teilstruktur. Auch die oben beschriebene Definition einer Vergleichsoperation führt implizit eine Abstraktion durch, ohne die abstrahierte Datenstruktur explizit zu berechnen und ist damit ein effizienter Weg, eine für einen Vergleich vorgesehene Abstraktion zu codieren. In manchen Fällen ist es sinnvoll, stattdessen nicht zu vergleichende Attributwerte und Links zu löschen oder eine Umformung in eine Normalform zu berechnen. So können zum Beispiel zwei Reihungen, die als Mengen zu interpretieren sind, zunächst sortiert und dann elementweise verglichen werden. Die beiden letzten Varianten, die Identitätsprüfung f-1(f(x)) = x und die Prüfung mithilfe einer Orakelfunktion g(x) = f(x), basieren auf dem Vorhandensein entsprechender Funktionalität. Die Funktion g stellt eine Form des Orakels dar. Orakel werden in [Bin99] genauer diskutiert. Dort wird richtigerweise angemerkt, dass es perfekte Orakel nicht geben kann. Gleichzeitig werden dort aber einige Muster angegeben, um Orakel zu realisieren. 6.2.3 Werkzeug JUnitAus der bisherigen Diskussion dieses Abschnitts lässt sich erkennen, dass die Unterstützung der Definition von Tests durch geeignete Funktionalität wichtig ist. Dabei kann diese Funktionalität durch Werkzeuge wie Generatoren oder Analysatoren oder durch ein Framework erbracht werden. Idealerweise wird sogar beides kombiniert, indem ein Generator Testcode generiert, der mit dem Framework zusammenarbeitet. Das Framework kann dann als Bestandteil der UML-Laufzeitumgebung angesehen werden. Das nachfolgend beschriebene Framework JUnit [JUn11, BG98, BG99, HL02] besitzt viel von der dafür notwendigen Funktionalität. In Abbildung 6.9 ist ein Ausschnitt eines in Java formulierten und mit JUnit durchgeführten Tests der Methode bid der Klasse Auction zu sehen.
In einer Aufbauphase (1) werden alle Objekte erzeugt, die für den Test notwendig sind. Dies können wie in den Fällen Auktion und Person komplexere Objektstrukturen sein, die auch noch untereinander verbunden sind. Deshalb wird diese erste Phase oft in eine eigene Methode mit dem Namen setUp ausgelagert. Die Anzahl der in diesem Beispiel notwendigen Objekte ist bereits relativ hoch. Um eine möglichst große Effizienz des Testablaufs zu erreichen, ist es nach [LF02] sinnvoll möglichst kleine Objektstrukturen zu nutzen. Die Durchführung des Tests (2) besteht aus einem einfachen Methodenaufruf. Dann werden die entstandenen Ergebnisse verglichen (3), wobei die Methoden assertX zur Festlegung und zur Meldung eines Erfolgs oder Scheiterns8 Stärken besitzt JUnit im Management und der Kombination von Testsammlungen, indem es einfache Mechanismen zur Erstellung einer Sammlung von Tests (engl.: test suite) und zur individuellen Ausführung solcher Sammlungen zur Verfügung stellt. JUnit ist ein trickreich implementiertes und mit diesem Beispiel nur unvollständig dargestelltes Framework, das mit wenig Code und sehr wenigen, vom Anwender zu verstehenden Klassen durch geschickte Bildung von Unterklassen und Interface-Implementierung effektiv zur Definition von Testfällen genutzt werden kann. Die Handhabung von JUnit ist bereits deshalb relativ einfach, weil die Implementierungssprache und die Sprache zur Testdefinition identisch sind. Aus diesem Grund können auch dieselben Werkzeuge, wie etwa Versionsverwaltung und Entwicklungsumgebung, verwendet werden. JUnit stellt ein gelungenes Beispiel für ein Entwicklungswerkzeug dar, das als Framework in der Implementierungssprache selbst realisiert ist und damit auf einfache und elegante Weise eine enge Verbindung zwischen Werkzeug und Implementierung schafft.
|
||||||||||||||||||||||||||||||||||||