In Legacy Code Projekten existieren typischerweise nur wenige oder gar keine automatisierte Tests. Die simple Empfehlung, diese doch bitte mal zu ergänzen, geht an der Realität vorbei. Der Code ist nicht mit Blick auf Testbarkeit konzipiert worden. Vor allem Abhängigkeiten und fehlende Aspekttrennung erschweren das Testen. Es hilft nichts: der Code muss refaktorisiert werden, bevor Tests ergänzt werden können. Dabei trifft man jedoch auf folgendes Dilemma:

  • Um den Code testbar zu machen, muss er refaktorisiert werden.
  • Bevor der Code refaktorisiert wird, sollten zunächst Tests ergänzt werden.

Aus diesem Dilemma gibt es keinen einfachen Ausweg, keinen Königsweg. Man muss eben irgendwo anfangen. Aufgrund des Risikos sollte dabei fokussiert und mit Umsicht vorgegangen werden. Oft hilft der Einsatz leistungsfähiger Tools wie TypeMock Isolator, um damit Abhängigkeiten durch Attrappen zu ersetzen.

Manchmal helfen aber auch einfache werkzeuggestützte Refaktorisierungen, die Situation im Sinne der Testbarkeit zu verbessern. Es muss nicht immer gleich der große Umbau der Software sein. Stattdessen sollte man kleinschrittig vorgehen und dabei regelmäßig und häufig Änderungen in die Versionskontrolle übertragen. So hält man sich einen schrittweisen Rückweg offen, sollte man sich doch einmal verrannt haben.

Die komplexen Refaktorisierungen sollten allerdings gezielt mit dem klaren Fokus durchgeführt werden, die Testbarkeit zu verbessern. Es gilt jetzt, sich nicht zu verzetteln. Dazu ist es hilfreich, ein wichtiges Prinzip in den Blick zu nehmen: Integration und Operation sind zu trennen. Das Integration Operation Segregation Principle (IOSP) besagt, dass eine Funktionseinheit entweder andere Funktionseinheiten integrieren soll, dann handelt es sich um Integration. Oder sie soll Logik enthalten und dann eben nicht auch noch integrieren. In diesem Fall sprechen wir von einer Operation. Operationen tragen zur Lösung des Problem bei, während Integrationen andere Funktionseinheiten integrieren, sie zusammenfügen. Die Integration kann hierarchisch sein. Das bedeutet, eine Integration kann Funktionseinheiten integrieren, die selbst wieder Integration sind. Es ergibt sich also eine Baumstruktur, an deren unterem Ende die Operationen liegen. Diese zeichnen sich dadurch aus, dass sie keine Abhängigkeit zu anderen Funktionseinheiten haben, denn dann wären sie ja wieder Integration. Die Operationen sind Blätter, weil sie selbst nicht andere Funktionseinheiten integrieren.

Eine Analogie finden wir in der Art und Weise menschlicher Zusammenarbeit. Diese funktioniert flüssig, wenn die beteiligten entweder in der Rolle Management oder Durchführender agieren. Management entspricht hier der Integration. Die Aufgabe von Management ist es, die Arbeitsabläufe zu organisieren und zu delegieren. Die Durchführenden sind diejenigen, welche die Tätigkeiten ausführen, selbst aber nicht delegieren. Werden die Rollen vermischt, ist die Ausführung nicht so flüssig wie sie sein könnte. Wenn Management sich in die Durchführung einmischt, entsteht mindestens Unklarheit. Ebenso kann etwas schief gehen, wenn Durchführende Teile ihrer Tätigkeit weiter delegieren. Eine klare Trennung der Aspekte hilft, eine arbeitsteilige Tätigkeit gut in Fluss zu bringen.

Das folgende Beispiel zeigt einen fiktiven Ausschnitt aus einem Softwaresystem zur Rechnungslegung. Der Code ist dafür zuständig, eine Bestellung in eine Rechnung umzuwandeln. Dazu werden einige einfache Berechnungen durchgeführt und die erstellten Rechnungspositionen dann gespeichert.

Das Problem an diesem Beispielcode: es wird das IOSP verletzt. Die Methode RechnungspositionenErstellen ist einerseits eine Operation, da sie eine Bestellposition in eine Rechnungsposition umwandelt. Andererseits ist sie Integration, da sie die Methode Save aus der Klasse RechnungsStore aufruft, diese also integriert.

Die Struktur dieses Codeausschnitts ist in der folgenden Abbildung zu sehen:

Abhängigkeiten vor IOSPDas Beispiel besteht aus den beiden Klassen Rechnungslegung und RechnungsStore. Rechnungslegung ist abhängig von RechnungsStore. Diese Abhängigkeit verläuft hier linear. Die Aspekte Domänenlogik und Ressourcenzugriff sind klar getrennt. Die beiden Klassen sind jeweils für nur einen dieser beiden Aspekte zuständig. Und trotzdem fällt es schwer, den Code automatisiert zu testen. Das liegt daran, dass Integration und Operation vermischt sind. Die Klasse Rechnungslegung enthält die Domänenlogik, trägt also zur Lösung des Problems bei und ist damit eine Operation. Allerdings ruft sie auch den Ressourcenzugriff auf und integriert damit sich selbst mit einer weiteren Funktionseinheit. Und genau diese Verletzung des IOSP ist der Grund dafür, dass der Code schwerer zu testen ist, als notwendig.

