• Teaser Home

    Clean Code Developer School

    Saubere Softwareentwicklung üben – regelmäßig, fokussiert, individuell, angeleitet

Zukunftsfähigkeit II – Korrektheit/Regressionsfreiheit

Ob Software schon und immer noch zukunftsfähig ist, wird im Review beurteilt. Den führt die Architektenrolle vor der Abnahme durch den Kunden umfassend durch. Geprüft wird auf

  • Korrektheit und
  • Wandelbarkeit

Beide Facetten der Zukunftsfähigkeit haben Aspekte, die es getrennt zu betrachten gilt. Bei der Korrektheit ist das zum einen die Maturität. Die ist vorhanden, wenn die Software schon bereit für die Abnahme/Auslieferung ist.

Zum anderen ist es die…

Regressionsfreiheit als Aspekt der Korrektheit

Software muss nicht nur schon korrekt sein in Bezug auf die aktuell umgesetzten Verhaltensanforderungen, sie muss auch noch korrekt sein in Bezug auf alle früheren.

Eine Regression liegt immer dann vor, wenn Software durch Veränderungen eine Qualität verliert, die sie bereits hatte. Je größer die Komplexität einer Codebasis, desto größer das Risiko für einen Rückfall auf ein vormaliges Entwicklungsniveau. Wenn die Codesituation unübersichtlich ist, dann ist „Verschlimmbessern“ eine ständige Gefahr. Gut gemeinte Verbesserungen an einer Stelle führen zu unbeabsichtigten Verschlechterungen an anderer. Wer hätte das als Anwender oder Entwickler nicht schon erlebt?

Regressionen sind schlimmer als Software, der die Maturität fehlt. Wenn neu hergestelltes Verhalten einen Fehler aufweist, dann ist das für Kunden nervig, aber halbwegs erwartet. So ist das eben mit Software… Das haben sie in vielen Jahren leidvoll gelernt.

Wenn jedoch Verhalten, das über Monate oder gar Jahre fehlerfrei war, auf einmal fehlerhaft ist, dann ist das ein Desaster. Hier wird dem Anwender der Boden unter den Füßen weggezogen. Er kann sich nicht mehr auf das verlassen, wozu er (entgegen allen Befürchtungen) Vertrauen gefasst hatte.

Regressionen führen zu einem schnellen tiefen Vertrauensverlust beim Kunden und werden regelmäßig eskaliert. Sie stören damit den ruhigen Fluss der Herstellung von Neuerungen erheblich. Nicht nur sind sie Nachbesserungen und daher Verschwendung, sie erfordern oft auch mehr Aufwand beim Aufbau eines mentalen Modells für die Korrektur, weil an den fehlerhaft gewordenen Stellen nicht aktuell gearbeitet wurde. Die Beziehung zum eigentlichen Arbeitsort war ja gerade nicht klar, sonst wäre keine Regression eingetreten.

Also: Regressionen sind zu vermeiden! Egal, was es kosten mag. Naja… fast ;-)

Das Mittel zur Herstellung von Regressionsfreiheit sind automatisierte Tests.

Während sich Maturität vielleicht noch mit manuellen Tests darstellen lässt, ist das für Regressionsfreiheit nicht der Fall. Manuelle Tests skalieren einfach nicht. Denn um zuzusichern, dass kein bisheriges Verhalten kompromittiert wurde durch aktuelle Veränderungen, müsste bisheriges Verhalten ja nochmals getestet werden.

Wenn die erste Iteration 3 Anforderungen umsetzt, sind Maturitätstests für 3 Anforderungen durchzuführen und 0 für die Regressionsfreiheit. Wenn dann jedoch die zweite Iteration 4 Anforderungen umsetzt, sind insgesamt 7 Tests durchzuführen: 4 für die Maturität und 3 für die Regressionsfreiheit. Bei der dritten Iteration mit 5 Anforderungen sind es 5+(4+3)=12 Tests, bei der vierten Iteration mit 2 Anforderungen 2+(5+4+3)=14 Tests usw. usf.

Wer konsequent Regressionsfreiheit überprüfen will, ist schon bald fast nur mit Regressionstests beschäftigt. Neue Anforderungen sind dann lediglich die Schaumkrone auf auf einer Welle in einem Ozean von Anforderungen, die alle, alle immer auch noch korrekt erfüllt sein wollen.

Ohne so konsequente Überprüfung von Regressionsfreiheit wird das Netz zum Fangen von Bugs mit jeder neuen Anforderungen weitmaschiger. Die Zahl der Tests im Verhältnis zur Anzahl der insgesamt umgesetzten Anforderungen nimmt ja stetig ab.

Ohne Automatisierung der Tests geht es also bei der Regressionsfreiheit nicht. Wer noch bei der Maturität meinte, ohne Automatisierung auszukommen, muss spätestens jetzt einsehen, dass das keine Option ist, wenn man die Zukunftsfähigkeit nicht leichtfertig aufs Spiel setzen will.

Wer bei der Maturität hingegen schon Ja zur Automatisierung gesagt hat, der hat ein Fundament für die Regressionsfreiheit gelegt. Die bleibenden Akzeptanztests spannen schon ein Netz auf, in dem sich Regressionen verfangen können.

Allerdings ist dieses Netz relativ weitmaschig. Je komplizierter die Domäne, desto weniger kann angenommen werden, dass Akzeptanztests genügend Pfade durch die verhaltenerzeugende Logik für das angestrebte Niveau von Regressionsfreiheit abdecken.

Der Review will für die Regressionsfreiheit also mehr bleibende Tests sehen als die Akzeptanztests. Die bilden allerdings das Fundament.

Aber was ist mit den Gerüsttests? Wäre es nicht doch gut, die zu behalten? Nein! Gerüsttests sind Gerüsttests und per definitionem zu löschen, nachdem sie geholfen haben, Maturität herzustellen. Sie testen ja Funktionen, die eigentlich privat sind und nicht unbedingt existieren. Sie gehören nicht zur Anforderungsdefinition des Kunden. Der ist lediglich an Eintrittspunkten interessiert, über die er Logik mit Input von außen triggern kann.

Anders liegt der Fall jedoch bei öffentlichen Methoden, die nicht vom Kunden gewünscht sind. Sie stellen Schnittstellen dar, über die Klassen von anderen benutzt werden können und sollen. Jede öffentliche Methode ist Teil eines Versprechens, nicht nur die an der Oberfläche einer Software.

Der Review überprüft daher im Hinblick auf Regressionssicherheit, ob alle öffentlichen Methoden mit angemessenen automatisierten Tests versehen und somit nachweisbar korrekt sind. Diese Tests lassen auch nachvollziehen, was sich die Entwickler dabei gedacht haben, Klassen zu definieren, die über das unmittelbar vom Kunden Gewünschte hinausgehen. Diese Klassen stellen ja eigenständige Verantwortungsbereiche dar. Insofern gibt es für sie ebenfalls Anforderungen und daher auch Akzeptanztests.

Software ist ein selbstähnliches Gebilde. So wie es sich nach außen darstellt – als ein Werkzeug, das Anforderungen erfüllt -, so ist sie innen selbst strukturiert: als eine Versammlung von Werkzeugen mit spezialisierteren Aufgaben.

Die äußere Form dieser Werkzeuge sind Module. Das sind Container für Logik, die einen Kontrakt definieren, der nach außen ein syntaktisches und ein semantisches Versprechen formuliert. „So kannst du mich benutzen. Das ist es, was ich tue.“

Der syntaktische Kontrakt besteht aus Funktionssignaturen. Der semantische Kontrakt ist unzweideutig beschrieben durch automatisierte Tests.

Funktionen sind die kleinsten Module. Klassen fassen Funktionen zu Modulen einer höheren Ebene zusammen. Bibliotheken wiederum fassen Klassen zusammen. Komponenten fassen Bibliotheken zusammen und Services Komponenten.

Auf die unterschiede der einzelnen Modulebenen soll an dieser Stelle nicht eingegangen werden. Für den Review der Regressionsfreiheit ist lediglich wichtig, dass jedes Modul auf jeder Ebene einen Kontrakt definiert, auf dessen korrekte Erfüllung Konsumenten der Module vertrauen.

Ultimativ ist das der Kontrakt gegenüber dem Anwender in Form einer Benutzerschnittstelle. Der ist allerdings vergleichsweise schlecht automatisiert zu testen. Es braucht dafür spezielle Werkzeuge und die Tests müssen besonders robust sein, da die Benutzerschnittstelle vielen Änderungen unterliegt, die nicht zwangsläufig zu Anpassungen von Tests führen sollen.

Doch schon kurz unter dieser Oberfläche lässt sich Logik in Modulen kapseln, die leicht zu testen sind, weil sie unabhängig von einer Benutzerschnittstellentechnologie sind. Dort erwartet der Review automatisierte Tests aller Modulkontrakte.

Wo also Methoden öffentlich sind, weil sie zu einem Kontrakt gehören, müssen ihnen Tests gegenüberstehen. Das ist eine konsequente Fortführung der Regel „Keine Anforderung ohne Akzeptanztest“.

Ob die Methoden geplant von vornherein öffentlich waren oder bei einer Refaktorisierung entstanden sind, ist unerheblich. Ein Akzeptanztest muss sein. Ja, sogar bei Refaktorisierungsergebnissen. Denn öffentliche Methoden sind, nun, öffentlich. Sie stellen ein Versprechen dar, das erstens explizit formuliert werden sollte (semantischer Kontrakt in Form eines Tests als Dokumentation) und zweitens immer wieder auf Einhaltung überprüft werden sollte (Regressionsfreiheit). Denn wenn alle Teile ihre Versprechen erfüllen, dann wird wohl auch das Ganze seine Versprechen erfüllen. Ein plausibler Gedanke, oder?

Aus dieser Richtung betrachtet werden auch nochmal Gerüsttests interessant. Sollte nämlich ein Gerüsttest einen Aspekt überprüfen, der nicht auch irgendwie in Akzeptanztests abgedeckt ist, dann gibt es nicht nur die Möglichkeit, die Akzeptanztests zu erweitern, um auch nach Löschen des Gerüsttests weiterhin den Aspekt im Blick zu behalten.

Die Alternative ist, diesen Aspekt, der bisher eigentlich ein unsichtbares Detail war, zu einem eigenständigen sichtbaren Teil aufzuwerten. Eine bisher eigentlich private Funktion wird dann öffentlich – und wandert in eine eigene Klasse. Sie ist damit Teil eines Kontraktes – und muss bleibend getestet werden. Zugehörige bisherige Gerüsttests werden damit quasi offiziell. Aus Gerüsten werden Pfeiler, d.h. tragende Strukturelemente.

Der Wunsch, „spannende“ Tests zu erhalten, ist mithin ein Treiber der Modularisierung von Software. Wenn Sie sich bisher gefragt haben, wie Sie Klassen „schneiden“ sollen, dann haben Sie jetzt ein weiteres Kriterium.

Es gehört daher nicht nur zum Review zu prüfen, ob Kontrakte unter Test stehen, sondern auch, wo noch Kontrakte definiert werden sollten. Durch Extraktion von Modulen werden dann Grenzen gezogen, die einerseits informieren, andererseits stabilisieren.

Sie informieren, weil sie Semantik beschreiben, die sonst undokumentiert wäre.

Sie stabilisieren, weil hinter einem Kontrakt das Innere eines Moduls sich unabhängig von der Umgebung entwickeln kann, solange der Kontrakt eingehalten wird.

Beispiel

Bezogen auf das Beispiel zur Maturität würde die Regressionsfreiheit noch eine weitere Option eröffnen. Sie erinnern sich? Es ging um Aggregationsfunktionalität: die Zahlen in einer Textdatei waren zu summieren. Nach dem Review der Maturität sah der Code so aus:

Aus einem Akzeptanztest und drei Gerüsttests für Load() und Calculate_sum() wurde ein erweiterter Akzeptanztest, der auch noch die Fälle der Gerüsttests von Load() abdeckt.

Das war eine legitime Entscheidung innerhalb des Horizonts Maturität. Doch warum nicht die Erkenntnis, dass zwei Gerüsttests erhalten bleiben sollen, dazu nutzen, die Modularität zu erhöhen und die Regressionsfreiheit zu verbessern? Mehr Module bedeutet mehr Oberfläche zur Überprüfung, ob Code nach Veränderung weiterhin korrekt ist.

Mit der Regressionsfreiheit im Blick könnte das Review-Ergebnis z.B. so aussehen:

Aggregator{} wäre als Geschäftslogikklasse auf die Summation konzentriert. FileProvider{} würde den Datenzugriff kapseln. Eine saubere Trennung von zwei sehr unterschiedlichen Verantwortlichkeiten, die mittels Tests dokumentiert würde:

Der Akzeptanztest könnte wieder so einfach sein, wie zunächst mit dem Kunden verabredet, der lediglich diesen Inhalt für numbers.txt vorgesehen hatte:

Der Akzeptanztest wäre fokussierter auf den happy day, auf das Wesentliche, auf die Integration von Teilfunktionalitäten. Sonderfälle würden an der Schnittstelle des Teiles geprüft. Die ist nicht öffentlich dem Kunden bzw. Konsumenten der beauftragten Funktionalität gegenüber; das ist nur die Klasse Aggregate{} mit der Methode Sum(). Aber es wäre immer noch eine Schnittstelle gegenüber Aggregate{} im Inneren des beide umfassenden Modules, z.B. der Bibliothek reporting.dll.

Ausgehend von der Überprüfung der Korrektheit von einzelnen Funktionen trägt die Regressionsfreiheit also dazu bei, Module höherer Ebenen herauszukristallisieren: zunächst Klassen, dann Bibliotheken.

Zusammenfassung

Die Römer haben gesagt „Pacta sunt servanda“ – Verträge sind einzuhalten. Das gilt auch für Software. Wo Verträge mittels öffentlicher Module formuliert werden, müssen sie eingehalten werden. Ob das geschieht, überprüfen automatisierte Tests. Und das nicht nur einmal, wenn Veränderungen erstmalig umgesetzt und ausgeliefert werden, sondern immerdar.

Konsequentes Testen aller Verträge sorgt für (weitgehende) Regressionsfreiheit. Wo Verträge ohne Tests sind, sollten die daher nachgerüstet werden. Wo Verträge neu ausgehandelt werden, sollten sie testgetrieben (test-first) implementiert werden.

Wer diese Regel versteht, kommt um einen diesbezüglichen Review jedoch nicht herum. Review muss weiterhin sein, auch wenn alle Entwickler besten Willens sind, so bei der Umsetzung von Anforderungen vorzugehen. In den Wirren des Tagesgeschäftes kommt selbst der beste Vorsatz einfach zu schnell mal unter die Räder. Außerdem wird bei der Codierung, wenn das Verhalten im Vordergrund steht, nicht jede Chance für einen Kontrakt, d.h. für Entkopplung erkannt. Im Review ist der Modus jedoch grundsätzlich anders, so dass solche Versäumnisse ausgebügelt werden können.

Maturität + Regressionsfreiheit: Werden beide Aspekte systematisch im Review betrachtet, kann die Korrektheit nicht anders, als zu steigen. Die Zahl der Nachbesserungen sinkt. Die Kapazität für Neuerungen wächst. Von der Zufriedenheit beim Kunden ganz zu schweigen.

PS: Der Review bleibt natürlich eine Überprüfung. Wenn Tests erst geschrieben werden, weil der Review ihr Fehlen entdeckt hat, wenn Modularität erst nach dem Review in den Code hineinrefaktorisiert wird, dann läuft die Softwareentwicklung immer noch falsch.

