Blog

Testing-Anti-Pattern-Kalender 2014 – August – Der Schnüffler

In unserer monatlichen Blog-Reihe zu unserem Anti-Pattern-Kalender 2014 zum Thema Testing hier der Kandidat für den Monat August: Der Schnüffler.

Es gibt Dinge, die will man gar nicht wissen …

Objektorientierte Software soll modular so aufgebaut werden, dass es später möglich ist, Komponenten ohne großen Aufwand zu tauschen. Im Idealfall ist dies durch Änderung einer Konfigurationsdatei möglich oder sogar zur Laufzeit aus der Oberfläche heraus.

Damit das möglich wird, müssen alle diese Komponenten nach außen die gleiche Schnittstelle implementieren. Der Anwendung ist es dann egal, welche Implementierung verwendet wird, ihr Zuständigkeitsbereich endet an der Schnittstelle.

Das zugrunde liegende Prinzip wird als Datenkapselung bezeichnet, oder etwas plakativer auch als Geheimnisprinzip. Es geht dabei nicht darum, Daten vor der NSA oder dem Bundesnachrichtendienst zu verstecken, sondern um das Verbergen der internen Programmlogik vor anderen Modulen. Die Schnittstelle (es handelt sich zumeist tatsächlich um ein Java-Interface oder ähnliches) bildet dabei den Kommunikationsvertrag zwischen den Modulen.

Beispiele hierfür können sein:

  • Bezahlungsverfahren: einem Buchungssystem (Hotelzimmer, Konzertkarten, Schiffsreisen) ist es primär egal, auf welchem Wege eine Rechnung bezahlt wird: EC-Karte, Kreditkarte, Barzahlung. Für die Realisierung der Zahlungswege gibt es eigene Komponenten, für das System zählt nur der Geldeingang.
  • Kommunikationskomponenten können verschiedene Protokolle nutzen: HTTP, HTTPS, REST, 56k-Modem, Brieftauben (http://tools.ietf.org/html/rfc2549). Für die Systeme, die über diese Kommunikationsschnittstelle verbunden werden, stellt sich das ganze als End-To-End-Verbindung dar: es existiert eine Verbindung, mehr ist darüber nicht bekannt
  • eine Schachengine kann zu verschiedenen Zeitpunkten des Spiels unterschiedliche Strategien zur Ermittlung des nächsten Zuges anwenden: Im Spiel kommt eine Vorausberechnung aller möglicher nächster Züge zum Einsatz, was sehr rechenintensiv ist. Zur Spieleröffnung bietet es sich allerdings an, Bibliotheken mit bekannten Eröffnungszügen zu verwenden, und auch zum Spielende kommen Bibliotheken mit Schachmattszenarien zum Einsatz. Die Schachengine kann abhängig vom Spielstand die Berechnungsstrategie austauschen, für das Spiel und den Spieler ist nur interessant, welche Figur als nächste gezogen wird.

Damit das funktioniert, darf das System, das die Module verwendet, keine Kenntnis der internen Modullogik haben. Das gilt auch für die Tests.

Der Schnüffler kennt jedes Detail der Implementierung…

Stellen wir uns folgendes einfaches Szenario vor:

Eine Schnittstelle definiert eine Methode:

Collection getElements();

Diese Methode soll eine Collection von Elementen zurückliefern.

Nun wissen wir, dass die darunterlegenden Implementierungen ArrayLists zurückliefern. Das hat beim Testen einen gewaltigen Vorteil: wir können uns eine zweite ArrayList aufbauen, die unser erwartetes Ergebnis repräsentiert und diese mit dem Rückgabewert der Methode vergleichen.

List expected = new ArrayList();
 expected.add(„3“);
 expected.add(„5“);
 expected.add(„8“);
List result = (List) interface.getElements();
 assert(expected.equals(result));

…. blöd nur, wenn sich diese ändert.

Das funktioniert soweit. Aber nur (!) solange wir Instanzen von List über die Schnittstelle zurückliefern. Wenn sich in einer Implementierung nun der Rückgabewert von getElements() ändert (z.B. nach HashSet, weil die Elemente eindeutig sein sollen), dann bekommen wir beim Testen ein Problem, nämlich einen TypeCast-Fehler zur Laufzeit.

Wie Du Tests schreibst, ohne zu spionieren

Wir sollten uns daher genau an den Schnittstellenvertrag halten. Das Problem ist natürlich, dass wir unseren Erwartungswert nicht als Instanz der Collection definieren können – die Collection ist ein Interface. Wir können aber einen Umweg wählen, um das Ergebnis zu testen. Mit einfachen Datentypen ginge es so:

List expected = new ArrayList();
 expected.add(„3“);
 expected.add(„5“);
 expected.add(„8“);
List result = (List) interface.getElements();
assert(result.containsAll(expected));
assert(expected.containsAll(result));

Hier nutzen wir nur noch Methoden, die von allen Collections bereitgestellt werden. Ein Austausch der Implementierung im Modul wäre nun kein Problem mehr.

(Wenn unsere Collection komplexere Objekte enthält, muss unsere Testroutine natürlich die „Gleichheit“ der enthaltenen Objekte entsprechend prüfen können)

Nun könnten wir argumentieren: „Ja, aber die interne Logik des unbekannten Moduls muss doch auch getestet werden!“ – das ist richtig, aber dann sollten wir doch lieber pro Implementierung eigene Testfälle schreiben. Im Fall unserer Schachengine wären dies dedizierte Tests für die Eröffnungsbibliothek, für die Vorhersage und für die Schachmattszenarien. Diese Testfälle liegen dann isoliert von den Tests für die eigentliche Schachspielanwendung, die die Zugberechnung ja wiederum nur als Black Box sieht.

Black Box ist ein gutes Stichwort, denn beim Testen einer Schnittstelle hilft es, sich das gekapselte Modul als solche Box vorzustellen, deren Implementierung wir nicht kennen („Wat is en Dampfmaschin? Da stelle mer uns mal janz dumm…“). Es ist verlockend, das Geheimnisprinzip zu umgehen, zumal ein Teil der internen Logik über die Datentypen der Nachrichten zwangsläufig nach außen gegeben wird. Unsere Testfälle sollen aber ausschließlich das Verhalten testen, das wir nach dem Schnittstellenvertrag erwarten dürfen. Schlagen die Testfälle fehl, dürfen wir annehmen, dass die Umsetzung des Moduls unvollständig ist und nicht der Testfall!

Viel Spaß beim Schreiben robuster Tests!

Über den Autor

Antwort hinterlassen