Hier sind zwei recht ordentliche Artikel zum Thema TDD: Teil 1 und Teil 2. Mir gefällt daran besonders, dass Neal Ford sein TDD beginnt mit einer kleinen Liste von Schritten auf dem Weg zur Beantwortung der Frage, ob eine Zahl eine Vollkommene Zahl ist.
Bei allem Gefallen an seinen Artikeln reibe ich mich jedoch an einigen Punkten.
Fragliche Objektorientierung
Wenn Sie die Wurzel einer Zahl berechnen wollten und ich würde Ihnen sagen, das geht mit C# so:
var sqc = new SqrtCalculator(7);
Console.WriteLine(“Wurzel aus 7: {0}”, sqc.Calculate());
Würden Sie das als naheliegend empfinden? Kaum.
Oder wenn ich Ihnen anbieten würde, einen String bei einer Zeichenkette so zu splitten:
var splitter = new StringSplitter(“ ”);
var words = splitter.Split(“the quick brown fox”);
Würden sie das als komfortable ansehen? Kaum.
Nichts anderes schlägt Neal Ford aber vor, wenn er seine Implementation zur Prüfung von Zahlen auf Vollkommenheit so aussehen lässt:
var c = new Classifier(6);
Console.WriteLine(“Ist 6 vollkommen: {0}”, c.IsPerfect());
Das ist genausowenig komfortabel oder naheliegend wie die obigen Beispiele. Das ist erzwungen objektorientiert. Das ist ein für den Anwender der Funktionalität nicht nachvollziehbarer API.
Die Prüfung, ob eine Zahl vollkommen ist, ist eine Funktion genauso wie die Prüfung, ob eine Zahl gerade ist oder die Berechnung ihrer Wurzel. Warum also sollte ich als Nutzer einer solchen Funktion(alität) eine Klasse instanzieren müssen? Warum sollte diese Klasse auch noch zustandsbehaftet sein?
Neil leitet das auf Seite 8 seines Teil 1 daraus ab, dass zwei interne Funktionen (factorsFor(int number) und isFactor(int number)) auf demselben Parameter arbeiten, der zu prüfenden Zahl. Er meint, das sei nun wirklich zuviel prozedurale Programmierung. Oder vielleicht scheut er sich vor der “Feuchtigkeit” der Wiederholung eines Parameters? Ich weiß es nicht. Ich finde es einfach nur überkandidelt – und zwar leider in einer Weise die symptomatisch ist für die Branche. Denn sein Vorgehen ist ja tragisch: Er will das Gute erreichen, verschlimmert die Situation aber. Er möchte einem Paradigma dienen, das für sich gepachtet zu haben scheint, dass es durch und durch gut sei – aber das Paradigma passt leider nicht zum Problem. Mit dem Hammer Objektorientierung wird die funktionale Schraube eingeschlagen. Autsch!
Hier dagegen meine Lösung:
public class VollkommeneZahlen
{
public static bool IstVollkommen(int zahl)
{
return EchteTeilerVon(zahl)
.ErgebenEineVollkommeneZahl(zahl);
}
…
Die Nutzung sieht so aus:
Console.WriteLine(VollkommeneZahlen.IstVollkommen(6));
Das halte ich sowohl in der Anwendung wie auch in der Implementation für intuitiv und angemessen. Egal, ob das nun speziell objektorientiert oder sonstwas ist. Der Anwender versteht es, weil eine Funktion als Funktion realisiert ist. Und der, der den Code liest, sieht sofort, wie das Ergebnis in zwei Schritten zustande kommt.
Unklare Verteilung von Verantwortung
Nicht nur ist aber die Objektorientierung für das Problem unangemessen, wie ich finde. Sie hat sogar einen negativen Effekt auf die Verständlichkeit und Flexibilität des Codes.
Vollkommene Zahlen sind solche, deren echte Teiler in Summe die Zahl ergeben. Die Teiler von 6 sind 1, 2, 3 und 6. Allerdings ist 6 kein echter Teiler. Die Summe ist deshalb nur aus 1, 2 und 3 zu bilden und die ergibt 6. Damit ist 6 eine Vollkommene Zahl.
In Neal Fords Code ist die besondere Behandlung der Zahl selbst als unechter Teiler an zwei Stellen codiert:
Er fügt die Zahl selbst als Teiler zunächst der Menge aller Teiler hinzu – und zieht sie am Ende wieder ab. Damit erfüllt auch seine Funktion calculateFactors() nicht mehr wirklich ihre Verantwortlichkeit, die Teiler der Zahl zu berechnen. Das trägt nicht zur Verständlichkeit der Algorithmusimplementierung bei.
Außerdem ist isPerfect() nicht nur von der Zahl als Zustand abhängig, sondern auch noch indirekt von der Menge der Teiler, die calculateFactors() berechnet und sumOfFactors() auswertet. Solcher Zustand erhöht immer die Komplexität einer Lösung. Und er macht die Wiederverwendung von Methoden in anderen Zusammenhängen schwieriger.
Meine Funktion IEnumerable<int> EchteTeilerVon(int zahl) hingegen ist von nichts abhängig und kann daher in jedem Zusammenhang, in dem nur die echten Teiler einer Zahl relevant sind, wiederverwendet werden.
Auch macht bool ErgebenEineVollkomeneZahl(this IEnumerable<int> teiler, int zahl) den Code selbstdokumentierender. Das Abstraktionsniveau der Bestandteile von IstVollkommen() ist einheitlich. Bei Neal hingegen liegen calculateFactors() und der Ausdruck zur Berechnung des Rückgabewertes auf unterschiedlichen Abstraktionsniveaus. Der Leser muss sich zusammenreimen, dass eine Zahl vollkommen ist, wenn die Summe von Teilern abzüglich einer Zahl gleich der Zahl ist. Zugegeben, das ist nicht so kompliziert, doch es ist ein spürbarer intellektueller Aufwand, der nicht Not tut. (Insbesondere verwunderlich ist sein Ansatz, da er in einem anderen Artikel das Single Level of Abstraction Prinzip lobt.)
Gesucht: Klares Vorgehen
Schließlich vermisse ich bei Neals Artikeln, dass er sein Vorgehen nicht weiter deutlich sichtbar systematisiert. Er tut schon das Richtige, in dem nicht mit TDD “reinspringt” und als erstes einen Test für isPerfect() schreibt, sondern mal einen Gedanken an den Algorithmus verschwendet. Er denkt also nach, bevor er codiert. Doch das hebt er nicht hervor. Einzig der “TDD workflow” wird als Handlauf für das Vorgehen wieder einmal thematisiert.
Schade. Denn zu TDD gehört mehr, denke ich. Hier mein Version eines Entwicklungsprozesses, in den TDD eingebettet sein sollte:
- Verstehen
- Nachdenken/planen
- Unsicherheiten ausräumen
- Codieren
Diese Schritte beziehen sich auf ein Problem bzw. auf die das Problem lösende Funktionseinheit. Sie sind daher rekursiv zu durchlaufen, sollte die Funktionseinheit in weitere zerfallen. Das ist ein wichtiger Punkt! Denn auf Zerlegungsebene eines Problem gilt es zuerst zu verstehen, dann zu planen usw.
Zu 1: Am Anfang der Entwicklung einer Softwarelösung – sei es Methode oder Klasse oder Komponente oder Anwendung – steht das Verstehen. Investieren Sie Zeit, das Problem oder gar den Kunden zu verstehen. Machen Sie sich klar, was die Anforderungen wirklich sind. Tragen Sie Fälle zusammen, die beschreiben, was die Eingaben/Parameter und der zugehörigen erwarteten Ausgaben/Ergebnisse sind. Das hört sich selbstverständlich an. In der Praxis ist aber schwierig und bedarf immer wieder Mut und Disziplin. Beide werden oft nicht ausgebracht. Schöner ist es, schnell den Code Colt zu ziehen und zu programmieren.
Leider ist ungenügendes Verständnis aber wohl eine der wesentlichen Ursachen für soviele Probleme der Softwareentwicklung. Wer ungenügend versteht, der implementiert zuviel. Wer ungenügend versteht, der implementiert das falsche oder inkorrekt.
Letztlich ist ungenügendes Verständnis zwar nie ganz zu vermeiden, doch ein wenig mehr Mühe darf es schon sein. Vor allem ist in den Prozess des Verständnisaufbaus der Kunde sehr aktiv mit einzubeziehen. Fragen Sie ihm Löcher in den Bauch! Dafür ist er da ;-) Lassen Sie ihn nicht aus seiner Verantwortung. Wenn er etwas von Ihnen will, dann soll er wirklich so genau wie möglich beschreiben, was das ist, wie es aussehen soll, wie es sich verhalten soll. Erbitten Sie von ihm sehr konkrete Abnahmetests.
Falls der Kunde sich bei solch “peinlichen Befragung” ziehrt, machen Sie ihm klar, dass das Ergebnis Ihrer Arbeit nur so gut sein kann wie seine Spezifikation. Garbage in, garbage out – das gilt auch hier. Wer nur ungenau spezifiziert, der kann auch nur ein ungenaues Ergebnis bekommen. Dann muss nachgebessert werden. Das macht niemandem Freude.
Da die meisten Kunden mit sehr genauer Spezifikation überfordert sein werden, gibt Ihnen das die Chance, ein agiles Vorgehensmodell “zu verkaufen”. Nutzen Sie die Chance! Aber nehmen Sie das nicht als Entschuldigung, ohne gründliches Verständnis mit dem Codieren zu beginnen.
Zu 2: Wenn Sie meinen, das Problem durchdrungen zu haben, sollten Sie noch nicht zur Tastatur greifen. Auch nicht, wenn Sie TDD betreiben wollen. Tun Sie sich den Gefallen und denken Sie zuerst über einen Lösungsansatz nach. Diese Phase fällt leider immer wieder zu kurz aus. Auch in wohlmeinenden Coding Dojos wird das nicht unbedingt praktiziert. TDD als Test-Driven Design verstanden soll es richten. Codieren kann Planung und Nachdenken aber nicht ersetzen, sondern allenfalls unterstützen. Eine Struktur für Ihre Software ergibt sich nicht einfach. Die will bewusst entworfen werden.
Das ist, was mir an Neals Artikel gefallen hat: Er hat über das Problem nachgedacht und einen kleinen Plan aufgestellt. Er ist auf zumindest drei Funktionseinheiten/Verantwortlichkeiten gekommen, aus denen eine Lösung für das Problem “Vollkommene Zahlen erkennen” besteht:
- Potenzielle Teiler einer Zahl erzeugen
- Feststellen, ob eine potenzieller Teiler tatsächlich ein Teiler ist
- Aufsummieren der tatsächlichen Teiler, um festzustellen, ob sie die Zahl ergeben
Das sind zugegeben kleine Funktionseinheiten. Macht aber nichts. Jede, die Sie beim Nachdenken finden, ist eine gute. Denn damit bekommen Sie “Bausteine” an die Hand, die sie separat testen können. Das ist immer gut. Und Sie können womöglich entscheiden, in welcher Reihenfolge Sie deren Umsetzung angehen.
Ein zunächst monolithisches Problem zerfällt so in kleinere Probleme und die womöglich wiederum in kleinere usw. Nachdenken hilft also bei der Komplexitätsbewältigung.
Zu 3: Allerdings mag es sich herausstellen, dass Sie sich mit der Umsetzung der einen oder anderen Funktionseinheit, auf die Sie beim Nachdenken gestoßen sind, nicht 100%ig wohlfühlen. Das ist ganz normal. Es mag an der Problemdomäne liegen oder an einer Technologie, die zum Einsatz kommen soll. Deshalb ist es wichtig, dass Sie vor dem Codieren noch einen Zwischenschritt machen.
Seien Sie sensibel für Ihre Unsicherheiten und räumen Sie sie aus. Das können Sie durch das Studium von Fachliteratur tun. Oder Sie befragen den Kunden nochmal. Oder Sie programmieren etwas. Rotzen Sie Code raus (Spike Solution), um sich z.B. mit dem neuen O/R Mapper vertraut zu machen, bevor Sie damit Produktionscode schreiben. Oder für das Problem der vollkommenen Zahlen könnten Sie sich Erweiterungsmethoden anschauen, die es möglich machen, die Funktion IstVollkommen() so lesbar zu gestalten.
Solange Sie noch unsicher sind, sollten Sie nicht mit der Codierung beginnen. Ansonsten entsteht schnell akzidenzielle Komplexität, d.h. Komplexität, die nicht nötig ist, die Sie letztlich auch nicht wollen.
Wenn Sie etwas programmieren, um Ihre Unsicherheit abzubauen, dann schmeißen Sie es am Ende besser weg. Nehmen Sie Erkenntnisse mit ins Codieren, aber keinen Code. Auch sind solche Spike Solutions keine Prototypen. (Allerdings können Sie offizielle Prototypen natürlich immer noch mit dem Kunden vereinbaren. Dann teilen Sie Ihre Unsicherheit mit dem Kunden.)
Zu 4: Erst wenn Sie genau verstehen, was der Kunde braucht, einen Plan haben, wie Sie ihm das geben können, nicht mehr unsicher sind, erst dann sollten Sie mit dem Codieren beginnen. Das gilt aus meiner Sicht auch für TDD. Oder gerade für TDD! Denn immer wieder sehe ich bei Entwicklern, die mit TDD beginnen, dass sie Schwierigkeiten haben, sich zuerst Tests vorzustellen, bevor sie etwas implementiert haben.
Das liegt meiner Meinung nach daran, dass sie das Problem noch nicht genügend gut durchdrungen haben bzw. der Kunde keine Akzeptanztestfälle geliefert hat. Und/oder es liegt daran, dass sie unsicher sind, was eigentlich zu implementieren ist. Denn wer davon keine Vorstellung hat, der tut sich schwer damit, Erwartungen zu formulieren.
Der Effekt ist dann oft, dass TDD mit trivialen Tests oder Sonderfalltests begonnen wird. Die für den Kunden viel wichtigeren “happy day” Szenarien werden dadurch auf die Lange Bank geschoben. Klarheit für deren Implementation ergibt sich auch nicht aus solchen Tests. Geschäftig mit Tests zu beginnen kann also auch ein subtiles Symptom von Prokrastination sein.
Zusammenfassung
TDD ist eine zentrale Praktik für professionelle Softwareentwicklung. Gerade deshalb ist es nötig, sich ihr ganz bewusst zu sein und immer wieder zu fragen, ob sie schon optimal betrieben wird. Ein systematisches Vorgehen hilft dabei – gerade am Anfang.
Vor dem falschen Programmierparadigma schützt TDD aber natürlich nicht. Wir tun also gut daran, auch seine Grenzen bewusst zu sehen. TDD produziert also immer nur Code auf dem fachlichen Wissensstand seines Anwenders. Deshalb ist es gut, immer wieder über den Tellerrand (der Objektorientierung) zu schauen und sich mit anderen auszutauschen.