Der Review soll eigentlich nur feststellen, dass während der Codierung alle Tests schon test-first realisiert wurden:

  • Alle Akzeptanztests implementiert (Fundamenttests)? Check!
  • Alle öffentlichen Funktionen unter Test? Check!
  • Wurden aus Gerüsttests weitere sinnvolle Module abgeleitet und unter Test gehalten (Pfeilertests)? Check!
  • Verbliebene Gerüsttests gelöscht? Check!

Eigentlich sollte der Review in dieser Weise durch den Quellcode fließen. In der Realität wird er jedoch Lücken finden. Das ist normal, das ist ok – solange es im Rahmen bleibt. Wird der überschritten und der Review artet aus in eine Nachbesserungsorgie, dann sind vorgelagert Maßnahmen zu ergreifen. Dann muss die Herstellung der gewünschten Eigenschaften geübt werden, um im Tagesgeschäft mühelos von jedem Entwickler durchgeführt werden zu können.

Zukunftsfähigkeit I – Korrektheit/Maturität

Unsauberer Code erfüllt Anforderungen. Er stellt gewünschtes Verhalten her. Das ist für den Kunden von höchster Wichtigkeit und vergleichsweise leicht zu überprüfen.

Clean Code erfüllt dieselben Anforderungen – und noch mehr. Clean Code steht dirty code in Sachen Verhalten in nichts nach. Darüber hinaus jedoch erfüllt er auch noch die Anforderung Zukunftsfähigkeit. Die ist dem Kunden (und auch der Softwareentwicklung) allerdings oft nicht bewusst. Außerdem ist sie nicht so leicht zu überprüfen wie das Verhalten.

Verhalten ist eine Laufzeitanforderung. Zukunftsfähigkeit hingegen ist eine Lebenszeitanforderung. Es geht um mehr als das Hier und Heute; es geht um Morgen und Übermorgen.

Clean Code Development bedeutet daher, Software so zu entwickeln, dass sie nicht nur aktuelle Verhaltensanforderungen erfüllt, sondern auch derzeit noch unbekannte in der Zukunft erfüllen können wird.

Dafür muss Software zwei Eigenschaften jenseits von Funktionalität und Effizienz haben und behalten:

  • Korrektheit und
  • Wandelbarkeit

Korrektheit scheint auf der Hand zu liegen. Ist sie nicht sogar eine Verhaltensanforderung? Grundsätzlich ist das richtig, doch die Erfahrung zeigt, dass fehlende Korrektheit sich nicht unbedingt im Moment der Abnahme von Software durch den Kunden zeigt. Inkorrekte Software geht allzu oft in Produktion; Fehler zeigen sich dann in einem Moment, wenn es gar nicht gut passt, weder für den Kunden/Anwender, noch für die Softwareentwicklung. Mangelhafte Korrektheit hat mithin Auswirkung auf die Zukunft.

Wandelbarkeit hingegen liegt nicht auf der Hand. Ist Software ihrer immateriellen Natur nach nicht ohnehin wandelbar? Oder ergibt sich Wandelbarkeit nicht mehr oder weniger, wenn man recht fleißig objektorientiert arbeitet? Kunden, Management und selbst Entwickler stellen diese Fragen. Wie die Praxis früher oder später jedoch zeigt, ist die Antwort darauf ein klares Nein. Nein, Wandelbarkeit, also Offenheit für Veränderungen, ergibt sich nicht von allein, auch nicht durch gut gemeinte Objektorientierung. Wandelbarkeit muss vielmehr ganz bewusst und systematisch hergestellt werden. Solange das nicht geschieht, verdunkelt jede Änderung am Code die Zukunft einer Software etwas mehr. Der Aufwand, Veränderungen vorzunehmen, seien das Bug Fixes oder Erweiterungen, steigt dann unverhältnismäßig.

Maturität als Aspekt der Korrektheit

Korrektheit mag als Anforderung auf der Hand liegen und jeder gewissenhafte Entwickler wird sich auch um sie bemühen. Trotzdem ist es so eine Sache mit ihr. Backlogs voll mit gemeldeten Fehlern sind keine Seltenheit, Teams, die sich auf Wochen ausschließlich mit Bug Fixes beschäftigen könnten, finden sich überall.

Korrektheit sollte daher nicht als selbstverständlicher Aspekt der Umsetzung von Verhaltensanforderungen erachtet werden. Ihre Herstellung erfordert mehr Aufmerksamkeit. Deshalb schlagen wir sie der Zukunftsfähigkeit zu. Ihre Überprüfung ist Sache des Reviews; die Architektenrolle will Korrektheit haben und zieht an ihr.

Dabei beginnt alles mit der Maturität. Die Frage lautet: Ist die Software schon korrekt?

Bedingt durch die Komplexität von Software kann diese Frage allerdings weder in einem Review des Codes, noch bei der Abnahme durch den Kunden abschließend beantwortet werden. Deshalb muss die Frage umformuliert werden: Wurde alles getan, um die Korrektheit für eine Auslieferung sicherzustellen?

Bugs lassen sich nicht komplett vermeiden. Doch es kann zumindest ein enges Netz geknüpft werden, um möglichst viele vor Release zu fangen. Wurde das getan?

Jeder Bug, der nicht gefangen wird und zum Kunden gelangt, wird früher oder später als Beschwerde zurückkommen. Die stellt dann eine Unterbrechung dar und wird den Fluss der Entwicklung von Neuerungen stören. Außerdem reduziert der spätere Bug Fix die Kapazität für Neuerungen. Bug Fixing ist Plaque im Entwicklungsprozess. Bugs führen zu Verschwendung. Geld wird verbrannt in Nachbesserungen. Das reduziert die Zukunftsfähigkeit, die umso größer ist, je mehr Neuerungen umgesetzt werden. Dafür wird Software ja gekauft. Dafür bekommen Softwareentwickler ihr Geld: dass sie Neuerungen einbauen. Der Fluss deren korrekter Herstellung ist zu maximieren.

„Die Zukunft beginnt jetzt!“ ist also der Gedanke hinter der Überprüfung der Maturität.

Was sind die Kriterien dafür, dass alles getan wurde, um die Korrektheit für eine Auslieferung sicherzustellen?

Es reicht nicht, wenn in der Reviewrunde gefragt wird, „Habt ihr denn auch alle eure Veränderungen am Code getestet?“ Auch unter der Annahme, dass alle Entwickler gutwillig und bemüht um Qualität sind, wäre eine allseits positive Antwort wenig aussagekräftig. Die Meinung darüber, was ausreichende Tests sind, gehen einfach weit auseinander und verändern sich auch noch unter Druck. Mündliche Zusicherungen sind schlicht nicht nachvollziehbar.

Deshalb gilt es als erstes zu überprüfen, ob für jede Anforderung die zugehörigen Akzeptanztests codiert wurden und automatisiert ausführbar sind und keinen Fehler melden.

Dass es überhaupt Akzeptanztests gibt, ist natürlich Ergebnis einer systematischen Analyse der Anforderungen vor ihrer Umsetzung. Die Umsetzung beginnt nicht, bevor sich nicht Kunde bzw. Stellvertreter und Entwickler auf klare Beispiele korrekten Softwareverhaltens geeinigt haben, die codiert als Tests ausdrücken, dass die wünschten Neuerungen umgesetzt wurden.

Akzeptanztests überprüfen Funktionalität und Effizienz an der Oberfläche einer Software. Sie setzen knapp unter dem UI an, um leicht automatisierbar zu sein. Zur Not „reizen“ sie die Software aber auch durch das UI.

Akzeptanztests sind auf einem Niveau, das der Kunde nachvollziehen kann. Sie stellen für ihn „Relevanzeinheiten“ dar. Auf ihrer Ebene kann der Kunde Feedback geben. Ob durch das Verhalten allerdings auch schon Wert für eine Anwendung in der Praxis entsteht, sei dahingestellt. Feedbackinkremente sind durchaus kleiner als Wertinkremente. Ihr Wert liegt nicht im Auge des Anwenders, sonder im Auge desjenigen, der sich für kontinuierlichen Fortschritt interessiert.

Akzeptanztest sind codiertes Verständnis. Ohne Akzeptanztests ist nur schwer nachvollziehbar, was überhaupt umgesetzt werden soll. Eine auf den Review folgende Abnahme bleibt ohne automatisierte Akzeptanztests schwach und lückenhaft.

Akzeptanztests sind jedoch nur die erste Bastion gegen ungenügende Maturität. Es braucht weitere Maßnahmen, die belegen, dass alles getan wurde, um die Korrektheit für eine Auslieferung sicherzustellen.

Als zweites überprüft der Review daher, ob jede Methode, die Logik enthält und nicht durch eine mechanische Refaktorisierung entstanden ist, mindestens durch einen aussagekräftigen Test überprüft wird.

Das sind Gerüsttests, die das entstehende Softwaregebäude während des Aufbaus umschließen. Mit „jede Methode“ ist daher tatsächlich jede gemeint, die (nicht triviale) Logik enthält, auch und insbesondere solche, die (eigentlich) privat sind. Denn da, wo Logik (Teil-)Verhalten herstellt, besteht Bug-Risiko.

Akzeptanztests leisten ihren Beitrag, um Bugs in allen Methoden aufzuspüren, doch am Ende können in ihnen nicht genügend (Sonder-)Fälle codiert sein, um in alle Winkel der Logik zu leuchten. Es braucht weitere Tests für die vielfältigen Details der ganzen Logik, die hinter dem gewünschten Verhalten einer Neuerung steht.

Der Review überprüft, ob Akzeptanztests und Gerüsttests vorhanden sind. Hat das Softwaregebäude ein Fundament an Tests und wurde es in einem schützenden und stützenden Rahmen hochgezogen?

Doch wie entstehen all diese Tests? Damit der Review sie vorfindet, ist zu empfehlen, sie vor dem Produktionscode zu schreiben. Ein test-first-Vorgehen ist die beste Versicherung dafür, im Review nicht kalt erwischt zu werden. Denn jeder Tests, der im Review fehlt, erfordert eine Nachbesserung, die wertvolle Zeit raubt für Neuerungen und eine Abnahme verzögert.

Sobald jedoch alle Akzeptanztests vorhanden sind und die Gerüsttests nachweisen, dass auch im Detail sorgfältig gearbeitet wurde, kann die Maturität bescheinigt werden. Die Software ist, was diesen Aspekt der Korrektheit angeht, aus Sicht der Softwareentwicklung schon bereit für ein Release.

Die Gerüsttests können anschließend abgebaut oder deutlicher: gelöscht werden. Sie dienten nur dem Aufbau korrekten Codes. Da der nun als korrekt eingestuft wurde und von allein nicht mehr kaputt geht, sind die Gerüsttests überflüssig. Alle privaten Methoden, die zum Zwecke des Tests eine größere Sichtbarkeit hatten, werden nun wirklich auf privat gesetzt. Nur so werden die Grenzen zwischen Modulen deutlich gezogen.

Würden alle Gerüsttests stehenbleiben, wäre der Code bald in einem engen Korsett von Tests eingeschnürt, das weitere Veränderungen erschwert. Gerüsttests sind Whitebox-Tests. Sie testen Details, die eigentlich nicht sichtbar sein sollen. Sie laufen dem Grundprinzip der Kapselung/losen Kopplung entgegen.

Also werden Gerüsttests am Ende eines Reviews gelöscht. Im Repository sind sie ja aber noch vorhanden. Wer also später einmal darauf zurückgreifen will… der kann sie dort suchen.

Das mag sich für Sie rigoros anhören, doch Sie werden es als Erleichterung empfinden, nicht alle Tests behalten zu müssen, wie es sonst in der Literatur empfohlen wird.

Und es ist auch nicht so, dass alle Tests, die nicht Akzeptanztests sind, Gerüsttests darstellen. Sie sind allerdings nicht Thema der Überprüfung der Maturität.

Beispiel

Zumindest mit einem kleinen Beispiel soll die Facette Maturität konkretisiert werden. Nehmen wir an, Sie sollen eine Funktion umsetzen, die ganze Zahlen in einer Textdatei addiert.

Analyseergebnis

Der Dateiaufbau ist simpel: in jeder Zeile steht eine Zahl.

Als Akzeptanztest wünscht sich der Kunde diesen Dateiinhalt für numbers.txt:

in Verbindung mit dieser Summe: 356.

Außerdem war nach den „Verhandlungen“ mit dem Kunden klar, wie die „Oberfläche“ der Software aussehen soll: eine Funktion mit der Signatur int Sum(string filename) in einer Klasse Aggregator.

Review

Der Review schaut zuerst auf den Akzeptanztest. Ist der vorhanden? Ja, das ist er:

Die referenzierte Datei hat den vereinbarten Inhalt.

Der Test ist auch erfolgreich. Ihm steht eine soweit fehlerfreie Umsetzung gegenüber:

