Testbarkeit wieder herstellen: das IOSP

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.

 

 

8 Gedanken zu “Testbarkeit wieder herstellen: das IOSP

  1. Hallo Stefan,
    für mich waren Kontrollstrukturen bis jetzt immer ausgeschlossen in Integrationen. Du verwendest eine foreach-Schleife in Deiner Integration RechnungspositionErstellen.
    Wo muss man Deiner Meinung nach bei Kontrollstrukturen von Domain-Logik sprechen und wo nicht? Macht es sich vielleicht an der Verwendung von Domain-Audrücken (im Sinne von Expessions der Programmiersprache) fest? Eine While-Schleife mit Abbruchbedingung aus der Domainlogik wäre demnach z.B. nicht erlaubt in Integrationen.

    • Hallo Denis,

      jep, mit Kontrollstrukturen in der Integration muss man sehr vorsichtig umgehen. Ein foreach ist ok, weil dabei keine Domänenlogik zum Einsatz kommt. Ein for, das über alle Elemente iteriert, ist ebenfalls ok. Steht aber im for z.B. eine Bedingung, die Domänenlogik enthält, ist es keine reine Integration mehr. Das gleich gilt für while Schleifen, wie du es geschrieben hast.
      Das größte Risiko sehe ich darin, dass der nächste Entwickler, der den Code ergänzt, das Konzept nicht versteht und dann Domänenlogik ergänzt. Dann hast du eine IOSP Verletzung im Code. Es sollten also alle Entwickler wissen, was es mit dem IOSP auf ich hat, damit nicht schleichend doch wieder Operationsanteile in den Integrationen auftaucht.
      Grüße
      Stefan

  2. Web-Site: Dein neuer Artikel taucht bei mir nicht in der Kategorie „Automatisches Testen“ auf. Er ist praktisch nur über die Suche zu finden…
    … und ich habe keine Mail-Benachrichtigung bekommen, dass Du geantwortet hattest. Hatte ich irgendwie erwartet. 🙁

  3. Hallo Stefan,

    eine Frage zur Datenstruktur „Bestellposition“. Ist es ok wenn sich alle Operationen die gleiche Datenstruktur teilen (bzw. von den Integrationen übergeben werden) und die Operationen hohlen sich die benötigen Daten raus, oder sollte man die Operationen so schlank wie möglich halten und nur die wirklich benötigen Parameter übergeben?

    Grüße,
    Reinhard

    • Das ist in der Tat immer wieder abzuwägen. Operationen sollten sich nur an die Daten binden, die sie selbst benötigen. Daher ist es in einigen Fällen sinnvoll, spezielle Datenstrukturen bereitzustellen. Im konkreten Beispiel würde ich das allerdings nicht tun. Hier sind nur wenige Operationen von der Datenstruktur abhängig und alle Operationen gehören zum selben Thema. Erst wenn die Themen unterschiedlich sind oder nur ein Bruchteil der Daten benötigt werden, würde ich eine weitere Struktur einführen.

  4. Was mache ich, wenn ich in der Logik weitere Daten brauche, die ich jedoch nur durch Operationsaufrufe bekomme, z.B. aus der DB lese? Ich transformiere bspw. eine Datenstruktur in eine andere (z.B. meine interne in eine für einen externen Service-Aufruf) und brauche dabei DB-Lookups.

    Wie ist es, wenn es schon auf „oberer Ebene“ eine Fallunterscheidung gibt, z.B. „suche mit input nach vorhandenen Daten. Gibt es Treffer, mache A (mehrere Operationen), ansonsten mache B (mehrere Treffer)“?

    • Wenn die Domänenlogik zwischendrin Daten aus der DB benötigt, zerfällt sie in zwei Teile, plus den Ressourcenzugriff. Die Integration ruft dann zunächst den ersten Teil Logik, dann die DB, dann wieder Logik auf.

      Die Fallunterscheidung findet auf der obersten Ebene statt. Die Entscheidung selbst ist Aufgabe einer Operation. Auf die Entscheidung zu reagieren und dann entweder „links rum“ oder „rechts rum“ zu gehen, ist Aufgabe der Integration.

Schreibe eine Antwort auf Erik Antworten abbrechen