Die Verletzung des IOSP findet hier sogar auf zwei Ebenen statt: die Methode RechnungspositionenErstellen enthält Domänenlogik und integriert den Aufruf des Ressourcenzugriffs. Auf der Ebene der Methoden liegt also eine IOSP Verletzung vor. Ferner liegt auf der Ebene der Klassen eine IOSP Verletzung vor: die Klasse Rechnungslegung enthält sowohl Domänenlogik als auch Integrationscode.

Eine Refaktorisierung sollte folgende Struktur zum Ziel haben:

Abhängigkeiten nach IOSPNun sind Integration und Operation klar getrennt. Die Blätter des Abhängigkeitsgraphen sind die Operationen. Da es Blätter sind, sie also von keinen anderen Funktionseinheiten abhängig sind, ist ein automatisierter Test leicht zu realisieren. Hinzugekommen ist die Funktionseinheit Rechnungserstellung, welche nun für die Domänenlogik zuständig ist. Die Klasse Rechnungslegung verhält sich nach außen so, wie vormals. Allerdings ist nun in dieser Klasse keine Domänenlogik mehr vorhanden, sondern nur noch Integrationscode. Das folgende Listing zeigt den Code nach der Refaktorisierung:

Nach der Refaktorisierung ist der Code deutlich einfacher zu testen. Das liegt daran, dass nun die beiden Operationen, aus denen die Lösung besteht, klar getrennt sind. Eine Operation enthält die Domänenlogik, eine weitere den Ressourcenzugriff. Ferner gibt es nun eine Integrationsmethode, welche die beiden Operationen zusammenbringt.

Eine Teststrategie sollte immer aus wenigen Integrationstests und vielen Unit Tests bestehen. Dies lässt sich hier gut umsetzen. Die Operationen können in Unit Tests isoliert getestet werden. Die Methode RechnungspositionErstellen ist eine Funktion: sie erhält ihren Input als Parameter und liefert das Ergebnis als Rückgabewert. Innerhalb der Methode werden keine weiteren zur Lösung gehörenden Methoden aufgerufen. Das war ja das Ziel der Refaktorisierung: Operation und Integration zu trennen. Somit lässt sich die Methode sehr leicht testen. Damit können wir nun die Domänenlogik mit vielen verschiedenen Testdaten und Szenarien testen, ohne in jedem Test mit Ressourcenzugriffen konfrontiert zu sein. Dennoch braucht es auf der oberen Ebene einen Integrationstest der sicherstellt, dass das Zusammenspiel der Operationen korrekt verläuft.

Im Sinne der Testbarkeit würde man bei diesem einfachen Beispiel auch ohne die Refaktorisierung zum Ziel kommen. Durch ein Extract Methode Refactoring könnte man die Domänenlogik aus der ursprünglichen Methode herauslösen und diese mittels internal und InternalsVisibleTo für den Test zugänglich machen. Hier geht es darum, das IOSP als Ziel für eine Refaktorisierungsmaßnahme darzustellen, daher habe ich ein einfaches Beispiel gewählt. In realem Legacy Code sind die Vermischungen der Aspekte und die Abhängigkeiten meist viel dramatischer als in meinem Beispiel. Das grundsätzliche Muster bleibt bestehen: Funktionseinheiten rufen jeweils ihren Nachfolger auf, wie folgende Abbildung zeigt:


Die Pfeile zeigen den Fluss der Daten von einer zur nächsten Funktionseinheit. Meist wird dies so implementiert, dass die Abhängigkeiten in der selben Richtung verlaufen. Damit ist die Testbarkeit nicht so einfach wie sie sein könnte. Es muss mit Attrappen gearbeitet werden, um Funktionseinheiten isoliert testen zu können. Mock Frameworks bieten diese Funktionalität. Doch wozu den großen Aufwand treiben, wenn es auch einfacher geht? Folgende Abbildung zeigt eine andere Struktur von Abhängigkeiten, bei der das IOSP eingehalten ist:


Die Daten fließen in gleicher Weise wie im ursprünglichen Entwurf. Doch nun verlaufen die Abhängigkeiten deutlich anders. Die Integration der Funktionseinheiten ist hier als eigenständiger Aspekt herausgelöst. Damit verschwinden die Abhängigkeiten an den Stellen, an denen sie den größten Schaden anrichten: die Abhängigkeiten werden aus der Domänenlogik rausgezogen und in eigens für die Abhängigkeiten zuständige Methoden verlagert. Bei neuem Code sollte man von Anfang an auf die Einhaltung des IOSP achten. Bei Legacy Code ist das IOSP ein sehr wirkungsvolles Ziel für Refaktorisierungen.