(Übersehen Sie für den Moment, dass es mit C# auch viel einfacher gegangen wäre. Um für das simple Szenario etwas Code-Fleisch auf die Knochen zu bekommen, ist der Code umständlicher als nötig.)

Als nächstes fragt der Review nach den Gerüsttests.

Gibt es weitere Funktionen über den vereinbarten Einstiegspunkt Sum() hinaus? Das ist der Fall. Und diese Funktionen sind sogar nicht durch Refaktorisierungen entstanden, sondern schon während des Entwurfs als Aspekte (eigenständige Verantwortlichkeiten) erkannt worden.

Sind diese Funktionen mit Tests versehen? Ja, das ist der Fall.

Auch diese Tests sind alle grün.

Für das Laden der zu verarbeitenden Zahlen gibt es sogar zwei Tests. Der Entwickler hat sich nicht darauf verlassen, dass die Dateien wohlgeformt sind. Er hat Load() robuster gemacht, als der Kunde es sich zumindest mit dem Akzeptanztest gewünscht hat.

Ist das ein Widerspruch zum YAGNI-Prinzip? Das kommt darauf an, ob die Fälle „Datei enthält Leerzeilen“ und „Zahlen sind von Whitespace umschlossen“ mit dem Kunden diskutiert wurden.

War das nicht der Fall, hat der Entwickler zwar qualitätsbewusst gedacht – doch potenziell vorzeitig und unnötig optimiert. Das hat Zeit gekostet und somit Verschwendung erzeugt.

Hat es jedoch eine Absprache mit dem Kunden gegeben, dann liegt keine Verschwendung vor. Allerdings ist zu fragen, warum diese Aspekte nicht Eingang in Akzeptanztests gefunden haben. Auch die Sonderfälle hätten mit nur einem Akzeptanztest abgedeckt werden können, indem numbers.txt etwas „vielfältiger“ gestaltet worden wäre.

Wie nun mit diesen Testfällen verfahren? Es wird entschieden, den Akzeptanztest zu erweitern. numbers.txt bekommt folgenden Inhalt (Punkte stehen für Whitespace der einen oder anderen Art):

Der Review ist erfolgreich. Die Software ist korrekt, sie hat die gewünschte Maturität.

Damit haben die Gerüsttests ihre Schuldigkeit getan. Sie können gelöscht werden und die Detailfunktionen verschwinden aus der Öffentlichkeit. Ihre Sichtbarkeit wird auf private gesetzt.

Der resultierende Code im Überblick:

Zusammenfassung

Review ist „die erste Entwicklerpflicht“. Ohne Review besteht kein Verlass, dass Code nicht nur Verhaltensanforderungen erfüllt, sondern auch zukunftsfähig ist.

Während des Reviews durch die Architektenrolle ist zuerst Augenmerk auf die Korrektheit zu legen. Ist die Software schon korrekt? Hat sie die erforderliche Maturität zur Auslieferung?

Nachvollziehbar ist die Maturität nur anhand von codierten Tests. Das sind einerseits die Akzeptanztests, die ausdrücklich mit dem Kunden vereinbart sind. Sie setzen an der Oberfläche der Software an und sind tendenziell grob. Ihre Aufgabe ist es, Grundvertrauen herzustellen und die Umsetzung für den Kunden fühlbar zu machen.

Darüber hinaus braucht es aber weitere Tests für Detailaspekte der Logik. Die sind zumindest nötig während der Umsetzung, um dem Entwickler schrittweise Sicherheit zu geben, korrekt zu arbeiten. Sie ergänzen u.U. auch Akzeptanztests zumindest temporär. Denn am Ende des Reviews werden solche Gerüsttests auf eigentlich privaten Funktionen gelöscht. So stehen sie zukünftigen Refaktorisierungen, die unter der Oberfläche stattfinden, nicht im Wege.

Zukunftsfähigkeit ergibt sich nicht nebenbei. Sie will systematisch geplant, umgesetzt und überprüft werden. Der Ort für die Überprüfung ist der Review. Ihn regelmäßig und umfassend durchzuführen, ist die erste Maßnahme auf dem Weg zu Clean Code.

Die Anforderungen auf die Füße stellen

Die üblicherweise ausgesprochenen Anforderungen des Kunden an eine Software sind:

  1. Funktionalität, z.B. die Software rechnet
  2. Effizienz, z.B. die Software rechnet schnell

Und zwar in der Reihenfolge, d.h. zuerst muss die Software grundsätzlich die gewünschte Funktionalität haben, dann kann ggf. an ihrer Effizienz geschraubt werden.

Schon diese beiden Anforderungen jedoch können nicht immer vollständig erfüllt werden. Es gibt Situationen, in denen muss eine Balance her. Da kann entweder nicht die volle Funktionalität geliefert werden, wenn die Effizienz für den Rest hoch genug sein soll. Oder es kann nicht die gewünschte Effizienz hergestellt werden, wenn auch die komplette Funktionalität vorhanden sein soll. Noch deutlicher wird das, wenn Effizienz aufgefächert wird in einzelne Aspekte wie Performance, Skalierbarkeit, Sicherheit, Portabilität, Robustheit, Benutzerfreundlichkeit usw.

Der trade-off lauert überall. Da ist dann die Architekturrolle gefragt, wenn z.B. zwischen Performance und Sicherheit abgewogen werden muss, oder es ist ein Gespräch angezeigt zwischen Architekt und Kunde, wenn Portabilität und Funktionalität ausbalanciert werden müssen.

Wait, there is more

Als wäre die Situation nicht schon kompliziert genug, sind das jedoch nicht alle Anforderungen. Es sind, wie gesagt, nur die irgendwie ausgesprochenen. Zu ihnen finden sich in einem Pflichtenheft oder Konzept mehr oder weniger klare Wünsche des Kunden. Dazu hat er eine Meinung und kann auch prüfen, ob eine Softwareversion ihm taugt.

Funktionalität und Effizienz bilden zusammen die Laufzeit- oder Verhaltensanforderungen.

Ohne, dass es dem Kunden (oder oft auch Management, gar Entwicklern) bewusst wäre, gibt es darüber hinaus allerdings weitere Anforderungen. Die beziehen sich auf die Lebenszeit der Software. Der Kunde kann nicht durch „Herumspielen“ mit einer Softwareversion feststellen ob und inwiefern sie erfüllt sind:

  • Wandelbarkeit: Kann die Software leicht an neue Anforderungen angepasst werden?
  • Regressionsfreiheit: Sind vor einer Veränderung existierende Funktionalität und Effizienz nach der Veränderung immer noch vorhanden? (Regressionsfreiheit ist Teil der größeren Anforderungskategorie Korrektheit.)

Die Anforderungen stehen auf dem Kopf

Die Liste der Anforderungen, die während der Softwareentwicklung immer wieder ausbalanciert werden müssen, ist also länger als gemeinhin angenommen:

  1. Funktionalität
  2. Effizienz
  3. Regressionsfreiheit
  4. Wandelbarkeit

Eigentlich sind alle diese Anforderungen wichtig. Der Kunde ist an der Erfüllung aller interessiert. Dennoch gibt es eine de facto Priorität. Wenn im Zweifel, dann wird eher eine höher liegende Anforderung erfüllt.

In der Praxis bedeutet das, dass Maßnahmen, die der Erfüllung einer weiter unten liegende Anforderung gelten, gestrichen werden, wenn eine darüber liegende Anforderung noch nicht ausreichend erfüllt zu sein scheint.

Die Entstehung von legacy code, die Anhäufung eines big ball of mud ist daher kein Wunder, oder? Denn die Wandelbarkeit steht ganz am Ende der Liste und gehört zu den Anforderungen, deren Erfüllung der Kunde nicht einfach überprüfen kann. So viele Anforderungen können bedürftig sein, da bleiben kaum Ressourcen, um auch noch der Wandelbarkeit zu dienen.

Solange eine Software überschaubar und keine lange Lebensdauer zu erwarten ist, mag das nicht weiter tragisch sein. Aber wer weiß denn wirklich, ob die klein geplante Codebasis wirklich klein bleibt? Ist eine Software erstmal erfolgreich, wollen Kunden mehr. Dann ist die Lebenszeit nicht absehbar. Das bedeutet, die Software muss regressionsfrei sein und bleiben und auch wandelbar sein und bleiben. Sonst besteht schlicht keine Zukunftsfähigkeit.

Schluss mit dem Kopfstand!

Zukunftsfähige Softwareentwicklung muss die Priorität der Anforderungen umkehren. Die Anforderungen müssen auf die Füße gestellt werden. Welche Funktionalität und Effizienz morgen oder übermorgen von einer Software gewünscht werden, ist unsicher. Dass jedoch anderes und mehr gewünscht wird, ist sicher.

Die erste zu erfüllende Anforderung ist mithin die Wandelbarkeit, nicht die Funktionalität!

  1. Wandelbarkeit
  2. Regressionsfreiheit
  3. Funktionalität
  4. Effizienz

Auch hier gilt wieder: Wenn im Zweifel, dann lieber eine höher liegende Anforderung erfüllen.

Oder: Wenn auf einer unteren Ebene zwei Optionen keinen Unterschied machen, dann die wählen, die auf höherer Ebene mehr Qualität bietet.

Aber wie kann Wandelbarkeit über Funktionalität stehen? Ohne Funktionalität gibt es keinen Code, der wandelbar sein könnte.

Es geht bei der Positionierung in der Reihenfolge nicht sofort und immer um Code, sondern zunächst um eine Grundhaltung. So wie bei der Fliegerei safety first gilt, sollte bei der Softwareentwicklung evolvability first gelten.

Lesen Sie die Liste gern auch im Stile des agilen Manifests: Wandelbarkeit vor Regressionsfreiheit usw.

Wandelbarkeit an erster Stelle bedeutet, nach Prinzipien zu codieren, die es erleichtern, in der Zukunft Veränderungen an Funktionalität und Effizienz vorzunehmen, sei das für Erweiterungen oder Bug Fixes.

Regressionsfreiheit an zweiter Stelle bedeutet, so zu codieren, dass automatisierte Tests erstens leicht möglich sind und zweitens auch mit einer guten Abdeckung existieren, um Regressionen zügig und mit hoher Wahrscheinlichkeit (und vor Auslieferung) festzustellen.

Und schließlich bedeuten Funktionalität an dritter und Effizienz an vierter Stelle, dass deren Realisierung nur in dem Rahmen stattfinden soll, der von Wandelbarkeit und Regressionsfreiheit aufgespannt wird. Es besteht quasi eine freiwillige Selbstbeschränkung: Funktionalität und Effizienz werden nicht mehr „irgendwie“ oder „auf Teufel komm’ raus“ hergestellt, sondern stets mit Rücksicht auf Wandelbarkeit und Regressionsfreiheit.

Wer an Funktionalität und Effizienz arbeitet, muss sich die Frage gefallen lassen, ob er die darüber liegenden Anforderungen im Blick hat.

Das bedeutet nicht, Wandelbarkeit oder Regressionsfreiheit über alles andere zu stellen. Sie sind nicht wichtiger als Funktionalität oder Effizienz. Aber sie sind eben auch nicht unwichtiger. Damit diese gleiche Gewichtung jedoch ihren Niederschlag im Code findet, ist eine neue Priorisierung nötig.

Clean Code Development wie wir es unterrichten, versteht sich mithin als Anwalt der oft unsichtbaren oder impliziten Anforderungen Wandelbarkeit und Regressionsfreiheit. Wir wollen ihnen zu ihrem gebührenden Gewicht verhelfen. Wir wollen die Anforderungsliste vom Kopf auf die Füße stellen.

Unser Ziel ist es, Softwareentwicklern und Managern (und gern auch Kunden) das Bewusstsein zu vermitteln, dass sich Zukunftsfähigkeit nicht von allein ergibt, sondern ausdrücklicher und systematischer Anstrengung bedarf.

Auf die Füße gestellte Anforderungsprioritäten sind dafür ein Bild. Daran kann sich die Diskussion entzünden. Das kann mit der Realität in einem Projekt verglichen werden. Inwieweit Softwareentwicklung sich um Clean Code bemüht, kann abgelesen werden an der Position von Wandelbarkeit und Regressionssicherheit in der Liste der Anforderungen.

Auf welchem Platz stehen diese Anforderungen denn bei Ihnen? Woran machen Sie das fest?

Grade der Testbarkeit

Testbarkeit ist für mich ein zentrales Kennzeichen für Wandelbarkeit. Wenn Code leicht zu testen ist, dann ist er lose gekoppelt an anderen Code. Die Struktur zu verändern, in der er eingebettet ist, fällt dann leicht.

Was getestet werden soll

Testbarkeit (durch automatisierte Tests) bezieht sich auf Verhalten. Verhalten ist entweder Funktionalität (z.B. die Software rechnet) oder Effizienz (z.B. die Software rechnet schnell oder sie ist trotz Berechnung responsive). Verhalten ist, wie sich die Software zur Laufzeit „darstellt“, wie sie auf Reize reagiert.

Hergestellt wird Verhalten durch Logik und ihre Verteilung auf Container, die wir bei der CCD School „Hosts“ nennen. Hosts sind z.B. Threads (oder Actors), Prozess oder Maschinen (auch virtuelle).

Die Testbarkeit ist also hoch, wenn man einen Verhaltensaspekt, z.B. eine (Teil-)Funktionalität oder eine Effizienz leicht überprüfen kann. Dazu muss die zugehörige Logik in den relevanten Hosts möglichst gezielt angesprochen werden.

Das Gegenteil von hoher Testbarkeit ist, wenn Sie eine Anwendung von Hand aufrufen müssen, sich anmelden müssen, sich durch mehrere Dialoge klicken müssen, einige Eingaben machen müssen und dann auf einen Button klicken müssen, nur um zu sehen, ob die Reaktion korrekt im Sinne des in Frage stehenden Anforderungsaspektes ist.

Leider sehe ich immer wieder und immer noch Anwendungen, bei denen genau das nötig ist. Mit solchen aufwändigen Tests vertun Entwickler ihre kostbare Zeit und/oder es werden spezielle Tester eingesetzt („QA Mitarbeiter“), die gewissenhaft Testprotokolle immer wieder durchlaufen.

Das kann man natürlich so machen – nur ist das teuer und kostet viel Zeit und ist fehlerträchtig. Die Reaktionszeit solcher Softwareentwicklung misst sich für die Umsetzung der meisten Anforderungen in Wochen und Monaten. Das klingt nicht reaktionsschnell in Bezug auf den Markt, oder?

Hier ein Beispiel für Verhalten herstellende Logik. Es ist die Implementation einer Lösung für das erste Inkrement der Kata „Word Count“:

Das ist die manifestierte Lösung für den bescheidener Wunsch eines Kunden. Aber diese Logik ist funktional und ausreichend effizient.

Genau um solche Logik geht es.

Ist das testbar?

Ohne weiteres Zutun steckt die Beispiellogik in einer Main()-Methode, die als Entry Point für die Anwendung dient. Darüber kann die Logik durch Programmstart angestoßen werden:

Wie steht es mit der Testbarkeit dieser Logik? Schlecht. Als Ganzes kann sie nur getestet werden, indem man das Programm aufruft und von Hand bedient.

Ok, wenn man es genau nimmt, dann könnten die Standard Input/Output Streams umgebogen werden und ein Test wäre automatisiert möglich; der könnte die statische Methode Main() aus einem NUnit-Test aufrufen.

Doch spätestens mit einem GUI fiele auch diese Möglichkeit weg. Und in jedem Fall ist es umständlich. Das macht keinen Spaß.



Unabhängig vom UI-API

Es leitet sich ein erster Grad für die Testbarkeit ab: Automatisiert testbar ist, was keine Abhängigkeiten zum UI hat.

In diesem Fall besteht die Abhängigkeit zum UI-API in den ersten beiden Zeilen und in der letzten. Um zumindest die leichter zu testenden Logik-Teile von den schwerer zu testenden zu trennen, ist es angezeigt, sie in separate Funktionen zu verpacken:

Jetzt könne ich das UI allein testen. Von Hand (oder automatisiert). Oder ich lasse es sein, weil das so trivial ist. Ob es korrekt läuft, sehe ich bei der Programmausführung.

Die Testbarkeit des Gesamtverhaltens ist aber noch nicht besser geworden. Ebenfalls kann ich noch nicht die Domänenlogik gezielt testen. Sie steckt in Main() zwischen den UI-Methodenaufrufen. Das ist ein Widerspruch zum Single Level of Abstraction (SLA) Prinzip – und ist schlecht testbar.

Freistehende Aspekte

Es leitet sich ein nächster Grad der Testbarkeit ab: Logik ist überhaupt nur für sich testbar, wenn sie freigestellt ist in einer eigenen Funktion.

Zum Glück lässt sich die Domäne ebenfalls einfach herausziehen:

Das Wichtigste an der Anwendung ist jetzt sehr gut testbar:

Die Domäne ist sicherlich ein ganz eigener Aspekt. Ihre Aufgabe bei der Herstellung des Gesamtverhaltens ist etwas anderes als der der UI-Methoden.

Beide UI-Methoden gehören zum selben Aspekt „Benutzerschnittstelle“. Der ist gekennzeichnet durch die Nutzung eines API und lässt sich daher recht leicht abgrenzen. Überall, wo Methoden desselben API zum Einsatz kommen, geht es um denselben Aspekt. Kennzeichnend für das UI ist in diesem Beispiel der API System.Console.

Die Wortzählung hat mit dem API nichts zu tun. Also gehört sie zu einem anderen Aspekt. Ich nenne ihn mal „Domäne“. Mit der Funktion Count_words() ist der nun sauber getrennt vom UI-Aspekt.

Aber ist die Domäne jetzt schon gut testbar? Insgesamt ja – doch die Domäne besteht aus Sub-Aspekten. Die sind weniger gut zu erkennen, aber sie sind da. Das wird klar, wenn ich diesen Test laufen lasse, in dem die Worte durch mehrere Leerzeichen getrennt sind:

Der schlägt fehl. Es werden 11 Worte gezählt, wo ich nur 3 sehe. Wie kommt das? Wo liegt der Fehler?

Jetzt stellt sich die Frage: Werden die Worte falsch gebildet und richtig gezählt oder werden richtig gebildete Worte falsch gezählt?

Kann ich gezielt nur die Wortbildung bzw. die Zählung testen? Nein. Sie stellen sich mir als getrennte Aspekte innerhalb der Domäne dar; deshalb konnte ich die Frage oben so differenzierend formulieren. Doch die verschiedenen Aspekte sind nicht in eigenen Modulen beheimatet, die unabhängig getestet werden könnten.

Also trenne ich die bisher monolithische Logik der Domäne auf:

Nach dieser Refaktorisierung kann ich die „Testsonde“ gezielt bei den Sub-Aspekten anlegen:

Nun stellt sich heraus, dass die Wortbildung nicht so funktioniert, wie sie sollte.

Mit einem gezielten Eingriff, quasi minimalinvasiv, ist das Problem zum Glück zu beheben. Die Funktion Split_into_words() dient als Schlüsselloch zur kleinstmöglichen Menge Logik, die relevant ist.

Jetzt ist wieder alles gut. Geholfen hat, dass die Aspekte in eigenen Funktionen freigestellt waren.

Unabhängig von Ressourcen

Jetzt weiter zum nächsten Inkrement bei „Word Count“. Es werden nicht mehr alle Worte gezählt, sondern nur noch manche. Die Zählung wird also verändert.

Ich weiß genau, wo die Zählung stattfindet: in Count_words(string[]). Also kommt die neue Logik dort hinein. Zuerst jedoch ein fehlschlagender Test:

Mit ein bisschen Linq-Power ist die Berücksichtigung der Stopwords schnell gemacht:

Der neue Test wird damit grün. Yey! :-)

