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.

using System.Collections.Generic;

namespace refactoringiosp
{
    public class Rechnungslegung
    {
        private readonly RechnungsStore rechnungsStore = new RechnungsStore();
        private const double UStSatz = 0.19;
        public void RechnungspositionenErstellen(IEnumerable<Bestellposition> Bestellpositionen) {
            foreach (var bestellposition in bestellpositionen) {
                var rechnungsposition = new Rechnungsposition {
                    Bezeichnung = bestellposition.Bezeichnung,
                    Einzelpreis = bestellposition.Einzelpreis,
                    Menge = bestellposition.Menge
                };

                rechnungsposition.SummeNetto = rechnungsposition.Menge * rechnungsposition.Einzelpreis;
                rechnungsposition.USt = UStSatz * rechnungsposition.SummeNetto;
                rechnungsposition.SummeBrutto = rechnungsposition.SummeNetto + rechnungsposition.USt;
                rechnungsStore.Save(rechnungsposition);
            }
        }
    }
}

using System;
namespace refactoringiosp
{
    public class RechnungsStore
    {
        public void Save(Rechnungsposition rechnungsposition) {
            Console.WriteLine($"Save: {rechnungsposition.Menge} x {rechnungsposition.Bezeichnung} á {rechnungsposition.Einzelpreis}");
        }
    }
}

namespace refactoringiosp
{
    public class Bestellposition
    {
        public string Bezeichnung { get; set; }

        public double Menge { get; set; }

        public double Einzelpreis { get; set; }
    }
}

namespace refactoringiosp
{
    public class Rechnungsposition
    {
        public string Bezeichnung { get; set; }

        public double Menge { get; set; }

        public double Einzelpreis { get; set; }

        public double SummeNetto { get; set; }

        public double USt { get; set; }

        public double SummeBrutto { get; set; }
    }
}

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:

using System.Collections.Generic;

namespace refactoringiosp
{
    public class Rechnungslegung
    {
        private readonly RechnungsStore rechnungsStore = new RechnungsStore();
        private readonly Rechnungserstellung rechnungserstellung = new Rechnungserstellung();
        public void RechnungspositionenErstellen(IEnumerable<Bestellposition> bestellpositionen) {
            foreach (var bestellposition in bestellpositionen) {
                var rechnungsposition = rechnungserstellung.RechnungspositionErstellen(bestellposition);
                rechnungsStore.Save(rechnungsposition);
            }
        }
    }
}

namespace refactoringiosp
{
    public class Rechnungserstellung
    {
        private const double UStSatz = 0.19;
        public Rechnungsposition RechnungspositionErstellen(Bestellposition bestellposition) {
            var rechnungsposition = new Rechnungsposition {
                Bezeichnung = bestellposition.Bezeichnung,
                Einzelpreis = bestellposition.Einzelpreis,
                Menge = bestellposition.Menge
            };
            rechnungsposition.SummeNetto = rechnungsposition.Menge * rechnungsposition.Einzelpreis;
            rechnungsposition.USt = UStSatz * rechnungsposition.SummeNetto;
            rechnungsposition.SummeBrutto = rechnungsposition.SummeNetto + rechnungsposition.USt;
            return rechnungsposition;
        }
    }
}

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.

 

 

