Agile Modellierung mit
UML
Loading

8.2 Testbare Programme gestalten

Einer der Vorteile objektorientierter Systeme ist die verbesserte Testbarkeit, die darauf beruht, Unterklassen der Testumgebung zu bilden und dabei dynamisch Methoden zu redefinieren. Dennoch gibt es bei objektorientierten Systemen einige Problemstellungen, die die Definition von Testumgebungen, die Durchführung des Tests oder die Feststellung des Testerfolgs erschweren oder sogar verhindern. Diese resultieren grundsätzlich aus statischen Attributen und Methoden, der Objekterzeugung und der Verwendung vordefinierter Frameworks oder Komponenten. Diese Probleme und ihre Behebung werden im Folgenden diskutiert. Dabei werden weitere Testmuster unter Verwendung von UML-Diagrammen vorgestellt. Interessanterweise sind Polymorphie und dynamische Bindung zwar Komplexitätstreiber für Tests, aber aufgrund der dadurch entstehenden Flexibilität auch wesentliche Elemente zur Testfalldefinition.

Um ein System testbar zu machen, werden im Folgenden eine Reihe von Strukturveränderungen vorgeschlagen, die die Simulation einer Systemumgebung erleichtern oder erst ermöglichen. Zusätzlich wird der Testling für die Testdurchführung instrumentiert. Beide Arten von Veränderungen greifen in das System ein. Die vorgeschlagenen Strukturveränderungen sind permanent und erfordern daher die Akzeptanz durch die Entwickler. Objektorientierte Methoden unterstützen dieses Vorgehen tendenziell deutlich eher als frühere Paradigmen. Außerdem ist es bei einer agilen Vorgehensweise von Vorteil, dass die Tests nicht erst nach Fertigstellung des Systems entwickelt werden und damit a priori Einfluß auf die Systemstruktur nehmen können.

Die Instrumentierung des Testlings ist demgegenüber nicht permanent und erfordert damit größte Vorsicht, da sie die Funktionsfähigkeit des Testlings nicht verändern darf. Vom Codegenerator korrekt durchgeführte Instrumentierungen führen zumindest zu einer geringfügigen Veränderung der Laufzeiten und verfälschen damit Laufzeitmessungen, aber nicht funktionale Tests.

8.2.1 Statische Variablen und Methoden

Eine immer wiederkehrende Problemstellung ist die Verwendung von statischen Variablen und Methoden. Soweit wie möglich sollte auf die Verwendung derartiger statischer Elemente verzichtet werden. Wo dies nicht möglich oder sinnvoll ist, sollte aber zum Beispiel statt der Variablen selbst ein Objekt verwendet werden, das die eigentliche Variable kapselt. Im Auktionsprojekt wurde zum Beispiel das in Abbildung 3.7 dargestellte Singleton-Objekt AllData verwendet, um damit Zugang auf alle Auktionen, Personen und weitere Datenstrukturen zu erhalten. Dieses Objekt ist im UML-Modell durch das statische und mit dem Zugriffsrecht readonly versehene Attribut AllData.ad zugänglich. Bei der Codegenerierung wird daraus eine geeignete Zugriffsmethode erstellt, allerdings keine Methode zur Änderung des Attributwerts selbst. Das heißt, das Singleton ist von außen zugreifbar, aber gegen Ersetzung geschützt. Dennoch wird nachfolgend ein Muster diskutiert, das statische Variablen vollständig kapselt und statische Methoden für Tests besser zugänglich macht.

       Java/P   
   
 class Protocol {
  static public writeToLog(String text) {
    // Sicherstellung, dass das Objekt existiert
    if(prot==null) {
      prot = new Protocol();
      // Alternative wäre: Factory.getProtocolObject();
    }
    // Delegation
    prot.doWriteToLog(text);
  }
  protected doWriteToLog(String text) {
    // Hier wird die eigentliche Ausgabe vorgenommen
  }
}
class ProtocolDummy {
  setAsProtocol() {
    prot = this;
  }
  public doWriteToLog(String text) {
    // Redefinition
    logList.add(text);
  }
}
Abbildung 8.8: Globales Protokoll-Objekt mit Ersetzungsmöglichkeit