Aber leider fliegen mir nun andere Tests um die Ohren. Mist! :-(

Ich habe der Einfachheit halber eine stopwords.txt-Datei wie in den Anforderungen gelistet im wordcount-Projekt angelegt, die nicht nur in das Output-Verzeichnis der Anwendung kopiert wird, sondern auch bei den Tests aufschlägt. Der neue Test nutzt sie und wird deshalb grün – aber alle anderen Tests nutzen sie und die erweiterte Wortzählungsfunktionalität auch. Deren Testfälle sind aber nicht darauf ausgelegt, Stopwords zu berücksichtigen.

Das Fehlschlagen der anderen Tests ist ein Zeichen für eine Abhängigkeit. Diese Abhängigkeit ist derzeit verborgen in den Tiefen der Domäne im Aspekt „Wortzählung“. Das macht auf einen Schlag die bisherige schöne Testbarkeit zunichte.

Nicht nur ist also Abhängigkeit von einem UI-API eine Behinderung beim Testen. Jede Abhängigkeit von einer Ressourcen verringert die Testbarkeit. Wo immer irgendein API im Spiel ist – hier: Dateizugriff -, wird das Testen knifflig.

Was tun?

Ich möchte in jedem Test ganz einfach bestimmen können, welche Stopwords zur Anwendung kommen. Dafür muss ich dann zwar auch Tests ändern, aber nicht die bisher erfolgreich getesteten Input-Output-Kombinationen.

Für die gezielte Einstellung der Stopwords ist es nötig, dass die Ressourcenabhängigkeit explizit ist. Stopwords dürfen nicht „einfach so“ nebenbei und im Verborgenen geladen werden.

Das Laden von Stopwords ist ein eigener Aspekt. Das sollte klar sein. Und wer das inhaltlich nicht erkennt, dem sollte das die Abhängigkeit von einem API verraten. Insofern war die obige Implementation natürlich naiv und hat schon dem identifizierten Grad der Testbarkeit „Freistehende Aspekte“ widersprochen. Ich habe nicht gezielt testen können, ob Stopwords überhaupt korrekt geladen werden.

Zuerst stelle ich daher die Stopwords-Beschaffung frei:

Unabhängigkeit von Konstanten

Für den Stopwords-Dateizugriff einen Test zu schreiben, ist nun einfach. Der basiert auch auf der Abhängigkeit von der einen Stopwords-Datei, die automatisch im Testverzeichnis landet:

Einerseits ist einfach. Andererseits: Auch das Laden der Stopwords hat verschiedene Aspekte. Wie kann ich das Verhalten der Funktion testen, wenn die Stopwords-Datei fehlt? Wie kann ich testen, wie die Funktion mit Leerzeilen in der Stopwords-Datei umgeht? Für jeden dieser Tests muss derzeit eine andere Datei mit Namen „stopwords.txt“ angelegt (oder gelöscht) werden.

Diese einzeilige Funktion hat nicht nur eine Abhängigkeit zu einem API (das ist sogar ihr Zweck), sondern auch zu einer Konstanten – dem Dateinamen -, deren Wert ich zumindest für Testzwecke gern austauschen möchte.

Der Grad der Testbarkeit sinkt also nicht nur mit Abhängigkeit von Methoden, sondern auch mit Abhängigkeit von Daten, seien das Konstanten oder auch Variablen.

Um den Dateinamen austauschbar zu machen, muss ich ihn von außen setzen können. Das kann per Methodenparameter geschehen. In diesem Fall entscheide ich mich jedoch für einen Konstruktorparameter, um zu unterstreichen, dass der Dateiname eigentlich fix ist. Aus der bisher statischen Methode wird deshalb eine Instanzmethode:

Im Produktiveinsatz braucht der Konstruktor keinen Parameter; der Dateiname ist weiterhin eine Konstante. Doch im Testfall übergebe ich einfach einen Dateinamen, wenn ich vom Standard abweichen will, z.B.

Der Test schlägt natürlich fehl. Bisher nimmt die Funktion an, die Stopwords-Datei würde existieren. Aber gut, dass ich das so gezielt prüfen kann. Keine andere Logik muss dafür von Hand (oder auch automatisiert) ausgeführt werden.

Die Nachbesserung ist einfach:

Funktionale Unabhängigkeit

Doch wie kommt nun die Wortzählung an die Stopwords? Ich könnte die bisherige direkte Beschaffung in der Domänenlogik ersetzen durch Aufruf der neuen Funktion:

Damit wären aber immer noch die Aspekte vermischt. Die separate Testbarkeit der Wortzählung wäre immer noch nicht hergestellt.

Das Problem wäre auch nur wenig geringer, würde ich die Stopwords-Beschaffung ersetzbar machen durch eine Attrappe:

Wieder bewegt mich die auszutauschende Abhängigkeit dazu, eine bisher statische Funktion zu einer Instanzfunktion zu machen. Aber das ist nicht das Problem. Wo Austauschbarkeit gefragt ist, ist das das Muster.

Nein, problematisch finde ich, dass die Domänenlogik in CountingWords.Count() sich überhaupt über die Beschaffung von Daten, die sie braucht, Gedanken zu machen. Was soll die funktionale Abhängigkeit von einer Methode Load()? Es ist egal, ob die nun direkt ist wie zuerst oder indirekt durch eine Dependency Inversion (DI). Die Wortzählungslogik steht immer noch nicht allein. Durch die Injektion des Providers entsteht eine Last. Ja, Attrappen bauen, ist eine Last. Die wird auch nur marginal geringer mit Mock-Frameworks.

Das fundamentale Problem ist durch DI einfach nicht gelöst. Da mag DI auch noch so geadelt sein durch seinen Platz in den SOLID-Prinzipien. Fundamental problematisch für die Testbarkeit sind nämlich funktionale Abhängigkeiten.

Die Funktion Count() enthält selbst Logik (für die Wortzählung) und ist gleichzeitig noch damit beschäftigt, weitere Logik zu integrieren (Stopwords-Beschaffung). Das ist ein Widerspruch zum Single Responsibility Principle (SRP).

Grundsätzlich gelöst wird das Problem erst durch eine Auflösung der funktionalen Abhängigkeit. Die Wortzählung darf nichts mehr von jeglicher Beschaffung von Stopwords wissen. Ihre einzige und natürlich Abhängigkeit besteht in der von den Stopwords selbst, also von Daten.

Die können statt einer Provider-Attrappe über den Konstruktor der Domänenklasse zur Verfügung gestellt werden:

Diesen Weg wähle ich, weil Stopwords für mich irgendwie orthogonal zur Wortzählung sind. Sie sind statischer als der zu analysierende Text. Der mag wechseln, die Stopwords werden eher dieselben bleiben.

Die Tests für die Domänenlogik fallen nun sehr einfach aus:

Potenzielle Testbarkeit

Jetzt sind die ursprünglich fehlschlagenden Tests, weil ich Stopwords eingeführt habe, wieder grün – bis auf einen. Weitere sind hinzugekommen. An den Testszenarien habe ich nichts geändert, allerdings musste doch Testcode angepasst werden, weil meine Lösung nun eine andere Struktur hat.

Lediglich der Akzeptanztest auf Main() ist noch rot. Für ihn bekomme ich keine Austauschbarkeit hin, weil der Kontrakt vorgegeben und unveränderlich ist: eine statische Methode mit einem Parameter.

Aber das macht nichts. In dem Fall passe ich eben das Testszenario auf die Existenz der Beispiel-Stopwords an. Das finde ich für diesen Akzeptanztest nicht tragisch.

Fertig! Oder?

Ich störe mich noch an einigen Methoden von MainClass. Deren Vorhandensein ist gut, denn so sind Logik-Aspekte testbar. Doch es passt nicht, dass diese Methoden öffentlich auf MainClass sind. Sie gehören nicht zu dem, was von außen sichtbar sein sollte.

Um die Situation zu entspannend, sehe ich mehrere Möglichkeiten:

  1. Ich setze die Methoden auf private und teste sie nicht.
  2. Ich ziehe die Methoden raus in eine andere Klasse, deren API sie dann darstellen und weiterhin getestet werden.
  3. Ich setze die Methoden auf internal und teste sie weiterhin. Das wären Whitebox-Tests.

Möglichkeit 3. schmeckt mir gar nicht. Interna sollen nicht permanent sichtbar sein. Das schafft unnötige Abhängigkeiten.

Möglichkeit 2. scheint mir für die Zerlegung des Textes in Worte angemessen. Das ist ein so eigener Aspekt wie die Stopwords-Beschaffung oder die Wortzählung. Die Methode kann auch statisch bleiben. Hier ist nichts auszutauschen.

Dasselbe gilt für die I/O-Methoden des UI:

Hier besteht zwar auch eine API-Abhängigkeit, die eine Austauschbarkeit für Tests nahelegt und also Instanzmethoden… doch im Test habe ich damit bisher kein Problem, weil ich Standard-Input/Output auf der Ebene darunter ersetze. Also lasse ich die Methoden statisch.

Jetzt ist nur noch Count_words() übrig:

Dafür gibt es sogar einen Test, den ich auf die Stopwords angepasst habe.

Diese Methode enthält keine Logik, sie ist reine Integration. Ihr Zweck ist im Rahmen von MainClass lediglich die Abstraktion der Details, wie nach der Texteingabe die Wortzählung funktioniert.

Aus diesem Grund muss ich sie gar nicht testen. Ich kann sie problemlos auf private setzen. Dadurch fliegen mir natürlich ihre Tests um die Ohren:

Aber das macht nichts. Ich werfe sie einfach weg. Sie testen nichts, was nicht schon durch andere Tests überprüft würde. Die Integration selbst ist trivial und muss nicht getestet werden. Ich kann sie durch Augenscheinnahme überprüfen. Dass ich zwei Methoden falsch in Count_words() „zusammengestöpselt“ habe, ist kaum zu erwarten.

Die Tests, die durch die angemessene Sichtbarkeit fehlschlagen, sind für mich Gerüsttests (scaffolding tests). Sie mögen für eine gewisse Zeit nützlich sein, während ich an der Software arbeite. Doch am Ende baue ich sie ab. Sie verstellen mir den Blick auf das Wesentliche, wenn ich dafür Methoden mit einer unangemessenen Sichtbarkeit versehen muss. Selbst internal wäre für Count_words() nicht passend.

Entscheidend ist, dass Count_words grundsätzlich testbar ist. Ich muss dafür nur die Sichtbarkeit ändern. Das kann ich jederzeit tun, wenn ich meine, auf der Ebene etwas überprüfen zu müssen, d.h. unterhalb von Main(). Ich scheue mich nicht, für die Veränderung von Produktionscode temporär Änderungen vorzunehmen und am Ende wieder zurück zu bauen.

Das ist der finale Code von MainClass:

Aufgeräumt, oder? ;-)



Zusammenfassung

Logik muss korrekt sein. Sie muss das gewünschte Verhalten herstellen. Wenn Sie das vor Auslieferung prüfen wollen, müssen Sie Logik testen. Tests von Hand skalieren nicht. Damit lässt sich keine Regressionssicherheit herstellen. Also braucht es automatisierte Tests. Um automatisierte Tests gezielt auf Logik ansetzen zu können, muss die testbar sein. Testbarkeit existiert in unterschiedlichen Graden:

  • Logik eines Aspektes ist überhaupt nicht für sich testbar, solange sie noch mit Logik anderer Aspekte vermischt ist.
  • Logik eines Aspektes wird überhaupt erst testbar, wenn sie in einer eigenen Methode steht (s.o. Abschnitt „Freistehende Aspekte“).
  • Logik eines Aspektes ist trotz eigener Funktion aber immer noch kaum testbar, solange sie funktional abhängig ist von anderer Logik in weiteren Funktionen, die sie aufruft (s.o. Abschnitt „Funktionale Unabhängigkeit“).
  • Logik ist schwer testbar, wenn sie auf Ressourcen zugreift. Im Beispiel sind das Konsole und Datei. Tests müssen diese Ressourcen in einen definierten Zustand bringen bzw. ihn am Ende überprüfen (s.o. Abschnitte „Unabhängigkeit vom UI-API“ und „Unabhängig von Ressourcen“).
  • Logik ist ebenso schwer testbar, wenn sie abhängig ist von in-memory Daten (Konstanten, Zustand), die im Testfall nicht beeinflusst werden können und ihn deshalb aufwändig oder gar unmöglich machen (s.o. Abschnitt „Unabhängigkeit von Konstanten“).
  • Logik ist nicht mehr ganz so schwer testbar, wenn sie zwar funktional abhängig ist, aber diese Abhängigkeiten über DI indirekt sind und zur Testzeit durch Attrappen ersetzt werden können (s.o. Abschnitt „Funktionale Unabhängigkeit“).
  • Logik ist nicht mehr ganz so schwer testbar, wenn sie zwar abhängig ist von in-memory Daten, die jedoch zur Testzeit gezielt beeinflusst werden können (s.o. Abschnitt „Unabhängigkeit von Konstanten“).
  • Logik ist viel leichter testbar, wenn sie keine funktionalen Abhängigkeiten mehr hat und auch nicht auf Ressourcen zugreift.
  • Logik ist noch leichter testbar, wenn sie keine Abhängigkeit mehr hat von Konstanten und Daten, sondern rein auf ihren Parametern arbeitet.
  • Logik in statischen Methoden ist leichter testbar als Logik in Instanzmethoden (s.o. Abschnitt „Potenzielle Testbarkeit“).
  • Logik in öffentlichen Funktionen ist leichter zu testen als solche in nicht öffentlichen (s.o. Abschnitt „Potenzielle Testbarkeit“).

Ich hoffe, diese Differenzierung hilft Ihnen, von vornherein testbarere Logik zu schreiben – und dadurch gleichzeitig höhere Wandelbarkeit herzustellen.