18 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.

    Antworten
    • 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

      Antworten
  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. 🙁

    Antworten
  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

    Antworten
    • 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.

      Antworten
  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)“?

    Antworten
    • 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.

      Antworten
      • „Auf die Entscheidung zu reagieren und dann entweder „links rum“ oder „rechts rum“ zu gehen, ist Aufgabe der Integration.“

        Habe ich dann aber nicht eine „domänenbehaftete“ IF-Verzweigung in der Integration? Das was Erik beschreibt, ist genau mein Problem (das „Links-rum-Rechts-rum-Szenario“), welches ich beim Verständnis des IOSP habe. Während ich auf theoretischer Ebene den Wert der Regel erkenne, finde ich es praktisch sehr schwierig umzusetzen.

        Im Grunde dürfte die Integration ja nur Sequenzen von Methodenaufrufen enthalten, sollte die Regel hart „keine Kontrollstrukturen“ heißen (eine foreach-Schleife ist ja letztlich auch nur verkürzte Syntax für eine bedingungslose Sequenz, so dass es in Deinem Beispiel passt).

        Wenn aber IF-Verzweigungen in der Integration unter bestimmten Bedinungen erlaubt seien, würde mich interessieren, woran ich erkenne welche das Prinzip nicht verletzen und welche es tun.

        Antworten
        • Hallo Michael,

          ich halte ein if in der Integration für vertretbar, wenn der Ausdruck vollständig in eine Methode ausgelagert ist. In Ordnung wäre also

          if(KundeIstKreditwürdig(kunde)) { … }

          Nicht in Ordnung wäre dagegen folgendes:

          if(kunde.Status == Status.Gold) { … }

          Dann wäre Domänenlogik im Ausdruck des if Statements.
          Eine gute Regel ist für mich in der Praxis, ob der Ausdruck der Verzweigung isoliert getestet werden kann. Ist der Ausdruck in eine Methode ausgelagert, trifft dies zu.

          foreach und for in kanonischer Weise sind ebenfalls in Ordnung, weil es dabei lediglich darum geht, eine Methode auf jedem Element aufzurufen.

          Antworten
          • Wow, danke für die Blitzantwort 🙂

            In Extremfall würde Dein Beispiel dann so aussehen (Syntaxfehler bitte ignorieren, bin eher in Python unterwegs):

            Integration:
            if(hatGoldStatus(kunde)) { … }

            Operation:
            public boolean hatGoldstatus(Kunde kunde): {
            if (kunde.Status == Status.Gold) {
            return True;
            }
            else {
            return False;
            }

            Bläht das den Code nicht unwahrscheinlich auf?

          • Theoretisch kannst du dein gesamtes Programm in die main Methode schreiben. Warum tust du das nicht? Warum teilst du es in Methoden auf?
            Mir geht es hier um zwei Werte:
            Wandelbarkeit – Das Programm soll auch nach Jahren noch verändert werden können
            Korrektheit – Durch automatisierte Tests möchte ich sicherstellen, dass auch nach Änderungen noch alles funktioniert.

            Sowohl die Wandelbarkeit als auch die Korrektheit werden verbessert, wenn ich den Ausdruck in eine Methode herausziehe. Es geht hier nicht um die Optimierung des Schreibens und Erstellens. Stattdessen optimieren wir besser auf das Lesen und Modifizieren. Wenn dazu weitere Methoden erforderlich sind, fein.

          • Hallo Stefan,

            danke nochmal für die schnelle Antwort.
            „Theoretisch kannst du dein gesamtes Programm in die main Methode schreiben. Warum tust du das nicht? Warum teilst du es in Methoden auf?“

            Ich glaube das ist falsch rübergekommen: mir geht es nicht um eine Grundsatzdiskussion ob das Prinzip durchsetzbar oder sinnvoll ist. Vielleicht zum Hintergrund: ich bin kein erfahrener Entwickler, sondern würde mich maximal als fortgeschrittenen Anfänger bezeichnen. Ich bin auf der Suche nach Wegen, wie ich meinen Code verbessern kann und bin so über die Clean-Code-Prinzipen gestolpert.

            Während die anderen Prinzipien im „roten Grad“ recht gut einleuchten, fand ich das IOSP sehr schwer zu verstehen, bzw. umzusetzen. Als Anfänger fällt mir einfach die Abwägung schwer, wie streng ich mich an solche Regeln wie „keine Kontrollstrukturen in Integrationen“ halten sollte, bzw. bin ich auf der Suche nach mehr oder weniger eindeutigen Regeln. Da Dein Blog eine der wenigen Quellen mit einem nachvollziehbaren Beispiel war, wollte ich einfach mal nachbohren. Ich bin sehr dankbar, dass Du Dir die Zeit nimmst zu antworten!

  5. Hallo Stefan,

    ich habe mit Interesse Deinen Artikel gelesen und mich beschäftigt tatsächlich noch eine Frage. Dein Bild am Schluß des Beitrags, wo eine Struktur konsequent das IOSP-Prinzip einhält. Wie geht man vor, wenn die Funktionen f1, f2, f3 und f4 nicht nur einen Kontrollfluss besitzen, sondern noch einen Alternativen.

    Das würde bedeuten, dass ich nach jeden Integrationsschritt f überprüfen müsste ob der Aufruf erfolgreich war. Also f1 Erfolgreich, dann f2 – falls erfolgreich dann f3 usw. Wie würdest Du das Ganze lösen?

    Danke

    Antworten
  6. Hallo Stefan,

    ich hätte eine Frage zum Thema I/O bzw. API-Aufrufe:
    Würdest du API-Aufrufe, die keine Logik sondern nur den Aufruf, z.B. ein DB-Query oder File-Zugriffe, selbst enthalten als Operator oder Integrator einordnen? (siehe auch https://clean-code-developer.de/die-grade/roter-grad/#Integration_Operation_Segregation_Principle_IOSP)
    Ich denke dass so etwas eher ein Integrator wäre, da ja keine Logik drin ist die unit-testen will.
    Wie siehst du dass und warum?

    Viele Grüße

    Johannes

    Antworten
    • Hallo Johannes,

      die Regeln des IOSP sind eigentlich sehr einfach. Folgendes ist jeweils erlaubt:
      Integration:
      – Aufruf anderer Methoden meiner Lösung

      Operation:
      – Aufruf von Framework/Runtime Methoden (sog. APIs)
      – Ausdrücke

      Insofern spielt es keine Rolle, welche Framework API du aufrufst. Ob File IO oder String API, der Aufruf ist dann Bestandteil einer Operation. Vielleicht willst du nicht alle Operationen Unit Testen. Klar. Dennoch gehört der Aufruf einer Framework API in die Kategorie Operation.

      Die Beschreibung auf der CCD Website ist da noch etwas unklar, ich werde sie bei Gelegenheit überarbeiten.

      Viele Grüße
      Stefan Lieser

      Antworten
  7. Hey Stefan,

    ich hätte eine Frage zum Thema Unit Tests. Es steht für mich außer Frage das die Operation vollständig mit Unit Tests getestet werden muss. Wie sieht es mit der Integration aus? Mocke ich alles weg was dort aufgerufen wird aus anderen Integrationen? Irgendwie testet man letztlich dann aber nur ob bestimmte Methoden X mal aufgerufen werden. Macht der Unit Test dann noch Sinn? Mich würde brennend deine Meinung zu dem Thema interessieren.

    Gruß
    Daniel

    Antworten
    • Hallo Daniel,

      die Operationen werden mit Unit Tests abgedeckt, wie du es geschrieben hast. Und in der Tat ergeben Pseudo-Integrationstests mit Mocks keinen Sinn. Daher: die Integrationen werden mit echten Integrationstests getestet. Dabei kommen nur in seltenen Ausnahmen Mocks zum Einsatz. Eine Datenbank teste ich bspw. mit (bspw. mit TestContainers/Docker). Aber einen Emailversand würde ich eher wegmocken.

      Dazu habe ich bspw. auch hier geschrieben: https://ccd-akademie.de/integrationstests-mit-docker/

      Grüße,
      Stefan

      Antworten

Schreibe einen Kommentar zu Reinhard Antworten abbrechen