Das Singleton für Protokolle wird im Attribut prot der Klasse Protocol abgelegt. Wie Abbildung 8.8 zeigt, wird zusätzlich eine Kapselung des Attributs in einer statischen Methode vorgenommen. Eine Meldung im Protokoll kann dann mit

       Java   
   
   Protocol.writeToLog("Meldung")

vorgenommen werden. Um die initiale Besetzung des Attributs prot sicherzustellen, prüft die Methode -das statische Attribut und besetzt es bei Bedarf, führt dann aber ausschließlich eine Delegation an dieses Objekt durch.

Die Ersetzung des Protocol-Objekts durch ein Dummy ist nun einfach. Die Methode doWriteToLog wird im Dummy überschrieben und das Dummy-Objekt macht sich durch den Aufruf von setAsProtocol() selbst zum Protokollempfänger.

Dieses Verfahren lässt sich in Tabelle 8.9 als Muster zusammenfassen. In Abschnitt 10.1.5 ist darüber hinaus eine Refactoring-Regel angegeben, die dieses Testmuster in ein bestehendes Modell der Struktur eines Systems einführt.



Muster: Singleton hinter statischen Methoden


Intention

Das Muster ermöglicht einerseits kompakten Zugriff auf ein Singleton-Objekt, das für Tests durch ein Dummy ersetzt werden kann, vermeidet aber andererseits eine öffentlich zugängliche statische Variable für dieses Objekt.


Motivation

Siehe vorherige Diskussion zur Testbarkeit von Code mit statischen Variablen. Beispiele sind Objekte, die Protokoll-Aufgaben, Abfragen der Zeit oder einen Factory-Mechanismus realisieren.


Anwendung

Eine Anwendung dieses Musters ist sinnvoll, wenn

  • von einer Klasse nur ein Singleton, dieses aber an vielen Stellen benutzt wird,

  • ein kompakter Zugriff der Form Singleton.method() gewünscht ist,

  • die Speichervariable für das Singleton verborgen bleiben soll und

  • das Singleton in Tests durch ein Dummy ersetzbar sein soll.


Struktur


Implementierung Singleton

       Java/P   
class Singleton {
  static initialize() {
             initialize(new Singleton(  )); }
  static initialize(Singleton s) { singleton=s; }
 
  static method(Arguments) {
    // eigene Initialisierung wenn notwendig
    if(singleton==null) initialize();
    // Delegation:
    return singleton.doMethod(Arguments);
  }
 
  doMethod(Arguments) {
    // hier wird gearbeitet
  }
}


Implementierung Dummy

       Java/P   
class SingletonDummy {
  setAsSingleton() { initialize(this); }
  doMethod(Arguments) {
    // hier wird Arbeit simuliert
  }
}


Zugriff

Der Zugriff auf das Singleton erfolgt mit dem Ausdruck Singleton.method(Arguments). Eine vorherige Initialisierung ist nicht notwendig.


Beachtenswert

Das Problem der unvollständigen Initialisierung wird behoben, indem als Default ein Objekt der Klasse selbst erzeugt wird. Eine restriktivere Form könnte hier eine Fehlermeldung erzeugen, da erfahrungsgemäß gerade bei Tests die adäquate Besetzung des Singletons gerne übersehen wird.



Tabelle 8.9 : Muster: Singleton hinter statischen Methoden

8.2.2 Seiteneffekte in Konstruktoren

Eines der wesentlichen Probleme bei dem gezeigten Verfahren ist die durch Java vorgegebene Notwendigkeit, dass Objekte aus Unterklassen einen Konstruktor der Oberklasse aufrufen. Wenn dieser Konstruktor Seiteneffekte verursacht, also im Beispiel die Protokolldatei öffnet, so ist die Definition von Dummies ohne Seiteneffekte für diese Klasse nicht mehr möglich. Aus diesem Grund sollten Konstruktoren relativ wenig Funktionalität beinhalten und gegebenenfalls zusätzliche Funktionen angeboten werden, die solche Initialisierungen vornehmen.

8.2.3 Objekterzeugung

Ein ähnlich gelagertes Problem ist die Erzeugung neuer Objekte. Ein Kommando der Form new Klasse() im Testling legt die Form des dabei entstehenden Objekts genau fest. Es ist hier nicht möglich, in Testläufen statt dem angegebenen Objekt ein geeignetes Dummy einzusetzen. Das führt zu schlecht testbarem Code. Dieses Problem lässt sich beheben, indem im Produktionscode eine Factory [GHJV94] eingesetzt wird.