Der Papertrail eines Trainings

„Wie läuft eigentlich ein Clean Code Development Training ab?“ werden wir immer mal wieder gefragt. Dann versuchen wir zu beschreiben, wie wir mit Übungen und Diskussion und am Flipchart arbeiten.

Clean Code Development braucht hands-on experience, als Trainingsteilnehmer muss man „es“ getan haben. Und zwar nicht zu knapp, in vielen Wiederholungen. Immerhin gilt es, alte Gewohnheiten abzulegen, die fast schon automatisiert in den Fingerspitzen stecken.

Aber auch wenn es um Clean Code geht, dreht sich das Training nicht nur ums Codieren. Damit ließe sich der Code gar nicht genügend sauber gestalten. Wer erst beim Codieren an Clean Code denkt, denkt zu spät daran.

Deshalb müssen wir viel Reden. Clean Code ist das Ergebnis von Diskussion im Team. Auch das will geübt werden. Nein, insbesondere das will geübt werden. Denn „im Reden“ sind Softwareentwickler gewöhnlich nicht so gut. Oder allgemeiner: Im Nachdenken über Lösungen vor dem Codieren sind sie nicht so geübt, vor allem im kollektiven Nachdenken.

Das tut aber Not. Denn wenn am Ende Collective Code Ownership stehen soll, dann reicht es nicht, den Code im Pair Programming zu schreiben. Dazu müssen Reviews kommen (Diskussion!), dazu müssen aber vor allem gemeinsame (!) Analyse der Anforderungen und gemeinsames (!) Nachdenken über den Lösungsansatz kommen (Diskussion!).

Und da solches diskursives Nachdenken nicht so schnell geht und für die meisten Teilnehmer sehr ungewohnt ist, verwenden wir den größten Teil von Clean Code Development Trainings darauf.

Anders als im bisher üblichen Tagesgeschäft der Teilnehmer sind wir allerdings sehr darauf bedacht, dass dieses gemeinsame Nachdenken nicht spurlos bleibt. Dafür hier ein Beispiel aus einem aktuellen Training:

Das ist der Papertrail von zwei Tagen. In denen haben wir uns auf eine Übungsaufgabe konzentriert: eine Anwendung zur Vorhersage von Aufwänden mittels Monte Carlo Simulation.

Was genau all diese Diagramme und Notizen bedeuten, ist hier nicht so wichtig. An dieser Stelle möchte ich Ihnen nur einen visuellen Eindruck davon vermitteln, wie es halt in den Trainings der CCD School zugeht.

Acht Teilnehmer und ich haben im Verlauf von zwei Tagen, also 16 Stunden Training, diese Spur in intensiven Diskussionen produziert – und auch noch implementiert.

Am ersten Tag haben wir ca. 60 Minuten codiert; wir hatten uns nur ein kleines Inkrement zum Einstieg vorgenommen. Am zweiten Tagen waren es 190 Minuten in mehreren Blöcken und sogar arbeitsteilig: ein Teil der Teilnehmer hat sich auf die Simulation konzentriert, ein anderer auf die Vorhersage. Beide Aspekte der Domäne wurden dann als Bibliotheken den anderen zur Integration zur Verfügung gestellt.

So geht es zu in unseren Clean Code Development Trainings. Selbst Männer kommen ins Reden – und fühlen sich gut dabei :-) Und damit nicht nur „daher geredet wird“, zeichnen wir die Gedanken auf. Es ist uns wichtig, dass die Teilnehmer lernen, das, was sie in der Analyse verstehen, anderen visuell vermitteln zu können. Nur so lässt sich ein gemeinsames (!) Verständnis verlässlich erreichen. Dasselbe gilt für ihre Vorstellungen von der Lösung des Problems. Visualisierung, Gedanken auf Papier ausdrücken können, ist das A und O der Softwareentwicklung im Team. Dazu kommen natürlich noch Prinzipien und Konzepte sauberer und agiler Strukturen, doch das Codieren ist am Ende im Grunde der einfachste Teil. Clean Code schreibt sich fast schon von allein ;-)

Wenn Sie jetzt Lust bekommen haben, das auch einmal zu erleben, dann schreiben Sie uns eine Email an info@ccd-school.de und wir schauen, wann wir für Ihr Team das „Trainingslager“ aufschlagen können. Oder Sie machen bei einem Clean Code Retreat mit oder belegen einen Kurs der Clean Code Development Akademie.

Kontrollstrukturen in der Integration

Die beiden wesentlichen Prinzipien für Clean Code Development sind für uns das IOSP und das PoMO.

  • Nach dem Integration Operation Segregation Principle (IOSP) sollen Funktionen (oder allgemeiner: Module) ihre Grundverantwortlichkeit entweder auf die Herstellung von Verhalten durch Logik konzentrieren (Operation) oder selbst keine Logik enthalten, sondern nur andere Module zu einer größeren Einheit integrieren.
  • Das Principle of Mutual Oblivion (PoMO) hingegen sorgt für Entkopplung von Funktionen (oder allgemeiner: Modulen), die zusammen einen Gesamtzweck verfolgen. Sie sollen einander nicht kennen, sondern lediglich über Nachrichten anonym und unidirektional in Verbindung stehen. Die Verschaltung zu einem Netzwerk übernimmt eine integrierende Einheit.

Diese Prinzipien sind klar und einfach und führen zu Code, der einige günstige Eigenschaften besitzt, z.B.:

  • Funktionen haben nur einen geringen Umfang.
  • Funktionen sind quasi selbstverständlich auf eine inhaltliche Aufgabe konzentriert.
  • Funktionen sind nicht funktional von einander abhängig. Das macht sie leicht testbar.
  • Integrationen bieten natürliche Ansatzpunkte für Erweiterungen, ohne bestehende Logik verändern zu müssen.
  • Integrationen geben in leicht lesbarer Form das Was von Code wieder.

Integration ohne Logik

Allerdings stößt die Befolgung dieser Prinzipien immer wieder auf Widerstände, weil es schwierig oder gar unmöglich scheint, Logik komplett aus Integrationen herauszuhalten. Wie soll z.B. dieser Entwurf umgesetzt werden?

Eine Eingabe, die entweder eine römische Zahl oder eine Dezimalzahl ist, soll in die jeweils andere Zahlendarstellung übersetzt werden. Dazu ist doch eine Fallunterscheidung nötig. Die naheliegende Übersetzung wäre:

Dieser Code integriert To_roman() und From_roman(), aber er enthält auch Logik: die if-else-Anweisung und die konkrete Transformation des Input in einen bool-Wert.

Das widerspricht jedoch leider dem IOSP. Nur wie sollte es sonst aussehen?

Um die Integration konsequent frei von Logik zu halten, sollte so verfahren werden:

Jetzt findet nur noch Integration statt. Die komplette Entscheidungslogik ist in Classify() gekapselt. Wie festgestellt wird, ob eine Dezimalzahl oder eine römische anliegt, ist nicht mehr zu sehen.

Das technische Mittel hinter dieser Übersetzung sind Funktionszeiger, die als Continuation eingesetzt werden:

Nicht nur dient diese Funktion der Einhaltung des IOSP, sie zeigt auch PoMO-Konformität: indem die Parameternamen für die Continuations auf die Funktion selbst, also nach innen bezogen sind, wird keine Annahme darüber gemacht, was mit den Daten passiert, die über sie hinausfließen. Die Funktion hat kein Wissen über einen eventuellen Downstream ab ihrer Position in einem Verarbeitungsfluss.

Aber auch wenn auf diese Weise sehr flexibel Teilungen im Datenfluss oder mehrfache oder verzögerte Output-Lieferungen einer Funktionseinheit möglich sind… sie erweist sich stets als gewöhnungsbedürftig.

Gerade dem Clean Code Neuling stellt sich daher die Frage: Ist denn diese Art der Übersetzung wirklich alternativlos, wenn man das IOSP einhalten will? Darf in einer Integration wirklich, wirklich gar keine Logik stehen?


GFU Clean Code Powerdays

Integration mit Kontrollstrukturen

Ja, wir sind der Meinung, dass Integration vollständig ohne Logik gedacht und implementiert werden sollte. Zumindest zunächst. Zur Übung. Um sich dem Imperativen, dem Kontrollflussdenken zu entwöhnen, das der Objektorientierung unterliegt. Denn mit Kontrollflüssen lassen sich keine Lösungen planen. Die Darstellung von Kontrollflüssen „skaliert nicht“. Sie greift auch zu schnell auf globalen Zustand zurück und erzeugt Abhängigkeiten.

Am Ende jedoch liegt es uns fern, ein Dogma in die Welt zu stellen. Alles sollte mit Augenmaß und moderat betrieben werden, auch die Einhaltung von klaren Prinzipien. Solange dem Zweck gedient ist, ist auch Logik in Integrationen erlaubt. Und was ist der Zweck?

  • Leichte Verständlichkeit
  • Leichte Testbarkeit
  • Hohe Wandelbarkeit

Fallunterscheidungen

Hier ein erster Vorschlag zur Güte mit Logik in der Integration zum obigen Beispiel:

Die Integration enthält immer noch if-else als Kontrollstruktur, doch das Wie der Entscheidung ist ausgelagert in die Funktion Is_decimal(). Die ist wieder gut testbar.

Allerdings weicht der Name der entscheidenden Funktion nun vom Entwurf ab. Das ist dem Rückgabewert geschuldet. Da die Wahl auf bool gefallen ist zur Unterscheidung der beiden Zahlenarten, passt Classify() als Funktionsname nicht gut; if (Classify(input)) liest sich nicht flüssig.

Natürlich ist die Verwendung von bool als Rückgabetyp ok, doch dann sollte der Entwurf anders aussehen. Schon dort sollte „Is decimal?“ als Bezeichnung für die Funktionseinheit gewählt werden, die die Zahlenart prüft.

Einerseits. Denn andererseits sollte ein Datenfluss eben Datenfluss sein und nicht Kontrollfluss. Ein if jedoch ist die grundlegende Anweisung in Kontrollflüssen. Es ist direkt vom bedingten Sprung des Maschinencodes abgeleitet.

Die Bezeichnung „Classify“ im obigen Entwurf war also schon mit Bedacht gewählt. Denn darum geht es: eine Klassifikation, aufgrund derer die Verarbeitung unterschiedlich weiter fließt.

Wenn dieser Name beibehalten werden soll, wie könnte dann dafür eine Übersetzung aussehen? Ich schlage vor, eine Aufteilung des Datenflusses allgemein mit einem enum-Datentyp anzuzeigen:

Aus dem if-else wird dann ein switch:

Das sieht ähnlich der Lösung mit den Continuations aus, oder? Es ist sogar ein wenig besser lesbar, da für jeden Ast des Datenflusses klar ist, um welchen es sich handelt.

Ist Logik in der Integration also doch eine gute Sache und erlaubt? Jein. So, wie hier angewandt, ist sie unschädlich, gar hilfreich. Doch unbedacht angewandt, öffnet sie Tür und Tor zu schlechter Lesbarkeit und schlechter Testbarkeit.

So auch hier: Es lässt sich zwar leicht testen, ob Classify() seine Sache korrekt macht. Aber wird das Ergebnis dann auch korrekt weiterverarbeitet? Gibt es einen Fall für jeden Wert des enum? Der C#-Compiler stellt das nicht sicher. Um die Abdeckung zu prüfen, müsste also im Zweifelsfall die switch-Logik in der Integration getestet werden – mit all dem, was daran hängt. Die Testbarkeit des Codes ist damit geringer, als würde das switch (oder ein if) in Classify gekapselt sein.

Schleifen

Wie viel Logik ist denn nun aber erlaubt? Bevor ich die Frage beantworte, lassen Sie uns noch auf die Schleifen als Kontrollstrukturen schauen.

In Datenflüssen werden häufig Mengen von Daten übermittelt. Hier ein Beispiel:

Aus einer Pfadangabe werden viele Dateien, die jede in ein Resultat zu überführen sind. Dass nicht nur ein Datum fließt, sondern eine Collection von Daten, zeigt der * hinter der Datenbezeichnung in der Klammer an.

Wenn die Verarbeitung pro file einfach ist, dann ist Process_files() eine Operation, z.B.

Doch was, wenn die Arbeit auf dem Dateiinhalt umfangreicher ist? Dann wäre es doch angezeigt, die in eine eigene Funktion auszulagern, oder? Das jedoch würde zu einem Aufruf dieser Methode in Process_files() führen, wo schon eine Kontrollstruktur steht. Ein Widerspruch zum IOSP.

Eigentlich. Denn dieser Code ist gut lesbar. Warum sollte er also nicht erlaubt sein? Die Testbarkeit ist nicht geringer geworden. Denn dass ein foreach falsch angewandt wird, ist eher nicht zu erwarten. Also kann sich ein Test auf die Operation konzentrieren, die sich mit einem file befasst: Process_file.

Die Kombination aus „Pluralfunktion“ und „Singularfunktion“ ist typisch für Implementationen von Datenflussentwürfen. Im Entwurf steht nur die Verarbeitung einer Collection, im Code wird das jedoch mit einer Funktion für diese Funktionseinheit plus einer weiteren zur Verarbeitung eines einzelnen Collection-Elements realisiert: Process_files() + Process_file().

Und wie steht es dann mit for()– und while()-Schleifen?

Wir raten davon ab!

Eine for-Schleife sieht unschuldig aus:

Doch im Gegensatz zu einer foreach-Schleife steckt hier Logik drin und kann auch nicht weiter versteckt werden. Ist es korrekt, bei Index 0 zu beginnen? Ist die Bedingung i < files.Length korrekt? Soll wirklich nach jeder Runde der Index um 1 hochgezählt werden? Und wenn ja, wird das auch so getan?

In diesem Fall ist die Logik der Schleife simpel, doch das kann sich schnell ändern. Wie testen Sie dann, ob sie (noch) korrekt ist? Das ist isoliert nicht möglich wie beim switch mit seinen Fällen.

Während beim switch jedoch Zählen hilft, ob alle enum-Werte mit einem Fall bedacht sind, ist die Analyse der Logik einer for-Schleife nicht so einfach. Also: Finger weg von for-Schleifen in Integrationen! Sie sind zu wenig deklarativ.

Dasselbe gilt für while-Schleifen, allerdings aus anderem Grund. Schauen Sie hier:

Spüren Sie es auch: Das ist viel weniger übersichtlich. Selbst die for-Schleife mit ihrer Logik war besser verständlich.

Immer noch wird ein Dateiinhalt verarbeitet und das Resultat zurückgegeben. Doch drumherum passiert einiges an Logik, das der while-Schleife dient. Nicht nur steht bei while ein Vergleich, es muss auch noch eine Hilfsvariable deklariert und aktualisiert werden. Deshalb auch das explizite result. (Die Queue<> als Quelle habe ich nur gewählt, um nicht noch weitere Logik zu brauchen, um auf die einzelnen Dateiinhalte zuzugreifen. Sie hat ansonsten keine Bedeutung.)

Beim while steckt die Logik nicht nur in einer Klammer, sondern verteilt sich über die ganze Schleife. Die Verantwortlichkeit ist verschmiert und vermischt mit der Integration. Sie zu verstehen, ist nicht auf einen Blick möglich, von der Testbarkeit ganz zu schweigen.

Das lässt sich auch nicht heilen, indem die Schleifenbedingung wie beim if in eine eigene Funktion ausgelagert wird. Es braucht weiterhin Zustand, der während der Schleife aktualisiert wird. Also: Finger weg von while-Schleifen in Integrationen.

