Anlässlich einiger Code Katas, die ich in letzter Zeit gemacht habe, hier ein paar Gedanken zu Nutzen und Grenzen von TDD (Test-Driven Development oder Test-Driven Design):
Was leistet ein Test? Er prüft die Korrektheit der Funktionsweise einer Codeeinheit. Klingt selbstverständlich. Die Erkenntnis durch TDD liegt für mich aber in der Umkehrung: Nur, was als eigene Codeeinheit vorliegt, kann auch direkt auf Korrektheit geprüft werden.
Codeeinheiten sind mehr oder weniger kompliziert. Eine ganz einfache könnte so aussehen:
public int Add(int a, int b)
{
return a-b;
}
Ist die korrekt? Das stellt ein genauso einfacher Test leicht fest:
[Test]
public void Adding_two_numbers()
{
var sut = new MyMath();
Assert.AreEqual(5, sut.Add(2, 3));
}
Wenn der Testbalken jetz rot ist, dann lokalisiert eine Codeinspektion schnell den Übeltäter.
Wie steht es aber mit diesem Code zur FizzBuzz Kata:
IEnumerable<string> FizzBuzz()
{
for(int i=1; i<=100; i++)
{
if (i%3 == 0)
yield return “Fizz”;
else if (i%5 == 0)
yield return “Buzz”;
else if (i%3 == 0 && i%5 == 0)
yield return “FizzBuzz”;
else
yield return i.ToString();
}
}
Ist der korrekt? Wer kann das auf einen Blick sehen? Vor allem aber, wer kann ihn so einfach runterschreiben? Die Entwicklung geht ja eher schrittweise vor. Genügt da ein Test wie:
[Test]
public void Test_FizzBuzzer()
{
var sut = new FizzBuzzer();
string expectedValues = new[] string {“1”, “2”, “Fizz”, “4”, “Buzz”, …};
int i;
foreach(string v in sut.FizzBuzz())
Assert.AreEqual(expectedValues[i++], v);
}
Mit dem kann man natürlich von Anfang an “draufhauen” auf das system under test (SUT). Aber wenn irgendwas nicht klappt, weiß man nicht so recht, wo es hapert. Die SUT-Codeeinheit ist unübersichtlich und damit der Test sehr pauschal.
Durch TDD entsteht ein Gefühl dafür, wenn es unübersichtlich wird. Dann testen die Tests nämlich nicht mehr nur eine Funktionalität, sondern mehrere. Im obigen Fall ist der Test sozusagen ein Integrationstest, der sowohl die Generierung von Zahlen wie auch die Transformation einer Zahl prüft. Ob es ein Problem mit den Zahlen gibt oder eines bei der Transformation lässt sich im Zweifelsfall nur schwer sehen. Für mehr Klarheit bietet sich dann der Debugger an.
Besser ist es jedoch, den Debugger in der Schublade zu lassen. Stattdessen lieber den Code in Teilfunktionen zerlegen. Denn: Nur, was als eigene Codeeinheit vorliegt, kann auch direkt auf Korrektheit geprüft werden.
Die Teilfunktionen hier im Beispiel: Zahlengenerierung und Transformation. Die könnten mit ihrer Signatur so aussehen:
IEnumerable<int> ErzeugeZahlenreihe() { … }
string TransformiereZahl(int n) { … }
Daraus folgen dann insgesamt drei Tests:
- Prüfe die Zahlengenerierung
- Prüfe die Transformation
- Prüfe die Integration von Zahlengenerierung und Transformation
Jeder Test ist nun sehr spezifisch und vergleichsweise einfach. Dass das zu etwas mehr Code führt, ist nicht so schlimm, wie sich gleich zeigen wird. Geht etwas schief, ist der Scope jedenfalls klein. Ein Debugger muss nur selten angeworfen werden, um den Fehler darin zu finden. Das erhöht die Entwicklungsgeschwindigkeit wieder.
Wieviel Rätselraten Sie betreiben wollen, wenn in Ihrem Code etwas schief geht, bestimmen also sie. Der Rätselspaß (oder –ärger?) ist proportional zur Größe Ihrer SUTs.
Zudem gilt: Die Integration einzeln getesteter und für korrekt befundener Codeeinheiten zu testen ist weniger aufwändig, als nur die Codeeinheiten verschmolzen zu einem Ganzen zu testen.
Und jetzt zur zweiten TDD-Erkentnis: Code in einer eigenen Einheit ist prinzipiell austauschbar. Nicht nur lässt er sich gezielt testen und damit kontrolliert verändert werden. Er lässt sich auch gezielt komplett ersetzen.
In Max Frischs “Andorra” steht – wenn ich mich recht erinnere: “Nur was verzapft ist, geht nicht aus dem Leim.” Ein Satz, der mir irgendwie aus dem Deutschunterricht im Gedächtnis geblieben ist, auch wenn die Lektüre ansonsten keinen bleibenden Eindruck bei mir hinterlassen hat.
Angelehnt an diesen Satz, möchte ich vor dem TDD-Hintergrund formulieren und hoffen, dass es sich bei Ihnen ebenso einprägt: “Nur was isoliert testbar ist, kann ersetzt werden.” Das ist der Grund, warum aus Test-Driven Development über die Zeit Test-Driven Design geworden ist. Denn TDD macht es nicht nur leichter, die Korrektheit von Code zu prüfen, weil es testbare Codeeinheiten “austreibt”. Indem es diese Codeeinheiten motiviert, erzeugt TDD Flexibilität.
Am Beispiel: Gibt es erstmal eine eigene Codeeinheit für die Zahlengenerierung und die Transformation, dann kann es dafür alternative Implementationen geben. Die Zahlengenerierung könnte heute die natürlichen Zahlen erzeugen und morgen nur Primzahlen oder nur Fibonacci-Zahlen. Die Transformation könnte heute Vielfache von 3 und 5 erkennen und morgen gerade Zahlen oder Primzahlen.
Direkt und isoliert testbare Codeeinheiten haben immer einen irgendwie gearteten Kontrakt. Für die Zahlengenerierung wäre das z.B. Func<IEnumerable<int>>. Solange eine Integration nur auf die Kontrakte der sie konstituierenden Codeeinheiten abhebt, ist sie also frei, jede Implementation, die ihn erfüllt, zu nutzen.
Ergo: TDD führt nicht nur zu einfacherer Testbarkeit aufgrund kleinerer Codeeinheiten, sondern auch zu größerer Flexibilität.
Doch damit nicht genug! Ebenfalls segensreich empfinde ich, dass TDD quasi sofort Ergebnisse liefert. Wer erst lange an einer Implementation rumschraubt und am Ende testen will, der verschiebt die Befriedigung eines grünen Testbalkens auf unbestimmte Zeit. Implementationsperioden ohne Testläufe sind Durststrecken. TDD ist also eine ganz zeitgemäße “Genuss sofort”-Technik. Warum sich die Befriedigung eines grünen Testbalkens versagen? Aber vor allem: Warum länger als unbedingt nötig im Nebel tappen? Denn je länger Sie ohne Testlauf implementieren, desto tiefer wandern Sie in einen Nebel der Ungewissheit hinein. Ist das, was Sie an Code schreiben, tatsächlich zielführend? Löst Ihr Code wirklich das Problem? Oder schreiben Sie überhaupt den Code, den Sie schreiben wollen? Implementieren Sie tatsächlich Ihr Konzept?
TDD führt also auch zu häufigem Realitätscheck. Das ist umso wichtiger, je unbekannter die Problemdomäne. Wie sich in CodingDojos auf dem .NET Open Space und der prio-Konferenz herausgestellt hat und auch Übungen während Clean Code Developer Trainings zeigen, lauert die Unbekanntheit überall. Kaum ein Problem ist so einfach, dass sich TDD erübrigen würde. Nehmen Sie dafür aber nicht mein Wort, sondern probieren Sie es aus. Die KataPotter ist dafür ein Beispiel, das schon so manchem einiges Stirnrunzeln gemacht hat. Oder versuchen Sie sich am Minesweeper. Auch schön ist der Umgang mit römischen Zahlen.
Apropos römische Zahlen: Hier sehe ich allerdings durchaus eine Grenze von TDD. TDD hilft, separate Funktionseinheiten in einer größeren zu erkennen. Wird die größere schwer zu testen, dann liegt eine Zerlegung nahe.
TDD hilft jedoch nicht weiter, wenn eine Codeeinheit quasi unteilbar ist. Das ist oft nur scheinbar der Fall – am Ende ist jedes Lösung ja aber ein endlicher Baum, dessen Blätter eben die “atomaren” Codeeinheiten sind. Wie solche “atomaren” Codeeinheiten intern aussehen, beantwortet TDD nicht. TDD ist ein Hilfsmittel zur Strukturierung, nicht für den Algorithmenentwurf. Ein Bubblesort-Algorithmus lässt sich mittels TDD vielleicht noch entwickeln, weil er sich in naheliegende Codeeinheiten zerlegen lässt; ein Quicksort-Algorithmus jedoch ist jenseits von TDD.
Die Kata zur Umwandlung von römischen Zahlen profitiert mithin nicht so stark von TDD wie z.B. FizzBuzz oder KataPotter. Sie ist eher algorithmischer, denn struktureller Natur.
Und so bin ich bei meiner abschließenden Erkenntnis zu TDD: TDD ist kein Ersatz für Kreativität, Nachdenken und Erfahrung. Auch mit TDD als Werkzeug im Programmierkoffer lohnt der Einsatz des Gehirns vor dem Programmieren. Nachdenken hilft und verkürzt die Lösungszeit. Angesichts von z.B. der Minesweeper-Kata müssen wir uns nicht dümmer stellen als wir sind. Wir müssen nicht reflexartig in die Tasten hauen und sofort einen Test schreiben. Es lohnt sich, einen Moment über das Problem nachzudenken. Welche Datenstrukturen sind im Spiel? Welche Algorithmen? Wie könnte eine Grobstruktur der Lösung aussehen, welche Codeeinheiten/Verantwortlichkeiten könnten wie zusammenspielen?
Wenn Sie dann einen gewissen Plan haben, dann erst legen Sie mit TDD los. Und zwar gehen Sie dann gezielt mit TDD auf die Grobstrukturen los, die Sie durch Nachdenken ermittelt haben. Das erspart unnötigen Refaktorisierungsfrust.
Zum Schluss eine offene Frage: Erzeugt TDD nicht nur testbare und flexible Strukturen, sondern auch evolvierbaren Code? Ist Testbarkeit bzw. Flexibilität mit Evolvierbarkeit gleichzusetzen? Oder könnte eine Zerlegung, die nicht durch TDD motiviert ist, noch evolvierbarer sein? Hm…
Einstweilen entscheide ich mich allerdings im Zweifel für den Glauben an TDD gepaart mit Nachdenken. Für die Strukturierung von Komponenten ist das ein sehr hilfreiches Vorgehen.