Agile Modellierung mit
UML
Loading

6.2 Definition von Testfällen

6.2.1 Operative Umsetzung eines Testfalls

Ein 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.


Abbildung 6.7: Struktur eines einfachen Testtreibers

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:

  1. Die für den Test notwendige Objektstruktur einschließlich der Dummies für die Umgebung wird aufgebaut.
  2. Der Testling wird zur Ausführung gebracht. Oft wird dabei nur eine einzelne Methode aufgerufen.
  3. Das erhaltene Istergebnis wird mit dem erwarteten Sollergebnis verglichen. Dabei besteht das erhaltene Istergebnis, wie in Abbildung 6.7 illustriert, aus der resultierenden Objektstruktur, den gegebenenfalls in den Dummies zu findenden Zugriffsprotokollen auf die simulierte Umgebung und zusätzlich aus einem return-Wert der getesteten Methode.

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 [JUn11BG98BG99HL02] 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 Testergebnisse

Vergleichsmuster

Neben 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:

Differenzvergleich
P(x,f(x)). Der Vor-/Nachvergleich beschreibt den Vergleich zwischen Ausgangsstruktur x und Istergebnis f(x) auf Basis eines Vergleichsprädikats P .
Eigenschaftsprüfung
P(f(x)). Mit der Eigenschaftsprüfung wird eine von der Ausgangsstruktur nicht direkt abhängige Eigenschaft geprüft. Dazu gehören Invarianten, aber auch spezifische Tests über einzelne Attribute und Attributkombinationen des Istergebnisses.
Äquivalenzvergleich
f(x) y mit explizit vorgegebenem y B und einer Äquivalenz , die nur relevante Aspekte vergleicht. Eine einfach realisierbare Äquivalenz ist die strukturelle und wertemäßige Gleichheit.
Vergleich nach Abstraktion
Ab(f(x)) = Ab(y) beziehungsweise Ab(f(x)) = z ist eine Variante des Äquivalenztests mit einer explizit dargestellten Abstraktion Ab : B Z und z Z. Ein einfaches Beispiel für eine Abstraktion ist der selektive Vergleich von Attributen eines Objekts, der zum Beispiel in der Methode equal gelegentlich umgesetzt wird.
Identitätsprüfung
f-1(f(x)) = x, wobei f invertierbar sein muss, um den Ursprungszustand wieder herstellen zu können.
Prüfung mit Orakelfunktion
g(x) = f(x), indem eine zweite, von f unabhängige Realisierung g existiert, die dieselbe Funktionalität implementiert.

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.

Abbildung 6.8: Vergleichsmuster für Testfälle

Vorteile und Nachteile der Vergleichsmuster

Der 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 Umsetzung

Aus 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 JUnit

Aus 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 [JUn11BG98BG99HL02] 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.

       Java   
   
 public class AuctionTest extends TestCase {
  public void testBidSimple() {
    // (1) Aufbau der Objektstruktur
    Auction auction = new Auction(...);
    Person  person  = new Person(...);
    Time    time    = new Time("14:42:22", "Feb 21 2000");
    Money   money   = new Money("552000", "$US", 2);
    // weitere Objekte sind notwendig, um die Struktur zu komplettieren
 
    // (2) Durchführung des Tests
    boolean result  = auction.bid(person,time,money);
 
    // (3) Prüfen der Ergebnisse
    // result==true: Gebot war erfolgreich
    assertTrue(result);
 
    // das abgegebene Gebot ist derzeit das Beste
    assertEquals(money, auction.getBestBid());
 
    // Das Auktionsende wurde auf time + extensiontime festgelegt
    Time expectedClosingTime =
           new Time("14:45:22", "Feb 21 2000");
    assertEquals(expectedClosingTime,
                 auction.getCurrentClosingTime());
  }
  ... // z.B. Konstruktor
}
Abbildung 6.9: Einfacher Testtreiber für Methode bid in JUnit

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.


Bernhard Rumpe. Agile Modellierung mit UML. Springer 2012