Und wie sollte das obige Szenario stattdessen aussehen? Eine while-Schleife kann natürlich zum Einsatz kommen – allerdings nur in einer Operation.

In der Integration Process_files() findet nun wirklich nur Integration statt; jegliche Logik ist verschwunden. Calculate() ist fokussiert auf die Berechnung der Resultate. Und die Schleife ist nach Limit() ausgelagert. Dort wird nun jedoch keine weitere Produktionsfunktion mehr aufgerufen. Limit() enthält ausschließlich Logik, sie ist eine Operation.

Bitte beachten Sie, wie der Wunsch, das IOSP einzuhalten, dazu geführt hat, dass die Verantwortlichkeit, die in der while-Schleife steckte, nun freigestellt und mit einem Namen versehen ist. Es geht um eine Begrenzung der Zahl der Resultate. Das war vorher nur durch umständliche Interpretation erahnbar, als Logik und Integration noch vermischt waren.

Da die Operation Limit() ohne funktionale Abhängigkeiten gut testbar ist, können Sie sich jetzt auch leichter entscheiden, sie umzuschreiben. Vielleicht gefällt Ihnen das while nicht und sie wollen stattdessen ein foreach mit einem if darin für vorzeitigen Abbruch?

Rekursion

Schleifen lassen sich durch Rekursionen ersetzen und umgekehrt. Deshalb ist auch ein Wort nötig zur Verwendung von Logik in rekursiven integrierenden Funktionen.

Rekursionen müssen abgebrochen werden. Irgendwo ist eine Fallunterscheidung nötig, das ist Logik. Wird jedoch nicht abgebrochen, dann erfolgt der Aufruf einer Funktion, das ist Integration. Rekursionen erfordern daher zwangsläufig eine Mischung aus Logik und Integration.

Wenn die Abbruchentscheidung jedoch deutlich sichtbar z.B. am Anfang der integrierenden Methode steht, ist dagegen nichts auszusetzen. Verständlichkeit und Testbarkeit leiden nicht.

Die rekursive Variante von Process_files() enthält zwei integrierende Schritte. Dass davor noch eine Zeile für die Abbruchbedingung steht, behindert die Verständlichkeit nicht. Aber natürlich gilt auch hier: Die Logik der Abbruchbedingung sollte für sich testbar sein (oder trivial wie im obigen Fall).

Empfehlungen

Wie sollten Sie mit Logik in Integrationen verfahren? Die Empfehlung lautet weiterhin: Halten Sie Integrationen frei von Logik!

Wenn Sie aber gar keinen Ausweg mehr sehen oder die Alternative wirklich nicht einfach zu lesen ist, dann beherzigen Sie Folgendes in der Integration:

  1. Benutzen Sie nur foreach und nicht (!) for oder while als Schleifen.
  2. Benutzen Sie if nur mit einer eigenen Funktion für die Bedingung.
  3. Benutzen Sie switch nur mit einer eigenen Funktion für die Fallbestimmung und liefern Sie aus der einen enum-Wert zurück.
  4. In einer Rekursion machen Sie die Abbruchbedingung so knapp wie möglich (s.o. if bzw. switch).
  5. Benutzen Sie pro Integration höchstens eine Kontrollanweisung.
  6. Schachteln Sie in Integrationen keine Kontrollanweisungen.

Das sind wirklich gut gemeinte Ratschläge. Sie sollen Ihnen helfen, dem Prinzip IOSP möglichst treu bleiben zu können.

Doch am Ende ist das Problem mit allen Empfehlungen, dass sie entweder zu Widersprüchen führen oder in sklavischer Anwendung das Gegenteil von dem erzeugen, was beabsichtigt war.

Deshalb zum Schluss noch einmal: Setzen Sie programmiersprachliche Mittel ein, wie Sie wollen. Nur überprüfen Sie immer wieder, ob Verständlichkeit und Wandelbarkeit hoch gehalten werden. Die Idee hinter IOSP (und PoMO) ist, dass durch die Abwesenheit von funktionalen Abhängigkeiten ein Datenfluss entsteht, der sowohl das gewünschte Verhalten zeigt wie auch die genannten nicht-funktionalen Anforderungen erfüllt. Das ist es wert, auch ab und an mal eine ungewohnte Codeformulierung in Kauf zu nehmen.

Struktur folgt Kräften

Strukturen sind, wie sie sind, weil das ihrem Zweck am besten dient. Das gilt für Gebäude, Brücken, Fahrzeuge, Organisationen, Organismen.

In der Natur entstehen Strukturen des Lebendigen in einem blinden, evolutionären Prozess. Was überlebt, hat eine genügend gute Struktur. Bei menschengemachten Strukturen sind Strukturen so gut, wie es nach Stand der Erkenntnisse möglich ist. Wie viel Erfolg sich damit erzielen lässt, zeigt die Anwendung.

Langlebig bzw. erfolgreich sind Strukturen, wenn Elemente und ihre Anordnung ein zweckvolles Gleichgewicht herstellen in einem Spiel von Kräften. Bei allem Materiellen sind diese Kräfte zunächst physikalisch. Gravitation, Wind, Erschütterungen, Wärme usw. wirken aus allen Richtungen. Dazu kommen bei menschengemachten materiellen Strukturen noch funktionale und nicht-funktionale Anforderungen. Sie formen durch Nachfrage an den Hersteller.

Bei Software ist das nicht anders. Software ist zwar eine nicht-materielle Struktur, doch auch sie wird durch Kräfte geformt. Kunden bzw. Anwender ziehen an ihr mit den Kräften Funktionalität und Effizienz. Sie stellen die Verhaltensanforderungen dar. Sie formen die Oberfläche von Software sowie ihre Verteilung auf Hosts, d.h. die interne Struktur.

Welche Menüpunkte und Buttons eine Benutzerschnittstelle bietet, ob die Software auf mehreren Threads läuft oder als Client und Server in mehreren Prozessen oder gar auf mehreren Maschinen… das und mehr ist eine Folge der Verhalten fordernden Kräfte.

Von selbst bringt sich Software aber natürlich nicht in eine zweckvolle Struktur, bei der diese Kräfte in Balance sind. Das geschieht nur, wenn diese Kräfte gegenüber den Softwareproduzenten auch kraftvoll auftreten. Sie müssen mit zwei Beinen und einem klaren Willen ausgestattet sein, von einem Hebel – z.B. Geld – ganz zu schweigen.

Und schließlich muss für alle Seiten klar erkennbar sein, ob sich die Strukturen in die Richtung der wirkenden Kräfte bewegen. Die Konformität muss messbar sein.

Das ist für Verhaltensanforderungen quasi per definitionem der Fall.

Doch der Kunde will mehr. Eigentlich.

Er möchte z.B. korrekte Software. Software soll bei Lieferung Verhalten fehlerfrei sein und diese Korrektheit über die Zeit und durch alle Veränderungen hindurch nicht verlieren. Was korrekt war, soll korrekt bleiben, er will Regressionssicherheit.

Er möchte auch Software, die sich weitreichend verändern lässt, er möchte Wandelbarkeit. Die Korrektur von Fehlern und die Erweiterung des Verhaltens sollen möglichst einfach sein und über die Lebenszeit der Software auch bleiben.

Korrektheit, Regressionssicherheit und Wandelbarkeit sind Kräfte, die nur eigentlich auf Software wirken, weil es ihnen an Klarheit und Messbarkeit fehlt. Deshalb weiß der Kunde auch nicht so genau, wie er sie auf die Softwareproduktion anwenden soll.

Er kann die Abwesenheit von Fehlern nur bedingt aktiv feststellen. Meist erleidet er sie nur zur Unzeit. Dann jedoch kann er sie ummünzen in Forderungen nach Funktionalität oder Effizienz.

Um die Wandelbarkeit steht es noch schlechter. Sie lässt sich „im Moment“ gar nicht messen, sondern zeigt sich nur im Verlauf längerer Zeit. Eine Forderungshöhe lässt sich hier jedoch gar nicht angeben.

Korrektheit und Regressionssicherheit üben mithin lediglich eine indirekte Kraft aus, Wandelbarkeit ist sogar nur eine sehr, sehr schwache Kraft.

Dementsprechend ist Software strukturiert. Ihre Struktur richtet sich vor allem nach Funktionalität und Effizienz, nur bedingt nach Korrektheit und Regressionssicherheit und quasi gar nicht nach Wandelbar.

Das ist auch völlig ok – wenn Software nur kurzlebig sein soll. Doch das ist tendenziell nicht der Fall. Software soll vielmehr unbestimmt lange leben, d.h. im Einsatz sein und sich auch noch an wandelnde Verhaltensanforderungen anpassen lassen.

Um das zu erreichen müssen die Kräfte Korrektheit, Regressionssicherheit und Wandelbarkeit verstärkt werden. Sie müssen gleichziehen mit Funktionalität und Effizienz. Dann wird sich eine neue, eine nachhaltige Balance in der Softwarestruktur zwischen allen Kräften einstellen.

Und wie lassen sich die Kräfte stärken? Durch Klarheit und Messbarkeit. Es muss sehr leicht erkannt werden können, ob eine Struktur ihnen folgt.

Ob eine Software der Kraft Funktionalität folgt, zeigt die Anwesenheit von Logik, die die Funktionalität liefert: „Dort stehen die Berechnungen, die aus den Eingaben in diesem Dialog die Grafik in jenem Dialog machen.“

Ob eine Software der Kraft der Effizienz folgt, zeigt die Anwesenheit von Logik und/oder ihre Verteilung auf Hosts: „Dort ist der Code für die Autorisierung.“, „Hier ist der UI-Thread und dort der Thread für die Transformation im Hintergrund, damit das UI nicht einfriert.“

Welche Strukturen zeigen an, dass den Kräften Korrektheit, Regressionssicherheit und Wandelbarkeit gedient ist?

Ja, ich meine Strukturen, die das sichtbar, fühlbar machen. Prinzipien sind geduldig. Software soll z.B. dem Single Responsibility Principle (SRP) folgen – doch wie kann ich das am Code ablesen?

Ohne hard-and-fast rules zur Strukturierung von Software sind die Nachhaltigkeitskräfte im Verhältnis zur den Verhaltenskräften schwach. Sie müssen schon ohne die zwei Beine des Kunden auskommen. Dann sollte zumindest für Entwickler glasklar sein, was zu tun ist.



Struktur resultierend aus der Kraft Korrektheit

Der Kraft Korrektheit wird aus meiner Sicht mit der Ausprägung eines Paares bestehend aus Testfunktion und Produktionsfunktion entsprochen.

Die Testfunktion codiert ein Beispiel für korrektes Verhalten, wie der Kunde/Anwender es sich wünscht. Sie stellt einen Akzeptanztest dar, sie dokumentiert das Verständnis des Problems. Motto: Keine Interaktion ohne automatisierten Test!

Dem entspricht im Produktionscode genau eine Funktion, die dieses Verhalten herstellt bzw. zeigt. (Natürlich dürfen auch mehrere Testfunktionen die Produktionsfunktion „auf den Grill legen“.) Sie beschreibt einen Entrypoint in die Software. Über sie wird das Verhalten durch einen Reiz aus der Umwelt ausgelöst.

Dieses Funktionspaar erreicht zweierlei: Zum einen entsteht klare accountability. Einem geforderten Verhalten wird ein konkretes Strukturelement gegenübergestellt, das dafür verantwortlich ist. Die Produktionsfunktion hat einen syntaktischen Kontrakt über den sie eine Semantik verspricht. Beides überprüft die Testfunktion. Sie fordert das Versprechen des Produktionscodes automatisiert und damit für jeden jederzeit überprüfbar ein.

Zum anderen entsteht eine Trennung zweier grundlegender Verantwortlichkeit: Benutzerschnittstelle und Verhaltensproduktion. Die Benutzerschnittstelle stellt Verhalten nicht selbst her, sondern dient nur der Veranlassung bzw. dessen Projektion. Also sollte die Benutzerschnittstelle auch nicht für die Überprüfung korrekten Verhaltens nötig sein. Sie kann die Verifikation nur erschweren entweder durch die Notwendigkeit zur manuellen Akzeptanztests oder Mehraufwand für spezielle Testautomatisierungswerkzeuge.

Software wird durch diese simple Regel sowohl horizontal wie vertikal fundamental geteilt:

  • Horizontal zerfällt Software in eine dünne Membran für das UI und einen Kern „für den Rest“.
  • Vertikal wird „der Rest“ geteilt in Funktionsbäume, deren Wurzeln für die Verarbeitung einzelner Reize stehen.

Struktur resultierend aus der Kraft Regressionssicherheit

Regressionssicherheit wird zum Teil schon hergestellt durch die automatisierten Akzeptanztests. Letztlich müssen die jedoch lückenhaft bleiben. Genügend viele Ausführungspfade lediglich durch (umfangreiche) Akzeptanztests auf Produktionsfunktionen nahe der Oberfläche abdecken zu wollen, scheint mir unrealistisch.

Zusätzlich sollten deshalb alle öffentlichen Funktionen von Produktionsklassen ebenfalls mit mindestens einer Testfunktion gepaart werden. Diese Funktionen definieren Kontrakte im Inneren von Software dar. Wie die Semantik dieser Kontrakte aussieht und ob sie eingehalten wird, ist keine geringe Sache. Beides wird aber dokumentiert mit Tests.

Müssen es wirklich alle öffentlichen Funktionen von Produktionsklassen sein? Der Einfachheit halber sage ich mal Ja. So sollte der Default aussehen. So ist die Regel einfach einzuhalten und zu überprüfen.

Im Einzelfall kann man sich natürlich begründet auch dagegen entscheiden. Außerdem ist damit nicht automatisch garantiert, dass wirklich, wirklich ein lückenloses Netz gewoben wird, das Regressionen fängt.

Mir geht es jedoch nicht um die optimale Methode, auf die Kraft Regressionssicherheit zu antworten, sondern um eine pragmatische. Klarheit, Einfachheit in der Anwendung finde ich wichtig. Für mich ist daher automatisierter Test aller öffentlichen Produktionsfunktionen der Standard und eine Abweichung ist erklärungsbedürftig. Wie es um Regressionssicherheit steht, wenn der Standard anders herum lautet, können wir an den heutigen Codebasen ablesen.

Dazu kommt, dass Testfunktionen für Entrypoint-Funktionen und andere Produktionsfunktionen zuerst geschrieben werden. Das erhöht die Wahrscheinlichkeit, dass die Tests überhaupt entstehen. Das befördert aber vor allem das Verständnis für den zu schreibenden Produktionscode. Wenn ich mir zuerst einen Test ausdenken muss, überlege ich genauer, wie der Kontrakt des Produktionscodes überhaupt aussehen soll.

Das bedeutet nicht, dass es keine Iterationen geben darf. Zumindest ist jedoch der Default klar. Wieder eine Regel, deren Einhaltung leicht überprüft werden kann.

Zunächst führt das zu keiner besonderen Struktur. Es werden eben nur noch weitere Produktionsfunktionen mit einem Test versehen. Das ändert sich jedoch, wenn man die Regel umkehrt: Wenn etwas „testwürdig“ ist, wenn ein Verhaltensaspekt sehr gezielt dokumentiert werden soll, dann müssen die zugehörigen Funktionen public gemacht werden. Und da deshalb bestehende Kontrakte nicht „verwässert“ werden sollten, führt das zur Entstehung weiterer Klassen.

Struktur resultierend aus der Kraft Wandelbarkeit

