Samstag, 31. Oktober 2009

TDD wofür? [OOP 2010] [endlich-clean.net]

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:

  1. Prüfe die Zahlengenerierung
  2. Prüfe die Transformation
  3. 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.

8 Kommentare:

Rainer Hilmer hat gesagt…

Ich hatte gerade erst so ein Problem mit einer verschachtelten LINQ Expression und dazu auch einen dreiteiligen Artikel geschrieben. Vielleicht möchtest du ihn ja mal anschauen.
http://dotnet-forum.de/blogs/rainerhilmer/archive/2009/10/30/linq-lambda-and-testability-issues.aspx

http://dotnet-forum.de/blogs/rainerhilmer/archive/2009/10/30/testing-a-dismantled-linq-expression.aspx

http://dotnet-forum.de/blogs/rainerhilmer/archive/2009/10/30/stripping-down-a-convoluted-linq-expression.aspx

Ralf Westphal - One Man Think Tank hat gesagt…

@Rainer: Danke für die Links. Insb. die Auflösung (im doppelten Sinn) im letzten Beitrag finde ich schön. Dort sieht man, dass deklarativer wie imperativer Code eben aus unterschiedlichen "Verantwortlichkeiten" bestehen kann. Die gilt es zu identifizieren und dann herauszulösen. Das erhöht dann nicht nur die Testbarkeit, sondern auch die Verständlichkeit.

-Ralf

Lukas Burri hat gesagt…

Hallo Ralf

Deinen Artikel finde ich gut. Allerdings habe ich mit TDD mühe. Überall im Web (Blogs & Co.) werden Artikelchen über die Wichtigkeit von Testroutinen geschrieben. Jedoch habe ich bis jetzt immer "kuschelige" Testbeispiele in der Form "1+1 = 2" gesehen. Im höchsten Fall wird für einen simplen Algorithmus eine Testmethode geschrieben.
In der Praxis sehen die Testfälle leider anders aus! Da ist z.B. von ganzen Importmodulen / Schnittstellen die Rede, wo eine Funktion ein Objekt mit Properties (20 An der Zahl) inkl. Daten bearbeitet z.B. Abhängig von einem Enumerationswert (der auch 10 Zustände annehmen kann und ebenfall an die Routine übergeben wird...) Da nimmt der Aufwand für eine Testroutine dann schon eher exponentiell zu; Und eine Woche später wird dann diese Funktion überarbeitet...

Die in den meisten TDD Artikeln vorkommenden Algorithmen haben eine andere Natur: Sie stammen in der Regel aus anderen Systemen, in welchen sich diese Algorithmen eben schon bewährt haben. Ein Test dieser Algorithmen wird somit hinfällig.

In einer kleinen Softwarefirma halte ich es für Überflüssig, solche Alibi-Tests bei einfachen Methoden (wo in der Regel sowieso weniger Fehler auftreten) zu implementieren. Und dies auch nur damit die hübschen Pünktchen im NUNIT auf grün springen und dem Chef glänzende Augen verpassen.

Von den TDD Propagisten erwarte ich mehr Praxisnähe. Die meisten Software Entwickler schlagen sich mit Alltagsmodulen wie Import, Export, Statistik & Grafik sowie Reporting (Druck) auseinander. Aber da wirds eben nicht so einfach mit den Unit-Tests.

Folglich sind die meisten Artikel über TDD für Theoretiker und Denker ein heisses Thema aber für den produzierenden Entwickler eben nur heisse Luft.

Ralf Westphal - One Man Think Tank hat gesagt…

@Lukas: Zu unterscheiden ist zwischen Unit Tests und TDD. TDD ist eine Spezialform von Unit Testing.

Wogegen richtet sich deine Kritik? Gegen TDD, also dagegen, Tests zuerst zu schreiben? Ich glaube, nicht.

Dir geht es "nur" um Unit Tests.

Golo Roden hat neulich auch einen kritischen Artikel zu Unit Tests geschrieben. Dazu habe ich einen ausführlichen Kommentar geschrieben: http://www.des-eisbaeren-blog.de/post/2009/11/01/Wie-viel-Sinn-machen-Unittests.aspx#comment

Den möchte ich hier ungern wiederholen. Schau doch mal dort vorbei.

An dieser Stelle jedoch zumindest soviel: Jedem, der mit Unit Tests unzufrieden ist, kann ich nur die Frage stellen: Was ist die Alternative? Wie stellst du ohne automatisierte Tests (und das sind nicht nur Unit Tests, sondern auch z.B. auch Integrationstests) verlässlich, systematisch und regressionssicher fest, dass deine Software (immer noch) korrekt ist?

Die Antwort auf diese Frage lautet: "Gar nicht."

Dass manche Unit Tests schwieriger sind als andere, dass Mickymausbeispiele zuwenig sind: da sind wir uns einig. Aber nur weil es nicht einfach ist, sollte das nicht heißen, es nicht zu tun. Der Schluss sollte vielmehr lauten, sich mehr zu bemühen. Denn ich bin sicher, dass auch dein Code zu 90% automatisiert testbar ist.

Pass auf, hier ein Angebot:

Stefan Lieser und ich - Dr. Clean und Dr. Code also ;-) - kommen zu dir ins Haus und schauen sich deinen Code an. Wenn wir dir keine substanziellen Hinweise geben können, wie du deinen Code mit einer deutlichen Testabdeckung versehen kannst, zahlst du nix. Ansonsten zahlst du unsere Tagessätze.