Wie in Abschnitt 5.1.7 beschrieben, kann diese Factory aus dem gegebenen UML-Modell erzeugt werden, indem für alle auftretenden Konstruktoren entsprechende Methoden der Factory erzeugt werden. In analoger Weise werden durch den Generator alle Konstruktoraufrufe in den Java-Coderümpfen durch Factory-Aufrufe ersetzt. Die Factory ist selbst ein Singleton, das typischerweise in einer statischen Variable abgelegt ist. Es kann daher mit dem bereits für Protokolle angewandten Muster dynamisiert und durch ein FactoryDummy-Objekt für Tests vorbereitet werden. Abbildung 8.10 zeigt einen noch weitergehenden Ansatz, der ein Dummy-Objekt als Factory angibt, das mehrere vorbereitete Dummy-Objekte für den tatsächlichen Testablauf bereitstellt.

       Java/P   
 class FactoryDummy {
  Class getNewClass(Arguments) {
     ocl indexClass < class.size;
     // zusätzlich ist es möglich, eine ocl-Zusicherung für Argumente
     // anzugeben oder diese Argumente zu verwenden, um Attribute
     // der übergebenen Objekte zu setzen
     return class[indexClass++];
  }
}
Abbildung 8.10: Factory bereitet zu erzeugende Objekte des Testlaufs vor

Die im Testablauf benötigten Objekte werden also nicht mehr während des Tests generiert, sondern bereits vorab erstellt und dann nur noch übergeben. Deshalb kann die Factory zum Beispiel durch ein Objektdiagramm wie in Abbildung 8.11 initialisiert werden. Dabei kann exakt bestimmt werden, welche Klassen genutzt und, falls es sinnvoll ist, wie die Attribute vorbelegt werden sollen.


Abbildung 8.11: Factory-Objekt bereitet zu erzeugende Objekte des Testlaufs vor

8.2.4 Vorgefertigte Frameworks und Komponenten

Die in den letzten Abschnitten beschriebenen Umformungen sind jedoch nicht durchführbar, wenn vorgegebene Frameworks, Klassenbibliotheken oder Komponenten verwendet werden sollen, die nicht umgebaut werden können. Ziel eines Tests sind dabei nicht die vorgegebenen Frameworks, sondern die selbst entwickelten Klassen, deren Funktionalität darauf aufbaut und deshalb Teile des Frameworks in der Testumgebung benötigt. Nach [LF02] können zum Beispiel Enterprise JavaBeans (EJB) [MH00] nur sehr schlecht in Tests einbezogen werden. Das hat im Allgemeinen mehrere Gründe:

  • Die Ablauflogik kann durch ein Framework festgelegt sein. Das in Frameworks übliche „Don’t call us, we call you“-Prinzip [FPR01] erlaubt es in Tests nur mit hohem Aufwand, die Kontrolle zu übernehmen.
  • Die Erzeugung neuer Objekte ist im Framework bereits fixiert. Ein Eingreifen mit einer Factory ist nicht möglich.
  • Statische Variablen, insbesondere wenn sie gekapselt sind, können im Test nicht ausreichend kontrolliert und nicht geeignet besetzt werden.
  • Gekapselte Objektzustände erlauben den Zugriff für die Bewertung des Testerfolgs nicht.
  • Von den zur Verfügung stehenden Klassen können keine Unterklassen und damit keine Dummies gebildet werden, weil (1) die Klasse oder eine darin enthaltene Methode als final deklariert ist, (2) kein öffentlicher Konstruktor existiert, (3) Konstruktoren unerwünschte Seiteneffekte haben oder (4) die interne Ablauflogik unbekannt ist.
  • Die Instrumentierung der Klassen ist nicht möglich, so dass zum Beispiel die für die Prüfung von Invarianten und Sequenzdiagrammen notwendige Information nicht zugänglich ist.