Auf die Kräfte Korrektheit und Regressionssicherheit lautet die Antwort Testfunktion. Auf die Kraft Wandelbarkeit jedoch lautet die Antwort „nur“ Testbarkeit.

Bei Korrektheit und Regressionssicherheit sichern die Testfunktionen zu, dass Verhalten existiert und sich nicht verändert. Sie sind Ausdruck dessen, was schon ist. Bei Wandelbarkeit hingegen geht es um ein Potenzial. Das braucht zwar eine Struktur, doch das kann nicht zur Laufzeit überprüft werden.

Wenn ich mir die Prinzipien für Clean Code anschaue, dann scheinen mir zwei zentral: DRY und HCLC (High Cohesion, Low Coupling). Doch zu welchen Strukturen führen die? Kann ich die Einhaltung an ihrer Form erkennen?

Man kann über diese Prinzipien und andere lange, lange nachdenken und diskutieren. Man kann sich daran richtig einen Wolf entwerfen oder refaktorisieren. Dann ist das Ergebnis vielleicht auch perfekt – nur ist das ein langsamer Prozess. Außerdem klingt das sehr kompliziert. Viel, viel Erfahrung ist nötig, um die besten ausbalancierten Entscheidungen zu treffen, oder? Wer traut sich das schon im Sperrfeuer des Alltags zu?

Diese ganze Prinzipienreiterei hört sich für mich zunehmend an wie scholastische Disputation im Mittelalter. Total interessant, nur wenig praxistauglich. Deshalb findet Clean Code Development auch so selten statt. Es scheint zu sophisticated, nichts für normalsterbliche Entwickler. Schön zum Anschauen auf Youtube und in Blogs, aber nicht zum Anwenden in der eigenen Praxis.

Wir sollten es deshalb aufgeben, perfekten Clean Code zu produzieren. Wir sollten auch aufhören, ein schlechtes Gewissen zu haben, wenn wir keinen perfekten Clean Code hinbekommen. Stattdessen lieber eine realistische, pragmatische Haltung einnehmen. Hard-and-fast rules, um jederzeit das halbwegs Richtige zu tun. Wenn dann irgendwann nochmal Zeit ist, kann man ja nachjustieren, abschleifen und polieren.

Für mich stehen deshalb zwei ganz, ganz einfache Regeln zur Herstellung von Wandelbarkeit am Anfang:

  • Produktionsfunktionen enthalten entweder Logik und rufen keine anderen Produktionsfunktionen auf; dann heißen sie Operationen. Oder sie rufen ausschließlich Produktionsfunktionen auf und enthalten keine Logik; dann heißen sie Integrationen. Ich nenne das mal the Integration Operation Segregation Rule (IOSR).
  • Außerdem sollen Produktionsfunktionen – Operationen wie Integrationen – nicht wissen, woher ihr Input kommt und wohin ihr Output fließt. Sie sollen ihre Umgebung nicht beachten. Ich nenne das malthe Rule of Mutual Oblivion (RoMO).

Bei Anwendung dieser Regeln entstehen Funktionsbäume, die speziell sind und sich sehr von denen unterscheiden, die Sie in Ihrer Codebasis finden. In diesen Bäumen existiert Logik nämlich nur in den Blättern – während sie üblicherweise über die ganze Tiefe von Funktionsbäumen verschmiert ist.

Logik in den Blättern hat nun jedoch einen entscheidenden Vorteil: sie ist wunderbar einfach testbar. Sie hängt ja nicht mehr von anderer Logik ab. Die Blätter sind Operationen, darüber liegen potenziell viele Strata von Integrationen.

Das bedeutet, es sind keine Testattrappen mehr nötig, um Logik zu überprüfen. Und das wiederum führt dazu, dass Overhead durch Dependency Inversion und Dependency Injection reduziert wird.

Aber Achtung: Es geht nur um Testbarkeit. Alle Operationen sollten zwar testgestützt implementiert werden. Doch nach Fertigstellung sollten diese Tests in den Papierkorb wandern, sofern die Operationen keine öffentlichen Produktionsfunktionen sind. Die Tests sind lediglich Gerüste für eine korrekte Implementation im Kleinen und somit temporär. Auf lange Sicht wird die Korrektheit durch Regressions- und Korrektheitstests zugesichert, die bis in die Operationen wirken. Zusätzliche Tests würden die Wandelbarkeit einschränken. Das bedeutet auch, dass private Operationen privat bleiben sollen, selbst wenn sie kurzzeitig für Gerüsttests öffentlich gemacht werden müssen.

Wie gesagt: Es geht bei der Wandelbarkeit nur um Testbarkeit, nicht um dauerhafte Existenz von Tests. Bei Bedarf soll es einfach sein, Tests für Logik-Aspekte jeder Granularität aufzusetzen.

Grundsätzlich sehr gute Testbarkeit ergibt sich durch Befolgung der Regeln IOSR und RoMO. Sie sorgen dafür, dass Produktionsfunktionen ganz von allein vergleichsweise klein bleiben. Die Logik in Operationen ist viel fokussierter als in üblichen Codebasen, weil IOSR verhindert, dass Request/Response-Aufrufe zwischengeschaltet werden.

Dennoch können Operationen „schlecht geschnitten“ sein oder mehrere Verantwortlichkeiten enthalten. Das zu beurteilen, ist jedoch aufgrund der Form kaum möglich. Solch inhaltlichen Aspekte sind Sache des domänenkundigen Betrachters.

Aber es gibt weitere Indizien für gute Testbarkeit:

  • Zustandslosigkeit
  • Unabhängigkeit von externen Ressourcen, z.B. Datenbank, TCP-Verbindung, UI-Framework

Das bedeutet im Umkehrschluss: Abhängigkeiten von Zustand bzw. externen Ressourcen, die zentral für jede Software sind und sich nicht wegdiskutieren lassen, sollten wenigstens in eigenen Produktionsfunktionen gebündelt werden.

Nicht nur führt IOSR also zu einer vertikalen Strukturierung und RoMO zu einer horizontalen Entkopplung einhergehend mit kleinen Funktionen, es werden auch noch weitere Funktionen „ausgetrieben“ durch Trennung grundsätzlicher formaler Aspekte. Ich nenne das mal the Rule of Separating Concerns (RoSC).

Strukturen abstrahieren

Bisher habe ich nur von Produktionscodefunktionen gesprochen. Was ist aber mit Klassen und den darüber liegenden Modulebenen? Sie entstehen für mich durch rigorose Abstraktion.

Wenn Funktionen die ersten Strukturelemente sind, um auf die Kräfte Korrektheit, Regressionssicherheit und Wandelbarkeit zu reagieren, dann entsteht eine Menge, in der sich Muster erkennen lassen. Diese Muster sind für mich die Grundlage für die Zusammenfassung von Funktionen in Klassen. Klassen abstrahieren von den konkreten Funktionen; sie geben Mustern einen Namen.

Hier werden ein paar Funktionen zusammengefasst, die mit den selben Daten arbeiten, dort einige, die den selben API nutzen, da drüben welche, die sich um den selben Domänenaspekt kümmern usw. usf.

Das ist für mich „Objektorientierung von unten“. Es stehen nicht Klassen und Objekte am Anfang, sondern Funktionen. Softwarestruktur wird damit durch Verhalten getrieben und nicht durch Daten.

***

Clean Code ist notwendig. Je länger eine Codebasis leben soll, desto wichtiger wird es, nachhaltig mit ihr umzugehen. Das Gefühl muss über die dominanten Kräfte Funktionalität und Effizienz hinaus reichen; Sensibilität für Korrektheit, Regressionssicherheit und Wandelbarkeit muss entstehen.

Doch diese Kräfte sind immer schwächer als Funktionalität und Effizienz. Wer das kompensieren will, wer eine differenziertere, eine zukunftsfähigere Balance herstellen will, der braucht hard-and-fast rules, die auch mit Tunnelblick unter Stress noch angewandt werden können. Das Resultat muss dann nicht perfekter Clean Code sein, aber deutlich besserer als heute.

Mit den vorgestellten Regeln ist das aus meiner Sicht möglich. Auch die brauchen noch Disziplin und Übung, doch sie sind viel, viel klarer als die ewig zitierten Prinzipien des Clean Code.

Wenn ich meine Empfehlung zur Reaktion auf die Kräfte Korrektheit und Regressionssicherheit noch zusammenfasse zu Test-first All Contracts Rule (TfACR), dann lautet die Zusammenfassung:

  1. TfACR
  2. IOSR
  3. RoMO
  4. RoSC

Durch diese Regeln, so glaube ich, entstehen Softwaresysteme, die eine deutlich saubere Grundstruktur haben.

Und jetzt: Nicht lang schnacken, Kopf in Nacken :-) Wie wir in Norddeutschland sagen. Machen, statt reden. Engage!

A Different Perspective on a TDD Lesson – Terrain Generation, Part III: Implementation

Implementation follows design. Finally I can sling some code ;-) But where to start? Analysis and design left me with some options. There is not just an Interpolate() method like Robert C. Martin had, but several classes and methods.

On the surface of the system under development there are:

  • TerrainGenerator.Interpolate()
  • Terrain.Size
  • Terrain.this[]
  • Terrain.ToArray()

And below the surface I found:

  • ShapeHierarchy.Shapes
  • Calculator.Process

Interpolate() will be simple to do once all other modules are coded. It’s the integrating function, it represents the whole of the algorithm to implement. I’ll do it last.

Implementing Terrain

The analysis already provided some test for Interpolate(). So since Terrain does not depend on other classes, it shall be the first module to code.

The tests are straightforward. I write them both down:

No stepwise testing development needed as TDD advocates, because the implementation is trivial:

Terrain is almost a pure data structure, very little logic is needed. The interesting stuff is one line to calculate the size of the square and a couple of lines to initialize the vertexes.



Implementing Calculator

Calculator seems to be the next easiest class. Just one public method (although a user of the solution won’t see it).

But Calculator.Process() consist of two aspects: averaging and jitter. I take a TDD approach and start with just calculating an average:

The implementation is trivial using C# Linq:

Now on to adding some randomness:

Since the average does not need to be checked again I just pass in 0.0f as a single value. That way the random value (based on offset and amplitude) will be the result of Process().

Using 1.0f and -1.0f as „random“ values tells me if the amplitude is able to increase and decrease the offset:

I could have put adding the jitter into a method of its own. But since Process() is only two lines in total and focussing on the jitter by choosing certain input values is very easy, I chose not to add another module (yet).

Implementing ShapeHierarchy

Implementing the shape hierarchy is the most challenging part of the whole thing. It’s the heart of the algorithm. I’ve to be careful to get it right. Small steps seem prudent.

First two „acceptance tests“, i.e. tests of the overall functionality. Once they become green I deem the implementation of ShapeHierarchy.Shapes done.

I need to check if shapes of the correct number and with the correct center and vertexes get produced for certain sizes. It’s not important if those shapes represent diamonds or squares. Averages are calculated just from vertex values on a terrain.

To make comparison easy I use the ToString() function of the Shape and Coordinate data structures to get shapes serialized. That way I just need to check string equality and don’t need to implement Equal() methods ;-) An added benefit: whenever something should go wrong with shapes and I need to debug, the IDE will readily show me shape contents as nicely formatted strings.

Of course I need to do the calculation of the expected shape coordinates by hand :-( But the acceptance tests I described earlier help:

I encoded the level of the shapes in their cell color (level 1: dark blue, light blue, level 2: dark green, light green) and chose a certain order for their calculation (squares will start with the north-west vertex, diamonds with the north vertex).

But what now? How to proceed with the implementation?

Some more design

I don’t like to drive an implementation just through the bottleneck of a single function. That might be ok for some very small functions like Calculator.Process(). But this here is more complicated.

Instead I insert another small design phase. Let me see if I can derive some more structure from the requirements for this specific method.

From what I can see in the specs there are squares and diamonds on each level. For a 3×3 terrain it’s one square and 4 diamonds on just one level where their radius is 1 (2^(n-1)). For a 5×5 terrain it’s one square and 4 diamonds on the first level with radius 2, and then 4 squares and 12 diamonds on a second level with radius 1.

There are levels with decreasing radius starting from 2^(n-1) each containing squares and diamonds which are independent of each other. Instead of a big problem there are now two small problems – and the big problem repeated on a lower level. Recursion to the rescue!

What I need are 3 functions:

  • Creating all shapes on a certain level with the appropriate radius – and in the end calling itself again for the next lower level.
  • Creating all squares with a certain radius.
  • Creating all diamonds with a certain radius.

The recursive function will be called by the Shapes property. And it’s easy to do: just concat the streams of squares and diamonds on one level with the stream of shapes from the next lower level.

There are tests for Shapes, but no tests for Enumerate_shapes() are needed. It’s a small plain integration function. I’ll know from the tests on Shapes if it’s correctly wiring together its functions.

The real workhorses are IEnumerable<Shape> Enumerate_squares(int size, int r)and IEnumerable<Shape> Enumerate_diamonds(int size, int r). Both take a radius r to generate shapes of the correct size for the level, and the size of the overall terrain to not include coordinates beyond its boundaries.

This is no rocket science. I really just got that from carefully studying the specs. The images there are telling this story. (And don’t get confused with „diamond step“ and „square step“. The diamond step actually calculates the center value of a square, and the square step calculates the center values of diamonds. At least that’s what I see in those images.)

Implementing private functions

The newly found shape enumeration functions are small, but a little bit tricky. I need to get the center point calculation right. It’s simple for the only square on the first level:

But how about the 16 squares on the third level of a 9×9 terrain? It’s too many for me to check all of them. So I’m doing just a spot check:

Here’s the implementation. Creating a shape at x/y with a certain radius is easy. I just need to be careful to calculate the vertexes in the order I expect in the tests. What’s a bit tricky is to vary x and y across the terrain on a level: where to start, how much distance between the center points?

But again with careful analysis of the specs it’s pretty straightforward ;-) And the same is true for diamonds, although they are generated with two sequential loops.

Integration

With all the details implemented and tested I can move on to integrate them. That’s the sole purpose of Interpolate(). It does not do anything on it’s own; it does not contain logic. It just pulls together all the parts and welds them together into a whole.

That’s no rocket science. All shapes come in the right order from the ShapeHierarchy; their vertex values get collected from the Terrain; the center value is calculated from those values. That’s it. Very straightforward.

And it makes the original acceptance tests go green! Success! :-)

Manual acceptance tests

The automatic tests tell me the implementation is finished. Problem solved. But can I trust them? A green test of 25 floating point values is not very tangible. For me at least. Also the tests for the shape generation might be green – but what does that mean for realistically sized terrains? I’m unable to write tests for 1025×1025 terrains.

Enter the test station. Now I can run it to put terrain generation to a real test. Will I be visually pleased by the calculated terrains?

See for yourself:

I think this terrain (and others) is looking pretty good. And if not, I guess it’s more the fault of my kind of visualization than of the algorithm’s implementation.

Removing scaffolding tests

Since I did not progress according to the TDD steps but did explicit design there’s no need for refactoring. The solution is already in good, clean shape. At least to my taste.

Nevertheless I will do some clean up. I will remove tests.

Yes, I think there are too many tests. All tests of methods which should not be visible on the surface should be deleted. They become apparent when I set the visibility of methods like Enumerate_Squares() to private.

Tests working on those methods were useful while building the solution. Like a scaffold is useful while building a house. But once the house is finished, the scaffold is removed. That’s why I remove those tests. They would be white box tests and make the test suite brittle.