Du siehst, wir sind so sicher, dass wird die Testschwünge am Trapez und den TDD-Salto bei dir ohne Netz wagen würden ;-)

Wie wäre das?

-Ralf

Christina hat gesagt…

Hallo Lukas,

du schreibst "In einer kleinen Softwarefirma halte ich es für Überflüssig, solche Alibi-Tests bei einfachen Methoden (wo in der Regel sowieso weniger Fehler auftreten) zu implementieren. Und dies auch nur damit die hübschen Pünktchen im NUNIT auf grün springen und dem Chef glänzende Augen verpassen."
Testen hat nichts mit der größe einer Firma sondern mit der größe der Anwendung zu tun, an der ihr arbeitet. Wenn diese Anwendungen größer als sagen wir 10 Dateien sind, dann kann ich mir nicht vorstellen, dass ihr bei neuen Anforderungen immer sicher seid, dass der alte Code weiterhin einwandfrei funktioniert. Und dafür hast du die Tests!
Ich betreue ein Projekt bei unserer Firma, das bei einem Fehlfunktion hunderte von Fehlern pro Sekunde verursachen würde. Ich kann mich noch an den Zeiten erinnern, wo ich Änderungen ohne Tests veröffentlichen müsste und seit dem gilt es für mich: Tests rules!

Rainer Hilmer hat gesagt…

@Lukas: In unserer Firma habe ich einen Windows-Dienst geschrieben, der vollautomatisch Emails von einem Exchange Server abholt, in seine Elemente zerpflückt, analysiert, gruppiert, kategorisiert und diese Teile dann in eine SQL-DB persistiert. Ich wüßte gar nicht wie man so etwas ohne TDD und Unit Tests zuverlässig realisieren sollte. Was soll ich dir sagen, der Dienst schnurrt seit dem ersten Tag wie ein Kätzchen. Leider aber muß ich dir den Code schuldig bleiben. Ich habe in meinem Arbeitsvertrag eine Geheimhaltungsklausel unteschrieben. Dafür kann ich dir aber meinen Blog-Post empfehlen. Dort findest du Testmethoden, die über 1+1=2 hinausgehen und konkrete Objekte testen (siehe den zweiten Link in meinem vorherigen Kommentar).

Lukas Burri hat gesagt…

Danke allen für die Hinweise. Ich sehe, dass dieses Thema auf grosse Resonanz stösst und brandaktuell ist - Danke auch für den Eisbären-Blog-Link.

Auch sehe ein, dass hinter TDD noch mehr als bei reinen Unit-Tests steckt, da sich der Entwickler vor der eigentlichen Implementierung grundlegende Gedanken zur Softwarestruktur machen muss. Ich möchte die beiden Begriffe schon nicht vermischen.

Die Wirksamkeit von Software Tests auf die Qualitätsverbesserung des Produkts ist unbestritten. Technische Artikel und Beispiele gibt es zu Hauf. Aber das Wurzelproblem ist ein Organisatorisches sowie auch eine Ideologiefrage.

Das schreiben von Test bedeutet einen Mehraufwand und dieser kostet Geld. Der Kunde erwartet maximale Qualität zu minimalen Preisen. Die Entwickler (CleanCoders & Co.) wollen möglichst qualitativer Code und gutes Handwerk leisten. Eingeklemmt dazwischen ist die Geschäftsleitung, welche schon lange keinen richtigen Code mehr geschrieben hat und sich mehr um den Markt und um die Kunden als um Entwicklungsdetails kümmert. Und genau dieser Punkt leifert Zündstoff für Disskusionen, in denen die Geschäftsleitung das letzte Wort hat.

Und gerade hierzu wünsche ich mir mehr Artikel von den Technologie-Heads in der dotnetpor und in Blogs :-)

@Ralf: Ich fände es super wenn du bei uns vorbeikommen würdest - und ich bin mir sicher, dass du mir viele interessante Ansätze zeigen könntest - aber leider wird mein Cheff das nicht bezahlen wollen – garantiert nicht.

Ralf Westphal - One Man Think Tank hat gesagt…

@Lukas: Du triffst den Nagel auf den Kopf: TDD ist kein technisches Problem. TDD leidet auch nicht unter "Mangel an Beweisen".

TDDs Problem ist nur Disziplin und organisatorischer Wille.

Die Disziplin muss jeder Einzelne jeden Tag aufbringen. Helfen kann aber natürlich auch eine Team-Disziplin (Stichwort Reviews).

Am Ende ist der Einzelne aber nichts und das Unternehmensmilieu alles. Eine gewisse Zeit lang kann man mit TDD unter dem Radar fliegen. Am Ende will man aber die Unterstützung der Führung.

Wenn die uneinsichtig ist... was tun? Ich schlage vor, es dann mit Verhandeln zu probieren. "Chef, lass uns das in diesem Teil der Software für diesen Zeitraum versuchen - und dann schauen wir, was uns das bringt." Sichergestallt muss dann allerdings sein, dass dieser geschützte Raum auch respektiert wird. Und in diesem Raum muss kompetent gearbeitet werden. TDD schlecht zu tun und damit dem Chef Angriffsfläche zu bieten, ist kontraproduktiv.

Überlege also, wie du beides herstellen kannst: TDD-Raum und Kompetenz.

-Ralf

Kommentar veröffentlichen

Hinweis: Nur ein Mitglied dieses Blogs kann Kommentare posten.