Um Software dennoch testen zu können, ist daher eine Separation der Applikationslogik von derartigen Frameworks oder Komponenten notwendig. Dazu kann generell das Adapter-Entwurfsmuster [GHJV94] verwendet werden. [SD00] beschreibt diese Trennung als wichtig für die unabhängige Wiederverwendbarkeit der Applikationslogik und des technischen Codes, aber auch für die Verbesserung der Wartbarkeit. Ein weiterer positiver Effekt dieser Trennung ist die bessere Testbarkeit. In Abbildung 8.12 ist ein Adapter für Java Server Pages (JSP) dargestellt. Das Klassendiagramm beschreibt eine Trennung der Verarbeitung von übers Web eingegebenen Datensätzen und der tatsächlichen Speicherung in HttpServletRequest-Objekten, die von den JSP [FK00] zur Verfügung gestellt werden. Diese Request-Objekte beinhalten die vom Anwender über ein Formular eingegebenen Daten und können unter anderem über die Liste der Parameter getParameterNames und das Auslesen einzelner Parameterwerte getParameter erfragt werden. Weitere Methoden wie getSession liefern zum Beispiel den Kontext der Session, zu der das Formular gehört.

       Java/P   
 class OwnServletRequest {
  OwnServletRequest() { // nichts zu erledigen
    httpServletRequest = null;
  }
  OwnServletRequest(HttpServletRequest hsr) {
    httpServletRequest = hsr;
  }
  getParameterNames() {
     ocl httpServletRequest != null;
     httpServletRequest.getParameterNames();
  }
  OwnSession getSession() {
    ocl httpServletRequest != null;
    return new OwnSession(httpServletRequest.getSession());
  } 
}
 
class OwnServletRequestDummy {
  OwnServletRequestDummy(Map(String,String) p) {
    super();        // Aufruf des leeren Konstruktors
    parameter = p;
  }
  Enumeration getParameterNames() {
    return parameter.keys();
  }
  OwnSession getSession() {
    return new OwnSessionDummy(  );
  } 
}
Abbildung 8.12: Adapter für Request-Objekte

Der Interaktionsmechanismus, den JSP fordert, um zum Beispiel die Eingabedaten auszulesen, macht die komplexe Adaption notwendig. Dabei sind gegebenenfalls Parameter und Ergebnisse jeweils wieder zu ver- und entpacken. Im Auktionssystem wurde dieser Mechanismus verwendet, um die JSP-Oberfläche vom Applikationskern zu trennen.3

Für die Separation des entwickelten Codes von benutzten Frameworks und Komponenten gibt es primär zwei Varianten. Zum einen kann eine vollständige Sammlung von Adaptern für alle Klassen des Frameworks angeboten werden. Zum anderen kann eine Minimalversion der gerade benötigten Klassen und der davon verwendeten Methoden erstellt werden.

Die Minimalversion entspricht der Idee, dass möglichst wenig Aufwand in solche technischen Definitionen gesteckt werden sollte, hat aber den Nachteil, dass mit der Notwendigkeit für weitere Methoden die Adapter-Schicht iterativ ausgebaut werden muss. Andererseits hat diese Beschränkung auch den Vorteil, dass eine Anpassung an eine neue Version des Frameworks sowie eine Migration zu einem anderen Framework leichter möglich ist.

Demgegenüber hat eine vollständige Adapter-Schicht den Vorteil, dass sie eine größere Wiederverwendbarkeit besitzt. Leider lässt sich, wie die Methode getSession zeigt, diese Adapter-Schicht nicht vollautomatisch generieren. Es ist deshalb von Vorteil, wenn das Framework selbst solche Adapter bereits besitzt oder durch Interfaces und Factory-Objekte so gekapselt ist, dass die direkte Verwendung von Dummy-Objekten möglich wird. Idealerweise bringt das Framework in einem zusätzlichen Paket sogar eine Reihe von Dummy-Klassen mit, die für verschiedene Testzwecke verwendet werden können. Dies würde die Testentwicklung in Framework-abhängigen Projekten stark vereinfachen.

Umgekehrt ist es aber auch sinnvoll, bei der Veröffentlichung einer Komponente oder eines Frameworks eine Sammlung von Tests mit herauszugeben, die demonstriert, dass die Komponente beziehungsweise das Framework sich entsprechend einer gegebenen Spezifikation verhält. Dies dient gleichzeitig dazu, das Zutrauen der Komponentennutzer zu erhöhen und den Nutzern durch Beispiele die Anwendung zu erklären.


Bernhard Rumpe. Agile Modellierung mit UML. Springer 2012