• Teaser Home

    Clean Code Developer School

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

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.

Tags: