An dieser Stelle werden wir monatlich einen Blog-Beitrag veröffentlichen, der die Anti-Pattern aus unserem Anti-Pattern Kalender 2014 zum Thema Testing genauer beleuchtet.
Um die Wartezeit bis zum nächsten Jahr zu überbrücken, folgt als verfrühtes Weihnachtsgeschenk ein Beitrag zum Anti-Pattern des Kalenderdeckblatts – Das Durcheinander.
Warum einfach, wenn es auch kompliziert geht?
Im Durcheinander finden sich Testfälle mit einem komplizierten Setup. Oftmals ist nicht mehr ersichtlich, ob das Test-Setup oder das Verhalten selbst den Test fehlschlagen ließ. Welch ein Chaos!
Das Problem
Das Durcheinander beschreibt einen Test, bei dem das Test-Setup oder ein Testfall lang und kompliziert aufgebaut werden muss und damit unübersichtlich wird. Oftmals wird bei solchen Tests intensiv Gebrauch von Mocking und Stubbing gemacht, um alle benötigten kollaborierenden Objekte für den Test zur Verfügung zu stellen.
Das bringt mehrere Probleme mit sich.
Zum einen wird es dadurch schwer nachvollziehbar, was der Test eigentlich testet. Bis zu dem Extrem, bei dem der Testfall nicht mehr das erwartete Verhalten prüft, sondern lediglich komplizierte Objektnetze aufbaut und letztlich nur prüft, ob das Object-Under-Test (OUT) das vorher gemockte oder vorgegebene Ergebnis zurückliefert.
Zum anderen kann es sehr aufwendig sein, den Test anzupassen, wenn sich das Verhalten des OUT ändert.
Aber auch das Verhalten der im Setup verwendeten kollaborierenden Objekte kann sich ändern. Schlägt ein solcher Test fehl, so kann die Ursachensuche schnell ausarten.
In der Folge wird meist verzweifelt versucht, diese Testfälle immer wieder umzustrukturieren, bis sie irgendwann deaktiviert werden.
Zusammenfassend kann, wenn ein Test im Durcheinander fehlschlägt, oft nicht mehr beurteilt werden, ob der Testaufbau oder das zu testende Verhalten inkorrekt ist.
Ein Beispiel
Das folgende Beispiel zeigt zwar einen eher harmlosen Vertreter des Durcheinanders, macht aber genannte Probleme gut sichtbar.
public class CarTest { @Test public void carHasFourMichelinTires() { Tire michelinTire = new Tire("Michelin"); Chassis chassis = new Chassis(); Wheel wheel1 = new Wheel(); wheel1.setTire(michelinTire); chassis.addWheel(wheel1); Wheel wheel2 = new Wheel(); wheel2.setTire(michelinTire); chassis.addWheel(wheel2); Wheel wheel3 = new Wheel(); wheel3.setTire(michelinTire); chassis.addWheel(wheel3); Wheel wheel4 = new Wheel(); wheel4.setTire(michelinTire); chassis.addWheel(wheel4); Car car = new Car(chassis); // each wheel should be of brand "Michelin" assertEquals("Michelin", car.getChassis().getWheels(). get(0).getTire(). getBrand()); assertEquals("Michelin", car.getChassis().getWheels(). get(1).getTire(). getBrand()); assertEquals("Michelin", car.getChassis().getWheels(). get(2).getTire(). getBrand()); assertEquals("Michelin", car.getChassis().getWheels(). get(3).getTire(). getBrand()); // new car should have four wheels assertEquals(4, car.getChassis().getWheels(). size()); } }
Der Testfall behauptet zu prüfen, ob Car-Objekte vier Reifen vom Typ „Michelin“ besitzen.
Der lange Testaufbau ist unübersichtlich und mit den vielen Konstanten fehleranfällig. Aber schlimmer noch – am Ende wird nur genau das geprüft, was im Testaufbau sowieso vorgegeben wird:
es wird ein Auto mit vier Rädern erzeugt und bei jedem Rad ein Reifen vom Typ „Michelin“ gesetzt.
Die Indizien
- Test-Setup oder Testfall mit vielen Zeilen Code
- Erzeugung vieler Objekte im Test
- Hoher Aufwand, einen einzelnen Testfall zu schreiben
- Viele Mocks
- Intensives Stubbing
- Hoher Aufwand für die Wartung des Test
- Domänenlogik im Test
- Viele Assertions in einem Test
- Schwer nachzuvollziehen, warum ein Test fehlschlägt
Die Ursachen
Im Wesentlichen existieren zwei Möglichkeiten, wie es zum Durcheinander kommen kann.
Entweder wird versucht, ein bestehendes, verflochtenes Objekt mit Tests zu überziehen oder es wurden während der Implementierung zwar stets Tests geschrieben, das OUT jedoch nie refaktoriert.
In beiden Fällen ist das OUT komplex, hat zu viele Verantwortlichkeiten – High-Cohesion und Low-Coupling wurden nicht eingehalten – und dem Law of Demeter wurde nicht Folge geleistet. Oft liegt auch ein God object vor.
Solche Monster-Objekte kommen zustande, wenn in der Designphase oder während der Entwicklung grundlegende Paradigmen der Objektorientierung, wie beispielsweise die Trennung von Verantwortlichkeiten, ignoriert wurden.
Erschwerend kommt hinzu, dass Tests oft erst nach der Implementierung geschrieben werden und letztlich der Test und nicht das OUT redesigned wird. Somit wächst die Komplexität des Testaufbaus mit dem des OUT.
Die Ursache für das Durcheinander kann aber auch im Testansatz selbst liegen. Manchmal schreiben Entwickler auch einfach Tests, indem sie sich an der Implentierung entlanghangeln in der Hoffnung, sich irgendwann daran zu erinnern, was sie eigentlich testen wollen. Eine undurchdachte Kombination von Stubbing, Mocking, einer Mischung aus mit einem Mocking-Framework erstellten Mocks und selbst geschriebenen Mocks oder eine unüberlegte Verteilung des Test-Setups in die statischen Teile der Test-Klasse, die Setup-Methode und den Test selbst können schnell unübersichtliche Tests entstehen lassen. Gerade wenn man den Grundsatz von Modultests ignoriert, sich auf die Funktionalität bereits getesteter Objekte und Methoden zu verlassen und diese nicht immer wieder mitzutesten.
Die Lösung
So viel vorneweg: es ist einfach dem Durcheinander vorzubeugen, jedoch oft schwierig, es zu entwirren.
Vorbeugen
Der konsequente Einsatz von Test Driven Development, insbesondere des Refactoring-Schritts, verhindert das Entstehen des Durcheinanders z.B. ursächlich in God objects oder Spaghetticode. Beim Hinzufügen jedes weiteren Verhaltens werden zuerst Tests geschrieben, teilweise bereits refaktoriert und die Frage nach Verantwortlickeiten des OUT gestellt, noch bevor eine Zeile der Implementierung hinzugefügt oder verändert wird.
Im Refactoring-Schritt wird die Implementierung und ggf. der Test selbst angepasst, um Code-Duplizierungen zu eliminieren, Namensgebungen und Lesbarkeit im Allgemeinen zu verbessern, aber auch klassenübergreifend versucht, die Kohäsion zu erhöhen.
Beides führt – konsequent durchgeführt – zu klaren, gut testbaren Code, klaren Verantwortlichkeiten und einer sauberen Mikroarchitektur.
Nacharbeiten
Sieht man dem Durcheinander bereits ins Auge, so wird dessen Auflösung schon ein wenig aufwendiger. Denn so vielfältig die Ursachen, so verschieden sind auch die Möglichkeiten, Ordnung und Struktur in das Durcheinander zu bekommen.
Prinzipiell bedarf es eines behutsamen Umbaus des OUT. Doch meist ist es schwierig, einen Ansatzpunkt für die Aufräumarbeiten zu finden.
Doch keine Sorge: im Folgenden werden ein paar Ansatzpunkte aufgezeigt:
Testfall aufteilen
Manchmal ist es möglich. einen langen unübersichtlichen Testfall in mehrere kleinere Testfälle zu unterteilen. Hierbei wird nicht die Testabdeckung reduziert, sondern auf mehrere Testfälle aufgeteilt, die gezielt Aspekte des gewünschten Verhaltens des OUT behandeln.
In der Folge kann u.U. auch das Test-Setup für die einzelnen Testfälle kürzer und übersichtlicher gehalten werden.
Hierarchisch testen
Ein wichtiges Prinzip, um übersichtliche Testfälle zu erstellen, ist es, sich auf bereits getestete Module zu verlassen und diese nicht jedesmal mitzutesten.
Dazu wird zuerst duplizierter Code im OUT identifiziert und in separate Methoden ausgelagert. Zudem werden Methoden identifiziert, die von mehreren Stellen im OUT aufgerufen werden.
Anschließend kann man sich überlegen, ob Teilmengen dieser Methoden semantisch zusammengehören und in eigene Objekte ausgelagert werden können. Notfalls kann man diese Methoden auch erstmal in Utility-Klassen übertragen.
Diese separierten Methoden können nun isoliert getestet werden. Anschließend kann man sich auf die Funktionalität dieser Objekte verlassen. Damit fällt der Test und der Testaufbau des OUT oft deutlich schlanker aus.
Prinzipien der Objektorientierung einführen
Anschließend kann man anfangen, nach und nach Prinzipien der Objektorientierung z.B. nach SOLID oder das Law of Demeter einzuführen. Die Schritte dazu werden im folgenden Folgenden beleuchtet.
Vorneweg sollte man überprüfen, ob das OUT in sich konsistent ist, in dem Sinne, dass es die eigene Fachlogik beheimatet. So kann man prüfen, ob etwa Instanzen erzeugt werden können, die der Fachlogik entgegenstehen, wie etwa ein Auto ohne Räder. Eine weiteres Indiz für potenziell inkonsistente Objekte und schlechte Objektstruktur ist, wenn Logik ausserhalb des Objekts liegt, so dass für Berechnungen oder Entscheidungen lediglich der Status des Objekts abgefragt wird, die Entscheidungen und Berechnungen aber ausserhalb des Objekts erfolgen. Ein Beispiel wäre eine Klasse Konto, bei dem die Zinsen berechnet werden, indem der Kontostand von aussen abgefragt wird und später wieder gesetzt wird, anstatt diesen vom Objekt selbst berechnen zulassen.
Auch das Einziehen von Interfaces, eventuell in Kombination mit Dependency Injection, können helfen, das OUT isolierter zu testen und den Testaufbau einfacher zu halten.
Im nächsten Schritt kann die Schnittstelle des OUT betrachtet werden. Welche Aspekte des OUT sollen nach aussen sichtbar sein, was nur nach innen? Wie bereits im Abschnitt zum hierarchischen Testen erwähnt, können auch auf diese Weise Methoden identifiziert werden, die eventuell gar nicht in der Verantwortlichkeit des OUT liegen und in andere Klassen ausgelagert werden können.
Für obiges Beispiel bedeutet das, nur in sich konsistente Objekte zu erlauben. D.h. ein Objekt von Typ Car
nimmt direkt den Reifentyp im Konstruktor an. Der Konstruktor ist für die korrekte und in sich konsistente Erstellung eines Autos verantwortlich. Dadurch fällt der Testfall deutlich kompakter aus und es wird tatsächlich Domänenlogik getestet anstatt nur das Testsetup.
Zudem scheinen im bestehenden Anwendungsfall die Reifen Tire
von Interesse zu sein und weder das Chassis
, noch die Räder vom Typ Wheel
. Deshalb wurde die Schnittstelle von Car
auf die benötigte Methode getTires()
reduziert. Damit wird das Law of Demeter wieder eingehalten.
Zusätzlich wurden die Testfälle auseinandergezogen und das Prinzip des hierarchischen Testens angewandt: da ein Testfall sicherstellt, dass jedes Auto vier Reifen besitzt, können in einem weiteren Testfall alle vorhandenen Reifen einfach durchiteriert und auf den erwarteten Reifentyp geprüft werden.
public class CarTest { private static final String TIRE_BRAND = "Michelin"; private Car car; @Before public void setup() { car = new Car(TIRE_BRAND); } @Test public void carHasFourTires() { assertEquals(4, car.getTires(). size()); } @Test public void carHasOnlyTiresOfGivenBrand() { for (Tire tire : car.getTires()) { assertEquals(TIRE_BRAND, tire. getBrand()); } } }
Einsatz von Pattern
Ein weiterer Ansatzpunkt ist die Einführung von Pattern zur Strukturierung des OUT und der kollaborierenden Objekte. Architektur-Pattern können helfen, Komponenten und Objekte eines Softwaresystems und insbesonderen deren Zusammenspiel zu organisieren und so klare Strukturen und Verantwortlichkeiten aufzubauen.
Verwandt mit
- Excessive Setup
- The Mockery
- The Giant
Siehe dazu auch James Carrs fantastischen Blogbeitrag zu TDD-Anti-Patterns und die dazugehörige Diskussion auf der Test-Driven Development Yahoo Group.
Gewinnspiel
Passend zum Durcheinander haben sich auch bei der Erstellung unseres Kalender ein paar Besonderheiten eingeschlichen. Wir haben es nicht anders verdient, als unseren kleinen Fauxpas mit einem kleinen Gewinnspiel wieder gut zu machen.
Das ganze läuft so: Alle gefundenen Besonderheiten und „Fehler“ gesammelt per E-Mail an kalender@holisticon.de schicken. Unter den besserwisserischsten Einsendungen wird ein kleines Geschenkpaket verlost.