The only tests I keep are those of public methods. They are my bulwark against regressions should I need to change the codebase. That of course requires them to be comprehensive so that enough code paths are covered.

Summary

That completes my journey through the terrains of the Diamond-Square algorithm. It was fun. A different kind of problem than I usually tackle.

I hope you found it interesting to watch the solution unfold along a different path than TDD would suggest. TDD to me has value and it’s place – but I also think it needs to be padded by comprehensive analysis and explicit design. Neither code nor design should be a matter of surprise or afterthought. „Think before coding“ is the way I suggest for truly clean code.


A Different Perspective on a TDD Lesson – Terrain Generation, Part II: Design

Analysis drives the structure of the solution for the Diamond-Square algorithm from the outside. It defines a syntactic – and semantic – overall contract for the whole. It thus already shapes the code without much creative effort from my side. And it produces criteria to assess if the desired behavior has been achieved.

Before I start coding, though, I like to look deeper. Is there more I can glean from the specs in terms of solution structure? Are there special terms or structures or features which are characteristic to the problem domain and thus should be made explicit in code?

I believe you should manifest insights, aspects, particular problems, noteworthy decisions very clearly in code. Make them public and easy to see for everybody (I mean yourself and your fellow developers). They should not be swept under the rug as implementation details.

So far that thinking has lead to the Terrain class and visible random number generation. The algorithm is not just about behavior. It’s equally about a data structure, and a special one for that matter. And since the use of random numbers make the core function Interpolate() hard to test, they need to be made explicit, too.

Data structures

But are there further notable structures spoken about in the specs? Yes, I think so. They are even right in the title. There are squares and there are diamonds. That’s geometrical shapes. Look here:

The square is defined by its center (orange) and its four vertexes (black). The same goes for a diamond (4 of them in this picture):

This, to me, begs for representation in code. If I was presented with code solving this problem, I’d look for such kind of structures. At least a Shape class seems in order, e.g.

Whether there should also be subclasses Square : Shape and Diamond : Shape, I doubt. The difference between a square and a diamond is not in it’s basic structure (center, 4 vertexes), but how the structure is filled (see below).

Also, what I read in the specs is that there is a hierarchy. Shapes don’t stand alone, but are nested. Can you see that, too?

In a 5×5 matrix like depicted in the Wikipedia article I see two nested levels.

On each level there are squares and diamonds with a certain „radius“. The radius starts with 2^(n-1) and is divided by 2 with each level (for a matrix with a size of 2^n+1). Squares are nested within squares, diamonds are nested within diamonds, if you will.

For me this should be represented in code with a dedicated data structure, e.g. ShapeHierarchy. Its purpose is to represent all shapes a terrain is made up of so I can iterate over them. I deem that a responsibility of its own which should be encapsulated in a dedicated module.

Robert C. Martin, too, thinks the shapes or at least their coordinates are special:

I want to know that the coordinates of the midpoint of the square are being calculated properly. I also want to ensure that the value of the midpoint is set by taking the average of the four corners. So I’m very interested in the calculation of the coordinates of those four corners.

However he does not derive from that the need for an explicit representation which can be tested individually. Instead he’s testing coordinate generation through the bottleneck of his algorithm’s root function interpolate() by letting a clever contraption accumulate coordinates in a string, e.g.

I prefer to make things more explicit. If there’s an important concept hidden in a problem it should be represented by a module (function, class, library etc.). That’s a form of documentation of my understanding and avoids the accumulation of concepts into a monolith.

Shapes and the shape hierarchy are no detail of my solution. Sure, the user does not need to know about them. But any fellow developers should. And since considerable testing is needed to get these aspects right, that should not require any test wizardry.

So much for structures I’m able to glean from the problem description. But what about the functionality?

Behavior

The overall functionality is to fill a matrix with values. That’s what Interpolate() represents to the user. But how does interpolation work? Is it a process with distinct steps or different aspects? Sure it is. Not much creativity is needed to come up with at least two.

  1. The vertex coordinates for each square and diamond need to be determined. That’s what is represented by the shapes and the shape hierarchy.
  2. And then for each shape the center cell’s value has to be calculated.

I don’t need TDD to stumble across this. It’s there in the algorithm’s description. So why not represent it explicitly in the code right from the start? Both aspects are not surprising details but should lead to consciously designed modules. No refactoring needed to arrive at them. They can easily be tested separately.

The ShapeHierarchy with its Shapes property stands for the first aspect. The second can be encapsulated in a Calculator, e.g.

But wait, what does calculation mean? It’s averaging the values and then adding some random jitter to the result. It’s this transformation which depends on the parameters passed to Interpolate() for creating randomness. That should show in the definition of Calculator:

Summary

Analysis is about understanding a problem. Understanding is represented by examples and modules which describe a „surface“, the contract. Then the examples get translated into tests for these modules.

Design is about finding a solution to the problem based on the understanding. A solution is described by internal modules representing behavior and data. A solution does not contain any logic. It’s declarative. It tells what needs to be done, not how.

The solution design is like a map. It’s rough, but still it’s useful to navigate through reality.

For the Diamond-Square algorithm the solution is

Break the terrain up into a hierarchy of squares and diamonds (shapes). For each shape calculate the value of its center from its vertexes. Fill the terrain with these values.

This is a declarative solution design given in prose. And I think it’s good clean code practice to represent its main problem domain concepts like shape, hierarchy, calculate, center, vertex, terrain in code as distinct modules. To wait for them to appear by doing TDD is to waste time and to make testing harder than needed.


A Different Perspective on a TDD Lesson – Terrain Generation, Part I: Analysis

How to do TDD on a not so simple problem? Robert C. Martin has published a lesson on that tackling the Diamon-Square algorithm for terrain generation.

I think that’s a really interesting problem to consider as an exercise in software development to sharpen your skills. Today I presented it to a Clean Code training group and we had a lot of fun for a couple of hours.

Unfortunately I do not agree with how Robert C. Martin approached the solution. For one I find his statement

Don’t worry, it’s a very simple algorithm; and the linked article is extremely easy to read.

a bit misleading. It sounds as if this problem was on par with the usual code katas like „FizzBuzz“ or „WordWrap“. But in my experience it is not. It requires far more time to solve. Don’t try to cover it in a 90 minutes coding dojo with your programmer pals. You’ll give up frustrated.

But don’t worry. I won’t bother you with all the other reasons I don’t concur with Robert C. Martin. Instead let me just demonstrate a different perspective. If you find this an interesting contrast I will have accomplished what I want :-)

Analysis

Before you can code anything, you need to understand the problem. Understanding is the result of analysis. So the first step on my way to solving the Diamond-Square problem is to analyze the requirements, to look very closely. Whatever is already there can help me make coding easier. Any misunderstanding will create detours and waste.

Unfortunately there’s no product owner to explain the requirements to me and answer my questions. I’ve to drill my own way through the „specification“ given by Wikipedia.

Question no. 1 to be answered: How does the user of my solution want to use it? What’s the formal or syntactic contract supposed to look like?

Question no. 2 is: How does correct behavior of the System Under Development (SUD) look like? Is there a usage example?

In my view those two questions need to be answered in an unambiguous way. And the answers need to be written down. They even need to manifest in code. That’s also part of clean code.

Syntactic contract

How should the solution look on the outside? Let’s check what the specs are giving away:

The whole thing is revolving around a matrix, even a square matrix. A matrix of floating point values is created. The simplest way to express this in code would be a 2D array, e.g. float[,].

But not just any float array will do. First it has to be a square, then only certain sizes are allowed for the square; the length of a square – I call it its size – must be 2^n+1, e.g. 3, 5, 9, 257, but not 4 or 7 or 128. How can such a 2D array be enforced? How about a special type which always will lead to matrixes created correctly? It could pretty much look like a 2D float array, but a more constrained one. Here’s a simple usage example of such a type:

When creating such a matrix the user passes in n, not the size. That way the matrix can calculate the proper size.

Also I read that matrixes to be filled by the algorithm always start out with the corner cells set to initial values. Why not make that clear by adding constructor parameters?

In the end of course the terrain should be available as a plain float array for further processing by other code. The dependency on the special array-like type Terrain should be limited. A user needs a way to turn the matrix into an ordinary float array:

So much about the basic data structure the algorithm revolves around. But how to fill it with terrain height data? That sure is not a responsibility to be carried by the data type Terrain. Its sole purpose is keeping data in a consistent way.

Another module is needed. One that does all the necessary processing. An initialized Terrain will be its input. And in the end this Terrain is filled according to the algorithmic rules. A static method will do, I guess:

The values of the corner cells won’t be changed by the interpolation. The above test thus will be green. But what about the other cells. Their values will be calculated, that’s what the algorithm is about. It states that each cell’s value is the average of the values of some „surrounding“ cells plus a random value. For the above 3×3 terrain this would mean for the center cell t[1,1]: (1.0 + 2.0 + 3.0 + 4.0) / 4 + randomValue or 2.5 + randomValue.

To check for a correct calculation thus is not as simple as Assert.AreEqual(2.5f, t[1,1]). It probably would fail mostly because I cannot expect the random value to be 0.0 all the time. That would be against the definition of randomness ;-)



Semantic contract

So far the contract definition pretty much is about the syntax of the solution, its static surface: which classes should there be with which functions.

The contract defines how the solution can be used, e.g.

Nothing much can go wrong now. This makes very explicit what the requirements describe.

But what’s the behavior supposed to look like? t is only input to Interpolate(). What’s the output? To expect the corners to be initialized is not much of an expectation. That’s trivial and hardly needs testing. I’m talking about all the other cell values in a Terrain matrix.

As explained above they get calculated. But Interpolate() is not a pure function. Calling it multiple times with the same input won’t produce the same output due to the involvement of randomness.

That, too, is an aspect of the solution that needs to be explicitly expressed, I’d say. Without making the influence of randomness visible to the outside the solution will not be easily testable.

The least would be to inject into the solution a source of random values. Then it can be replaced for testing purposes if needed. But what’s the range the random values should be in? Wikipedia does not make any statement on this. But Robert C. Martin suggests two more values: an offset and an amplitude. If I understand him correctly this would mean a cell’s value should be calculated like this:

Or to further separate the aspects:

Setting offset and amplitude to 0.0 would switch off random variations of averages.

To me these additions make sense. If a terrain is supposed to be made up of values in a certain range, then random values cannot just be between 0 and 1 as they usually are provided by random number generators. They need to be in the ball park of the initial values or whatever the user wants.

This leads to another modification of the contract:

Now the user can control the randomness or the „jitter“ of the interpolated values. This is great for testing purposes! And it makes an essential part of the algorithm obvious.

For convenience sake, though, an overloaded function can be defined which does not take a function for random value creation but provides a default internally. That’s for production use:

But during tests the parameter injection will come in handy. It will be easier to provide examples for the semantic contract.

The semantic contract is about input-output relations. Which input leads to which output? Possibly some resources also might be involved like in-memory data or a file or the printer. The overall change of data during transformation is what’s perceived by the user as the behavior of the software:

Or with the resource state extracted from the transformation and added to the data flow:

transform() is stateful, transform'() is stateless, i.e. a pure function.

Semantic contracts define the input data plus the resource state before the transformation and the output data plus the resource state after the transformation.

Are there any examples of Diamond-Square behavior in the specification? Unfortunately not. I have to come up with sample inputs and outputs myself, it seems.

No, wait, Robert C. Martin provided an example. For a 5×5 matrix with all corner cells set to 0.0 as the initial value, an offset of 4.0 and an amplitude of 2.0, plus all random values being 1.0.

[This] simply turns off the randomness, and uses the absolute values of the random amplitude instead.

The effect of this is that 4+2*1=6 will be added to all averages calculated. The resulting terrain looks like this in his test code:

Great! Thanks for doing all the calculations. It’s tedious.

But, alas, this does not prove anything about my own understanding. I should come up with an example myself. Bummer :-(

I could pick up pen and paper and do the calculations in my head – but why not put Excel to some good use? Here’s my first example, just plain averages (no offset, no amplitude, no randomness):

Still though, I don’t know whether that’s correct. I just carefully tried to translate the calculation rules from the algorithm description into Excel formulas. See how each cell refers to other cells to pick the values to average (and then add the random jitter):

This is one of the challenges of this problem: to come up with correct examples.

But now that I have set up Excel, why not vary the data a bit? Here are the expected results for some other initial data and jitter:

And now that I’m at it, why not start with the same data as Robert C. Martin?

Hm… strange… my results look different from his. Why’s that? Take cell (1,1) for example: his value is 8.5, mine is 11,5. According to the specs the source cells to pull the values from for the average are these:

In Robert C. Martin’s matrix those values are 0.0, 8.0, 6.0, 8.0 with an average of 5.5. If I add the constant jitter of 6 to this I arrive at 11,5, but his value is 8.5.

Strange. Who’s wrong, who’s right? Without knowing how he did it, I don’t know. I believe I applied the rules correctly.

Anyway, that’s a couple of examples I can start with. I should translate them into automated acceptance tests. Here’s how they look:

They are simple, but typing in the expected matrix values is no fun :-( You’ve to be very careful to not introduce subtle mistakes.

Assert_matrix_equality() is a small helper method to compare the floating point values of the cells using a delta. The test framework’s Assert.AreEqual() does not allow for that when throwing arrays at it.

A test station for manual tests

Analysis could be complete now. I guess I understand how to calculate terrains using the Diamond-Square algorithm. I even can generate all kinds of examples for automated tests. They only suffer from one flaw: they are small. Setting up an Excel sheet for matrixes larger than 5×5 is prohibitive. So what about examples of size 9×9 or even 1025×1025?

There won’t be any. Sorry. That’s one of the reasons Robert C. Martin picked the algorithm for his TDD lesson. You cannot exhaustively check the result of the solution using examples. You need to find another way to gain confidence in your code. But that’s a matter of the phases after analysis.

So how could I check the correctness or rather „fitness“ of larger terrains? There’s no way I can do that precisely, i.e. by cell value, but I can at least check the overall visual impression of a terrain. In the end terrains are generated to be looked at, anyway. I don’t care for values but for visual appeal :-) Do the terrains look realistic or at least interesting?

To easily check that I set up a special test station for the algorithm. It generates terrains based on manually entered input values and shows them as a bitmap:

For the transformation of a floating point array into a bitmap I have to create a small mapper (Heatmap), though. It translates the terrain data into rainbow colors. But the effort for this is compensated by the ease with which I can check terrain generations at any time.

Get_input() reads some data from the console. The second parameter is the default value in case the user just presses ENTER.

Setting up such a test station might look like too much for such a small problem. But at least I would really like to see the terrains that get produced, not just floating point arrays. Since I don’t know graphics frameworks to render float arrays, I’m doing the translation myself.

Summary

Analysis is not just a mental exercise. Its results should be documented – as tests. Examples plus function signatures are concrete evidence of understanding. They should be manifested in code.

Such acceptance tests do not hint at any implementation, though. They don’t need to be incremental in any way. Hence you can expect them to stay red for quite a while.

But as soon as they turn green, you know you’re done :-)

In addition a test station for manual tests provides a means to make the solution more tangible in isolation. With it tests can be run by anybody.

However, doing the analysis in this way does not only result in tests which help stave off regressions. It helps structuring the solution. The specs don’t talk about functions or classes. But careful reading provides information on what should be tested. And that leads to a certain structure. Different aspects get expressed differently: a data structure with certain properties becomes a class, behavior of some kind becomes a function, dependencies on resources are made explicit.

To me that’s clean coding right from the start.