Follow my new blog

Posts mit dem Label Testen werden angezeigt. Alle Posts anzeigen
Posts mit dem Label Testen werden angezeigt. Alle Posts anzeigen

Montag, 10. März 2014

Attrappen gestütztes Nachdenken

Wie viel Design vor dem Codieren darf es denn sein? Diese Frage erhitzt immer wieder die Gemüter. Neulich auch das von Robert C. Martin. Der reagierte nämlich sehr barsch auf einen Blogartikel von Justin Searl.

Martin sieht nun einen Unterschied in der Notwendigkeit zu entwerfen zwischen größeren Strukturen und kleineren. Für die einen sollte man explizit entwerfen, für die anderen sich durch TDD treiben lassen. Ich allerdings erlaube mir anderer Meinung zu sein. Für mich gibt es keine wie von Martin beschriebene Diskontinuität. Vielmehr sehe ich Software als selbstähnliche Struktur, die deshalb auf allen Ebenen auch ähnlich behandelt werden will. Ausführlicher erkläre ich das ein einem englischen Blogartikel.

Damit sei das Thema erledigt – doch dann ließ mich der Artikel von Searl nicht recht los. Der beschreibt nämlich einen TDD-Ansatz, den Searl für mindestens didaktisch günstiger hält als “den traditionellen”.

Schon beim ersten Lesen flog mich da eine Ähnlichkeit zu Flow-Design an, doch ich konnte sie noch nicht recht greifen. Als mir jedoch die Mock-Frameworks TypeMock Isolator und JustMock einfielen, habe ich mich darangesetzt, und Searls Vorschlag ausprobiert. Das Ergebnis hat mir sehr gefallen.

Schon lange setze ich keine Mock-Frameworks mehr ein, weil die Zahl der Attrappen zum Test meiner Flow-Designs klein ist. Für noch ausgefeiltere Mock-Frameworks wie die beiden hatte ich deshalb schon gar keinen Bedarf. Doch jetzt finde ich sie sehr nützlich. Lange hat das Profiler-gestützte Mocking auf einen Einsatzzweck bei mir gewartet, nun ist er da: mit JustMock kann ich Flow-Designs schneller in Code übersetzen – und das top-down.

Wie ich mir das denke, habe ich in einem längeren englischen Blogartikel beschrieben. Der ist als Dialog abgefasst, weil er eine Herangehensweise an TDD beschreibt – wie weiland Martin es getan hat.

image

Den Dialog zu schreiben, hat Spaß gemacht. Das war mal ein ganz anderes Format als sonst. Doch, ja, ich gebe zu, er ist ein bisschen lang und gewunden geworden. Jedenfalls für den normalen Leser im Web, der schnell, schnell Informationen sammeln will.

Deshalb habe ich das Vorgehen in einem englischen Folgeartikel nochmal systematischer beschrieben. “Informed TDD” habe ich diese Herangehensweise genannt, weil hier TDD nicht “blind” aufgrund von Testfällen betrieben wird, sondern geleitet durch ein entwerfendes Nachdenken.

image

Ich verstehe, wenn manchem der Entwurf am Flipchart zu theoretisch ist. Zwar halte ich das für eine Gefühl, das sich mit etwas Übung überkommen lässt – doch erstmal ist es halt so. Mit “Informed TDD” kann hier jedoch schneller abgeholfen werden: Das Nachdenken kann kürzer ausfallen, weil es das Problem zunächst weniger tief durchdringen muss. Schneller kann man zur Tastatur greifen und schonmal Code schreiben, der das Nachdenken verifiziert. Schrittweise werden die durch “hartes TDD” zu lösenden Probleme auf diese Weise kleiner.

Attrappen stützen also das Nachdenken. Und das ganz modern ohne spezielle Vorkehrungen für eine Injektion von Funktionalität.

Dienstag, 28. Mai 2013

JavaScript Unit Testing mit WebStorm Schritt für Schritt

JavaScript tut Not. Es hilft halt nichts. Lieber würde ich mich zwar mehr mit F# beschäftigen, doch dringender scheint mir JavaScript-Fingerfertigkeit. Denn mit JavaScript kann ich meine Reichweite vergrößern in Richtung Web und Mobile. Bisher bin ich ja eher der “Desktop GUI Guy” ;-) Und mit JavaScript kann ich coole Entwicklungen schneller mitmachen im Bereich Backend, z.B. node.js oder Cloud APIs. .NET Bindings werden da ja eher stiefmütterlich behandelt.

F# brächte mir zwar Vorteile bei der Strukturierung von Geschäftslogik. Doch da bin ich weniger Unzufrieden mit C# als bei Reichweite und Modernität.

Also mehr JavaScript. Hier ein bisschen, da ein bisschen. Vor allem aber auch gern testgetrieben.

Als Entwicklungsumgebung habe ich mir mal WebStorm von JetBrains angeschafft. Das war sehr preisgünstig beim letzten Cyber Monday und schien ordentlich.

Damit geht auch testgetriebene Entwicklung – nur ist die nicht so einfach zu starten, wie bei VS mit ReSharper. Deshalb hier ein Spickzettel zunächst einmal für mich selbst:

1. Verzeichnisse einrichten: In einem WebStorm Projekt zwei Verzeichnisse einrichten, eines für den Produktionscode, eines für Testcode.

image

Wie die Verzeichnisse heißen, ist eigentlich egal. Ihre Namen müssen nur korrekt im nächsten Schritt referenziert werden.

2. Konfigurationsdatei angelegen: Bei .NET findet ein Testrunner die Tests automatisch in den Assemblies eines Projektes. Bei JavaScript muss man dafür jedoch eine Konfigurationsdatei anlegen. (Zumindest für den JavaScript Test-Driver, der mit WebStorm ausgeliefert wird.) Der Name der Datei ist nicht so wichtig, die Extension muss allerdings .jstd sein:

image

Die Konfiguration ist simpel: Sie enthält die Adresse des Testrunners, der in einem Browser läuft. Und sie listet die Quellen für Produktionscode (load:) und Tests (test:):

server: http://localhost:9876

load:
  - src/*.js

test:
  - tests/*.js

Die Quellen nehmen Bezug auf die oben angelegten Verzeichnisse.

3. Tests werden im Testverzeichnis angelegt. Am besten zur weiteren Konfiguration des Testframeworks einen Probeweisen Test anlegen. Im ersten Schritt sieht der nur so aus:

image

Wichtig ist, dass angeboten wird, die Bibliotheksdateien des Testframework zu laden. Das sollte mit dem Shortcut getan werden. Dann sieht das Projekt so aus:

image

Durch die Referenzierung dieser Dateien steht für Tests Intellisense zur Verfügung:

image

Achtung: Tests müssen den Präfix “Test” haben!

4. Leider läuft der Probetest jetzt noch nicht einfach so. Es muss erst noch eine Laufzeitkonfiguration für das Projekt angelegt werden:

image

image

image

Hier wird die .jstd-Datei referenziert! Und der Name der Konfiguration taucht dann im Run-Menü auf:

image

5. Jetzt den Probetest ausführen lassen. Falls das nicht funktioniert, läuft der Testserver wahrscheinlich nicht. Der wird im Browser gehostet. Gestartet werden kann er über die IDE:

image

image

Durch Klick auf ein Browser-Icon wird der Testrunner-Server als Seite im Browser geöffnet:

image

Und nun – Wunder der Technik! – wird auch der Test über Run ausgeführt. Die IDE erstrahlt in frischem Grün:

image

 

Das war´s. Jetzt, da ich das Setup nochmal Schritt für Schritt durchlaufen bin, ist es gar nicht mehr so undurchsichtig. Aber wer weiß… Wenn ich mal wieder längere Zeit JavaScript nicht in die Hand nehmen sollte, hilft mir diese Erläuterung bestimmt, schneller wieder reinzukommen.

Und es bewahrheitet sich der alte Spruch, dass man durch Lehren, also Erklären, am besten lernt.

Sonntag, 24. Februar 2013

TDD ohne Zauberei und Überraschung

TDD bleibt für mich aktuell, auch wenn es ein alter Hut ist. Das liegt einfach daran, dass TDD landauf, landab nicht das liefert, was es verspricht. Wenn gewöhnliche Entwickler nur nach monatelangem Studium in Klausur die TDD-Weihen empfangen können, dann liegt etwas im Argen.

In der dotnetpro stelle ich daher meine Gedanken zu einer Version 2.0 von TDD vor (Ausgaben 3/2013 und 4/2013). Aber auch hier im Blog habe ich dazu schon einiges gesagt, z.B. dass TDD in der heutigen Form für mich das Single Responsibility Principle verletzt. Anlass war für mich ein Video von Corey Haines.

Da war dann insbesondere ein Kommentator zu dem Blogartikel anderer Meinung. Und der hat auf ein aus seiner Sicht vorbildliches Beispiel verwiesen: Brett Schucherts Demonstration der TDD-Implementation eines RPN-Rechners.

Leider, leider hat mich die Demonstration auch wieder enttäuscht. Und wieder aus den selben Gründen wie bei Corey Haines:

  1. Es gibt keine Erklärung des Problems.
  2. Es gibt keine Sammlung und Priorisierung von Testfällen.
  3. Lösung und Implementation sind nicht von einander getrennt. Damit fällt die Lösung während des Codes quasi vom Himmel.

Zumindest Punkt 2 widerspricht ausdrücklich jeder TDD-Empfehlung, würde ich sagen. Deshalb kann die Demonstration nicht vorbildlich sein.

Und Punkt 3 macht die Demonstration zu einem “magischen” Event, der suggeriert, so könne es jeder Entwickler: Die Lösung einfach kommen lassen. Das wird schon.

Dabei haben sich alle, die TDD so demonstrieren, selbstverständlich über das Problem und auch ihren Lösungsansatz vorher ausführlich Gedanken gemacht. Entweder, indem sie sich hingesetzt und überlegt haben oder indem sie das Szenario einige Male ohne zu überlegen implementiert haben.

Ich kenne keine (!) TDD-Demonstration, bei der die Aufgabe live gestellt wurde und dem Demonstraten vorher unbekannt war. (Falls jemand jedoch zu so etwas einen Link hat, möge er ihn bitte in einem Kommentar hier hinterlassen.)

Also: TDD-Demos sind heutzutage weitgehend unrealistisch. Damit suggerieren sie, dass die TDD-Schritte red-green-refactor es allein richten. Das halte ich für mindestens fahrlässig, weil es ganz sicher in große Frustration bei vielen Entwicklern führt.

Und wie sollte dann eine TDD-Demo aussehen?

Ich lehne mich mal aus dem Fenster und behaupte: So wie im Folgenden.

Dafür nehme ich das Beispiel von Brett Schuchert auf: den RPN Rechner. Das Problem ist mir damit zwar auch nicht unbekannt, aber ich habe versucht, mein Vorwissen so weit wie möglich zurückzunehmen. Allemal esse ich mein eigenes Hundefutter und gehe das Problem für alle sichtbar nach den von mir vorgeschlagenen TDD 2.0 Schritten an.

image

Los geht´s…

Was ist eigentlich das Problem?

“Without requirements or design, programming is the art of adding bugs to an empty text file.” - Louis Srygley

Ich habe mir Brett Schucherts Lösung gar nicht ausführlich angeschaut. Seine Videos sind über 2 Stunden lang; er hat sich sehr viel Mühe gegeben. Außerdem wollte ich mich nicht für meine eigene Durchführung “kontaminieren”. Trotzdem habe ich natürlich beim Überfliegen Eindrücke gewonnen.

Mein erster Eindruck: Brett sagt niemandem, was das Problem eigentlich genau ist. Weder zeigt er am Anfang mal ein Beispiel für einen RPN Rechner, noch sagt er, was das Ziel seiner Entwicklungsarbeit ist. Er mokelt vielmehr 51 Minuten im ersten Video an einem API herum, von dem niemand so recht weiß, warum er so aussieht, wie er aussieht.

Wie kann man es besser machen?

Man macht erstmal eine Skizze vom Problem. Was sind die Anforderungen? Man versucht das Problem zu verstehen. Hier die erste Doppelseite meiner Analyseskizze:

image

Auf der linken Seite ist oben ein UI angedeutet und unten eine Folge von Eingaben mit zugehörigen Ausgaben; so habe ich mir die Funktionsweise eines RPN Rechners vorgestellt. (Dass Sie meine Schrift nicht lesen können, ist quasi unvermeidlich ;-) Aber das ist auch nicht nötig. Ich möchte Ihnen mit den Skizzenblättern nur einen groben Eindruck von meinem Vorgehen-/Denken vermitteln.)

imageIn der Mitte der linken Seite sehen Sie allerdings eine Verirrung. Da lag ich falsch mit meiner Vorstellung. Erst ein Blick auf den Taschenrechner von Mac OS X im RPN Modus hat mich eines Besseren belehrt. Das habe ich dann unten links und auch noch rechts ganz oben korrigiert.

Nach ca. 5-10 Minuten “Scribbeln” und mit dem RPN Rechner herumspielen war mir das Problem klar. Dafür musste ich aber auch ein Anwendungsszenario im Blick haben. Deshalb findet sich links oben zuerst eine UI-Skizze. Nur wenn ich weiß, wie ein Benutzer wirklich mit einem RPN Rechner umgehen will, sollte ich mir Gedanken zu einem darunter liegenden API machen.

Zu oft entwickeln wir einfach im luftleeren Raum, ohne Kontext. Wir imaginieren dann eine ganze Menge, was alles nötig sein könnte. Wir setzen dann ganz schnell eine technische Brille auf – und verlieren den Benutzer aus dem Blick. Damit ist der Verschwendung Tür und Tor geöffnet. Wir basteln dann nach den Regeln der Kunst an Zeugs herum, das nur wenig Realitätsbezug hat.

Das halte ich für einen großen Übelstand. Da werden unbewusst unökonomische Muster eingeschliffen. Deshalb finde ich es bei jeder Übung wichtig, möglichst konkret und benutzerbezogen zu sein. Nur so üben wir uns auch ständig in agilem Denken.

Lösungsansatz formulieren

“First, solve the problem. Then, write the code.” - John Johnson

Erst nachdem ich ein Verständnis für das Problem entwickelt hatte, konnte ich mich daran machen, über eine Lösung nachzudenken. Genau: nachdenken. Ich habe also nicht Visual Studio angeworfen, um mit dem Codieren anzufangen.

Erstens ist nichts einschnürender als Code. Zweitens wäre ich mit der Arbeit an den Rechner gekettet gewesen.

Mit meinem Notizbüchlein konnte ich jedoch in der S-Bahn weiter über die Lösung nachdenken. Und zwar so konkret, dass die Codierung für mich hinterher ganz leicht war [1].

Das Ergebnis sind die rechte Seite im oberen Bild und die beiden Seiten im nächsten Bild.

Im ersten Bild rechts sehen Sie mein Flow-Design. Rechts oben der big picture Flow mit allen Interaktionen des einzigen Dialogs der Anwendung. Das sind Enter (Zahl eingeben), Drop (Zahl vom Stack entfernen) und Operator auslösen.

Alle Domänenlogik fasse ich in einer EBC-Funktionseinheit zusammen: dem RPN Calculator.

Unten auf der rechten Seite im ersten Bild sehen Sie dann eine Verfeinerung der Interaktion, die über einen Operator ausgelöst wird. Da habe ich mir klar gemacht, was mit dem Operator und der aktuellen Zahl im “Rechenwerk” passiert, wenn denn etwas berechnet werden soll.

Bis hierhin hat es ca. weitere 7 Minuten gedauert. Nach runden 15 Minuten hatte ich also nicht nur das Problem verstanden, sondern auch einen Lösungsansatz. Dessen Kern war erstens ein Stack für die Zahlen, die auch im UI zu sehen sind, also die Operanden. Und zweitens gab es die Vorstellung eines Verzeichnisses von Operationen auf diesen Zahlen. Die ergab sich ganz natürlich aus der Verallgemeinerung der Interaktion des Benutzers in Bezug auf die Operatoren. Nicht jeder Operator für sich war für mich eine Interaktion, sondern sie alle sollten mit einer Interaktion abgehandelt werden, die mit dem konkreten Operator parametriert sein sollte [2].

Testfälle sammeln

Nach der Modellierung kannte ich den API des RPN Rechners. Ich musste mir nichts aus den Fingern saugen wie Brett. Dessen get/set für einen Akku und die Enter-Methode finde ich gänzlich unnatürlich. Er versucht da etwas 1:1 in einen API zu übernehmen, das er (ohne es uns wissen zu lassen) von einem UI abgeschaut hat. Aber warum sollte in einem API ein Repräsentant einer UI-Design-Entscheidung stehen? Dass es dort einen Enter-Button gibt, kann sich doch morgen ändern.

Deshalb gibt es bei mir keinen sichtbaren Akku und auch keine Enter-Methode, sondern ein Push() und ein immer gleiches Resultat nach jeder Aktion. Push() abstrahiert von jedem UI. Es sagt vielmehr etwas über den Lösungsansatz für den RPN Rechner aus, dass es darin nämlich einen Stack gibt. Das ist echte Domänenlogik. Die abstrahiert von UI-Eigenheiten.

Mit dem Entwurf für den RPN Rechner konnte ich dann konkret über Testfälle nachdenken. Die sehen Sie auf der linken Seite der nächsten Abbildung:

image

Für die drei Methoden meines API gibt es drei Gruppen von Testfällen, die ich als Tabellen notiert habe. Etwas schöner sieht so eine Tabelle natürlich in Excel aus. Sie ist der Reinschrift meiner Skizzen für diese Dokumentation entnommen.

image

Weil man es nicht häufig genug sagen kann: Über Testfälle nachdenken und sie priorisieren kann man nur und ausschließlich, wenn man einen Lösungsansatz hat, zu dem ein API gehört.

Wer TDD vorführt, ohne Testfälle zuerst zu benennen, und wer nicht erklärt/erklären kann, warum deren Reihenfolge so ist, wie sie ist, der führt TDD falsch vor.

Wo Testfälle vom Himmel fallen oder sich überraschend ergeben, ist TDD magisch und suggeriert Einfachheit, die nicht vorhanden ist.

Was ist meine Erklärung?

  • Meine Testfälle gliedern sich von vornherein nach API-Methoden. Da gibt es keine Überraschung. (Was nicht heißt, dass ich mich Änderungen während der Implementation verschließe. Da kann es immer neue Erkenntnisse geben. Aber ich darf mit einer Idee beginnen.)
  • Innerhalb der Testfälle für eine API-Methode gibt es eine klare Aufteilung zwischen Eingaben und zugehörigen erwarteten Ausgaben.
  • Aus den Inputs kann nicht jeder Output erklärt werden. Der RPN Rechner hat einen Zustand. Der muss ebenfalls in die Testfälle eingehen.
  • Die Testfälle wachsen in Richtung zunehmendem Zustand, da der Input immer dieselbe Form hat.

Mein TDD wie unten zu sehen, ist also nicht magisch und nicht überraschend, sondern ganz handfest. Es folgt einem Plan, der durch Überlegen entstanden ist. Das hat maximal 5 Minuten gedauert.

Wem dieses Überlegen schon gleich zu viel sein sollte, wer das schon im Widerspruch zum rechten TDD sieht… Nun, dem habe ich eben nichts zu sagen. Wir leben dann auf verschiedenen Planeten. Das ist ok. Ich freue mich aber über jeden, der sich drauf einlässt. Dann können wir auch trefflich darüber debattieren, ob nicht der eine Testfall früher oder später liegen sollte oder der eine Schritt mehr oder weniger KISS ist.

Inkrementell vorgehen

Auch TDD tut gut daran, an den Kunden zu denken. Brett hat da allerdings niemanden im Blick. Er startet auch ohne UI. Das ist aus meiner Sicht aber kein Grund, nicht agil/inkrementell vorzugehen. Auch Entwickler, die einen API nutzen sollen, sind Kunden. Die Frage lautet deshalb für mich immer: Wie kann ich möglichst schnell einen kleinen Nutzenzuwachs bieten? Welchen Schritt kann ich tun, um etwas von Wert herzustellen, zu dem ein Kunde/Anwender Feedback geben kann?

Deshalb habe ich einen Moment darauf verschwendet, die rechte Seite im zweiten Skizzenbild zu füllen. Dort sehen Sie fünf Inkremente angedeutet, die sich an den Interaktionen des Dialogs orientieren.

  • Inkrement #1: Der Anwender kann Zahlen auf den Stack schieben, die Operanden. Das entspricht Push() auf dem RPN Calculator. Dazu kann der Anwender schon mal Feedback geben.
  • Inkrement #2: Der Anwender kann die aktuelle Zahl zum Stack-Top addieren. Jetzt ist der RPN Rechner schon ein bisschen nützlich, auch wenn er nur eine Operation beherrscht.
  • Inkrement #3: Der Anwender kann Zahlen vom Stack entfernen. Das entspricht Drop() auf dem RPN Calculator.
    Für diese Funktionalität in Inkrement #3 habe ich mich nur in Anlehnung an Bretts Demonstration entschieden. Er hat Drop() sogar noch vor der ersten Operation realisiert. Da wollte ich mich nicht lumpen lassen ;-)
    Ohne seine Vorlage hätte ich jetzt mit Operationen weitergemacht.
  • Inkrement #4 und #5: Jetzt endlich weitere Operationen. –, *, / als binäre Operationen und ! als unäre.

Damit fiel es mir leichter, mich bei der TDD-getriebenen Implementation zu konzentrieren. Den Preis von weiteren 3 Minuten habe ich dafür gern gezahlt. Denn ich konnte sicher sein, selbst wenn ich bei der Implementation unterbrochen werde – was sehr wahrscheinlich ist –, habe ich immer etwas in der Tasche. Nach jedem Inkrement kann ich den Griffel fallen lassen und der Kunde hat schon etwas in der Hand; zwischen Inkrementen liegen Sollbruchstellen der Implementation.

Implementation nach red+green+refactor

“Programs must be written for people to read, and only incidentally for machines to execute.” - Abelson / Sussman

Auch wenn ich nach meinen Überlegungen gar nicht mehr so viel Lust hatte zu implementieren – ich hatte den Eindruck, das Spannendste schon erledigt zu haben: die Problemlösung –, habe ich mich natürlich an Visual Studio gesetzt.

Das folgende Script zeigt die Schrittfolge meiner Codierung des RPNCalculator-Klasse. Das UI und die Integration von UI und RPNCalculator habe ich ausgelassen. Die sind nicht so spannend.

NUnit Tests für das UI gibt es – aber die sind als Explicit markiert. Sie dienen nur der Überprüfung, ob die Events korrekt gefeuert werden bzw. die Anzeige des Resultats korrekt erfolgt. Das ist kein Hexenwerk, ändert sich nicht häufig und kann auch mal manuell getestet werden, falls nötig.

Die Musik spielt in der Domänenklasse RPNCalculator. Darauf liegt auch bei Brett Schuchert das Augenmerk. Also los…

RPN Desktop Calculator TDD by Ralf Westphal

Wenn Sie sich die Schrittfolge näher ansehen, stutzen Sie vielleicht hier und da. Nicht alles mag in Ihren Augen TDD der reinen Lehre sein. Die strebe ich aber auch nicht an. Mir geht es um pragmatisches, realistisches TDD.

Dennoch hier ein paar Erklärungen, die Sie besänftigen mögen:

  • Bei Schritt 2.1 sehen Sie, dass ich über einen Konstruktor in das System under Test (SUT) Zustand injizieren will [3]. Ja, da bin ich ganz schamlos. Das mache ich einfach. Ich weiß, dass das SUT Zustand hat. Warum soll ich den nicht explizit setzen? Das spart mir u.U. eine Menge Verrenkungen, um mit anderen API-Aufrufen an einen Punkt im Test zu kommen, wo ich endlich das überprüfen kann, was gerade Thema ist.
  • In Schritt 3.2 habe ich mich hinreißen lassen, wider besseren Entwurfswissens eine ganz einfache Implementation zu wählen. Die arbeitet schon irgendwie richtig, aber sie entspricht nicht dem, was Ziel sein muss: eine Auswahl der Operation aus einer Liste. Ich verdrahte die Addition fest.
    Das habe ich gemacht, um hier die TDD-KISS-Gemüter zu beruhigen ;-) Im richtigen Leben hätte ich mir erlaubt, schon die Struktur zu implementieren, die Sie nun erst in 6.1 eingeführt sehen.
  • Zu meiner eigenen Überraschung hat der Test in Schritt 3 (leider irrtümlich so benannt) gleich grün geliefert. Das hätte eigentlich anders sein sollen – aber es zeigt, dass auch ein Nachdenken über Testfälle in endlicher Zeit nicht perfekt ist.
    Dafür will ich mich nicht schelten. Kann halt passieren. Macht nichts.
    Da muss ich mich nächstes Mal nicht noch doller anstrengen, um bessere Testfälle zu finden. Ich nehme es einfach so und freue mich, dass ich etwas Implementationsaufwand spare.
  • Bei der Implementation zu 4.2 könnten Sie einwerfen, dass die nicht KISS sei. Das mag sein – aber: WTF. Das ist mir egal. Ich weiß doch schon, wie Resultate aus dem RPNCalculator geliefert werden. Warum soll ich mich dümmer stellen, als ich bin?
  • Ha, jetzt haben Sie mich: Nach der Implementation von 5.1 ist ein anderer Test auf Rot gegangen. Das darf doch nicht sein!
    Ja, das mag gegen die reine Lehre verstoßen. Aber auch hier: WTF. Das kann mal passieren. Ich finde das nicht schlimm. Es ist eher ein Zeichen dafür, dass die Implementation in 5.1 KISS ist. Sie konzentriert sich nur darauf, einen Test grün zu bekommen.
    Mit einer kleinen Nachbesserung zurre ich die Regression dann in 5.2 wieder fest. Es ist kein größerer Schaden entstanden.
  • Nun Trommelwirbel… Jetzt zum Kern der Domänenlogik: der leicht erweiterbaren Liste von Operationen. Auf die hatte ich in 3.2 noch verzichtet – doch nun kann ich nicht mehr an mich halten.
    In einem Rutsch refaktorisiere ich die bisherige Addition und füge auch noch die Fakultät hinzu. Ja, bin ich denn wahsinnig geworden?
    Nein, ich finde das halb so wild. Es sind ein paar Änderungen, die ich da vornehme – aber sie sind allesamt trivial. Und wenn dabei etwas verrutschen sollte, sehe ich sofort, wo das Problem liegt.
    Geht gar nichts mehr, dann ist etwas mit Auswahl und Aufruf der Operationen falsch.
    Schlagen nur die Additionstests fehl, dann habe ich die Addition falsch implementiert.
    Schlägt nur der Fakultätstest fehl, dann ist dort etwas falsch implementiert.
    Der neue Inhalt von Calculate() ist auch nicht spontan entstanden, sondern steht schon im erste Skizzenbild rechts unten. Ich lese die Implementation quasi nur ab.
  • Für die weiteren Operationen gehe ich eine Abkürzung in 7.*. Ich stecke die Testfälle in TestCase-Attribute und füge dem _operations-Verzeichnis einfach nur kurze Lambda-Ausdrücke hinzu. Das Muster ist immer gleich. Die Addition hat es vorgemacht.

That´s it. Ich denke, damit habe ich eine Lösung geliefert, die nicht nur funktioniert, sondern auch Martin Fowlers Forderung erfüllt:

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” - Martin Fowler 

Nicht nur den Code kann man verstehen. Der ist ja denkbar kurz. Er ist auch erweiterbar – und zwar von vornherein und nicht erst nach einem überraschenden Refactoring. Er war ja schon so gedacht. Wer einen weiteren Operator hinzufügen will, der setzt in das UI einen Button dafür und trägt eine Operation in das Verzeichnis ein. Fertig.

Eine Injektion von Operationen von außen oder auch nur eine eigene Klasse halte ich derzeit allerdings für überflüssig. Eine Refaktorisierung in dieser Hinsicht wäre für mich vorzeitige Optimierung. Die Anforderung dafür ist derzeit nicht aus dem Gesamtszenario ablesbar. Und einfach nur Prinzipien anwenden, weil ich sie gerade kenne, ist mir nicht Grund genug.

Nicht nur der Code ist aber verständlich, die ganze Lösung ist es. Weil es dazu ein Modell gibt und der Code dieses Modell widerspiegelt. Dazu gehört natürlich auch noch die Integration, die ich hier bewusst ausgelassen habe, weil ich mich auf TDD konzentrieren wollte. Die finden Sie bei Interesse jedoch im Repository.

Fazit

Bubbles don´t crash – das ist wahr. Genauso kann man sagen, Landkarten seien nicht das Terrain. Ja, und? Sind Landkarten deshalb nutzlos? Kaum. Genauso wenig sind Bubbles, d.h. Entwürfe auf dem Papier nutzlos oder verschwendete Zeit, nur weil sie noch kein Code sind. Das ist ja gerade ihr Zweck. Sie sollen kein Code sein, damit man mit ihnen viel schneller vorankommt bei der Lösungsfindung.

Wo Code ins Spiel kommt, wird es morastig. Wenn ich also über eine Lösung sinnen kann, ohne codieren zu müssen, kann ich mich nur freuen. Die ersten zwei Bilder haben gezeigt, dass ich das kann. Die spätere Implementation hat mich keines Besseren belehrt. Dennoch ist der Lösungsansatz auf Papier natürlich zunächst nur eine Hypothese. Das macht aber nichts. Im Gegenteil: Es geht gar nicht anders. Auch der Lösungsansatz, den Sie ohne Papier nur im Kopf während des TDD Code Slinging entwickeln, ist nur eine Hypothese.

Wenn ich Ihnen hier Skizzen und Nachdenken präsentiert habe, dann nur, um etwas explizit zu machen, das sich nicht einmal vermeiden lässt. Corey Haines tut es, Brett Schuchert tut es, Sie tun es, wir alle tun es – fragt sich nur wann und wie nachvollziehbar.

Ich finde meine Lösung insgesamt viel nachvollziehbarer als die der zitierten Herren. Da steckt keine Magie drin. Ich bin nicht (künstlich) überrascht über Testfälle, die sich auftun. Meine kleinen Überraschungen sehen Sie klar dokumentiert in der TDD-Schrittfolge und im Vergleich zu meinen Skizzen. Das ist real und nicht poliert.

Wenn Sie ein Guru sind, kommen Sie natürlich ohne all das aus. Ich kann das leider noch nicht. Ich muss noch nachdenken – und dann tue ich das gern in Ruhe. Nicht immer, aber allermeistens. Ab und an klappe ich Visual Studio auch schneller auf. Doch dann meist, um im Rahmen meiner Lösungsfindung etwas zu explorieren. Dann bin ich jedoch in einem anderen Modus, dann bin ich Forscher und nicht Ingenieur.

  • Forscher finden heraus, was ist. Sie dokumentieren Zusammenhänge.
  • Ingenieure nehmen die Ergebnisse von Forschern und finden sehr kreativ Problemlösungen. Sie schaffen Neues.
  • Handwerker schließlich wenden Lösungen vor allem an. Sie setzen um, reproduzieren.

Das sind drei zentrale Rollen, deren Hüte wir bewusst wechseln sollten. Wenn wir es nicht tun, riskieren wir Frust oder Schlimmeres.

Durch die bewusste Herausstellung von Problemanalyse und Lösungsfindung/Entwurf möchte ich den Rollen Forscher und Ingenieur Raum geben. Wir müssen Probleme zunächst erforschen, dann müssen wir mit den Heuristiken und Technologien unseres Entwicklerwerkzeugkastens state-of-the-art Lösungen entwickeln – und erst am Ende setzen wir die handwerklich sauber mit TDD um.

Alle drei Rollen nur in den red+green+refactor-Phasen innehaben zu wollen, tut uns nicht gut und dem Ergebnis auch nicht. Und letztlich tut es auch der guten Idee hinter TDD nicht gut.

Bottom line: TDD ist eine feine Sache – wenn man die nicht magisch betreibt und überlastet.

Endnoten

[1] Ja, ich will es geradeheraus sagen: Das TDD-Vorgehen, welches ich hier zeige, weil ich mich nicht lumpen lassen wollte, hat mich am Ende auch langsam gemacht. Die kleinsten Schritte waren für mich nicht wirklich nötig. Ich hätte größere machen können – und hätte nur in eine kleinschrittigere Gangart zurückgewechselt, wenn ich in ein Problem gelaufen wäre.

Das ist natürlich gegen die reine TDD-Lehre… Deshalb hab ich mich auch nicht hinreißen lassen… Doch es ist für mich ein realistischeres Vorgehen.

TDD-Kleinstschritte sollten kein Dogma sein, sondern eine Methode unter bestimmten Bedingungen. Und diese Bedingungen sind, dass man eben noch nichts über die Lösung weiß. Das tue ich ja aber, indem ich vorher darüber nachdenke.

Und dass das nicht nur eitle, nutzlose Gedanken sind, die nichts mit der Coderealität zu tun haben, dass ich mir so gar nichts vor dem Codieren vorstellen könne… Das versuche man mir bitte nicht einzureden. Irgendetwas müssen ein paar Jahrzehnte Softwareentwicklungserfahrung doch in meinem Hirn hinterlassen haben, oder?

Wichtig sind nicht stets und ausschließlich die allerkleinsten Schritte, sondern ein gesundes Gefühl dafür, ob man sich noch in bekannten Gefilden mit dem Code bewegt. Je unbekannter, desto kleiner die Schritte. Selbstverständlich. Aber Lösungen lassen sich eben auch noch auf andere Weise als durch Codieren erkunden.

Falls dabei mal etwas herauskommen sollte, das nicht KISS ist, dann finde ich das nicht schlimm. Ich habe in den Fall nämlich sehr wahrscheinlich Zeit gespart. Das kann mehr Wert haben als KISS-Code – solange die Verständlichkeit nicht grundsätzlich leidet.

[2] Für Brett Schuchert ist das eine überraschende Erkenntnis, auf die er nach knapp einer Stunde Codieren stößt. Er muss dafür seine Implementation weitere 20 Minuten refaktorisieren.

Das halte ich für unökonomisch, wenn ich durch 5 Minuten Nachdenken gleich darauf kommen kann, dass Operationen nicht durch einzelne API-Methoden repräsentiert werden sollten.

Ich halte es für keine Tugend, sich solcher Erkenntnis mit Macht zu widersetzen. Und es ist auch keine Tugend, nicht nach Wegen zu suchen, um solche Erkenntnis möglichst früh zu gewinnen.

[3] Eigentlich soll dieser spezielle Konstruktor internal sein. Das hab ich im TDD-Eifer übersehen. Sorry. Bei dem jetzigen Codereview für den Blogartikel kommt es ja aber heraus :-)

Dienstag, 18. Dezember 2012

Die TDD Single Responsibility

Gerade wird wieder eine TDD Demo über Twitter herumgereicht. Corey Haines hat sich an die Kata Roman Numerals gemacht.

image

Mal abgesehen davon, dass TDD anscheinend ein unerschöpfliches Thema ist und die Katas auf die Dauer langweilig werden… Mir gefällt die Darstellung aus einem anderen Grunde nicht so gut.

Corey gibt sich Mühe. Alles läuft seinen kanonischen TDD Weg. Es könnte ein schönes Video sein. Wäre da nicht die ständige Überraschung.

Ja, es hört sich so an, als würde Corey in die Lösung des Problems “Übersetzung arabischer Zahlen in römische” stolpern. Er zaubert Testfälle aus dem Hut und erstaunt sich dann immer wieder selbst mit der Lösung.

Und ich meine hier wirklich die Lösung und nicht den Code.

Da scheint mir ein Grundproblem im Umgang mit TDD zu liegen. Das habe ich auch neulich auf den XPdays in Coding Dojos gesehen. Das Muster ist so:

  1. Es wird ein Problem vorgestellt.
  2. Es wird mit dem Codieren à la TDD begonnen.

Das Ergebnis? Regelmäßig kein Code, der die Aufgabe vollständig erfüllt [1].

Mit dieser Realität sollten wir nicht streiten, denke ich. So ist es einfach. Man bemüht sich redlich um die rechte TDD-Schrittfolge. Das kommt mir vor wie beim Tanzen. Alle starren gebannt auf ihre Füße und hoffen, niemanden anzurempeln. Nur leider geht dabei das große Ganze verloren. Beim Tanzen der Spaß an der Bewegung und am Miteinander. Und bei der Softwareentwicklung lauffähiger Code. Vor lauter TDD-Rhythmus und versuchtem Design funktioniert es nicht mal.

Wie frustrierend, wie tragisch. Kein Wunder, dass auch 2012 immer noch TDD hoch und runter evangelisiert werden muss.

Dabei scheint mir die Rettung der Situation einfach: mehr Systematik.

Ja, tut mir leid, dass ich mit so einem lästigen Wort komme. Das klingt nach Einschränkung, nach viel Aufwand ohne schnellen Nutzen… doch das Gegenteil ist der Fall. Systematik macht frei. Aus Komplexem macht sie Kompliziertes.

Fehlende Systematik überfrachtet TDD. TDD soll plötzlich die ganze Softwareentwicklung retten. Endlich wird das mit der Korrektheit besser. Und dann auch noch bessere Dokumentation durch TDD. Und außerdem höhere Evolvierbarkeit durch besseres Design (lies: bessere Strukturen). Vor allem aber nicht zu vergessen: eine Lösung stellt sich auch wie von selbst ein.

Kommt das niemandem merkwürdig vor? One size fits all?

Ich bin ein großer Freund der Prinzipien Single Responsibility (SRP) und Separation of Concerns (SoC). Danach scheint mir ein bisschen viel Last auf den Schultern von TDD zu liegen.

Das ist es auch, was mich an Coreys Demonstration wieder stört. Er steht dabei nur als einer von vielen, die TDD zeigen. Die Vermischung von Lösung und Design stößt mir auf. Sie ist es nämlich, die zu den erwähnten Misserfolgen in den Dojos führt.

TDD bedeutete zunächst Test-Driven Development. Da ging es also um eine bestimmte Art zu codieren. Red-green-refactor. Das hat vor allem zu hoher Testabdeckung geführt.

Dann wurde aus dem Development das Design: Test-Driven Design. Die Betonung wurde damit auf den Refactoring-Schritt gelegt. Zur hohen Testabdeckung sollte dann auch noch eine “gut” Codestruktur kommen.

Und heute? Mir scheint, dass es gar nicht mehr um TDD geht, sondern um TDPS: Test-Driven Problem Solving. Nicht nur sollen Tests zu einem Design führen – denn über das Design soll man ja vorher nicht nachdenken, es soll entstehen, in minimaler Form. Nein, jetzt sollen am besten die Tests auch noch die Lösung herbeiführen.

Wenn Sie sich nun fragen, was denn da der Unterschied sei, dann rühren Sie genau an dem Problem, das ich meine: Es wird kein Unterschied zwischen Lösung und Code gesehen. Oder vielleicht sollte ich sagen, zwischen Lösungsansatz und Code? Ist es dann deutlicher?

Hier zwei Beispiele für den Unterschied.

Als erstes eine Lösung für das Problem des Sortierens eines Feldes. Text und Bild beschreiben einen Ansatz, sie beschreiben ein Vorgehen, sie sagen, wie man das Ziel ganz grundsätzlich erreichen kann:

image

Und jetzt ein Design in F# für diesen Lösungsansatz:

image

Im Design, im Code finden sich natürlich die Aspekte und Schritte des Lösungsansatzes wieder. Aber der Code ist nicht der Lösungsansatz. Er implementiert ihn in einer bestimmten Programmiersprache mit bestimmten Sprach- und Plattformmitteln.

Als zweites ein Lösungsansatz für ein Problem im Compilerbau, die Erkennung von Bezeichnern. Hier ein Syntaxdiagramm dafür:

image

oder alternativ ein Deterministischer Endlicher Automat:

image

Dass es überhaupt eine Phase zur Erkennung von Bezeichnern gibt (lexikalische Analyse), ist ebenfalls Teil eines Lösungsansatzes:

image

Das konkrete Code-Design, die Implementierung des Lösungsansatzes, könnte dann so aussehen:

image

Lösungsansatz – oder auch Modell – und Code in einer bestimmten Struktur – nach TDD auch Design genannt –, sind einfach verschiedene Aspekte. In den TDD-Vorführung wie bei Corey und den TDD-Selbstversuchen in den Dojos werden die jedoch nicht sauber getrennt. Immer wieder wird gehofft, durch Red-Green-Refactor nicht nur ein evolvierbares Design herzustellen, sondern auch eine Lösung zu bekommen.

Das (!) halte ich für falsch. Erstens ganz praktisch aus der Beobachtung heraus, dass so selten lauffähiger Code entsteht, der die Aufgabe erfüllt. Zweitens eher theoretisch aus dem Gedanken heraus, dass wir Menschen damit schlicht unterfordert sind. Das über Jahrtausende geschliffene Werkzeug “Denken” wird nicht genutzt. Man hofft vielmehr, durch Mustererkennung beim Code, irgendwie zu einer Lösung zu kommen.

Das funktioniert manchmal tatsächlich, wenn man genau hinschaut. Die Kata Roman Numerals könnte dafür ein Fall sein. Nur ist nicht zu erwarten, dass das immer so geht. Auf den Quicksort Lösungsansatz kommt man nicht durch TDD, davon muss man einfach eine Vorstellung entwickeln – eben einen Lösungsansatz. Im Kopf. Durch Nachdenken.

Und wie sollte es dann anders aussehen mit TDD?

Systematischeres Vorgehen

Systematisierung, Entzerrung, Entlastung, Fokus finde ich wichtig. Aus meiner Sicht sollte das Vorgehen diese Schritte beinhalten:

  1. Problem vorstellen
  2. Lösungsansatz entwickeln
  3. Testfälle ermitteln und priorisieren
  4. Lösungsansatz mit TDD implementieren

Schritte 2. und 3. können dabei mehrfach durchlaufen werden. Und wenn sich bei 4. noch neue Erkenntnisse zum Lösungsansatz ergeben sollten, dann ist das auch ok. Aber 4. ohne explizites 2. und 3. zu beginnen, halte ich für eine Überlastung.

Damit wären Lösungsansatz und Design getrennt. Damit würde – da bin ich ganz sicher – die Erfolgsquote jedes Coding Dojos steigen. Und wenn nicht, dann würde man genau sehen, woran es liegt: Liegt es an mangelnden TDD-Fähigkeiten oder liegt es an mangelndem Problemverständnis und dadurch fehlender Lösungsphantasie?

Die Single Responsibility von TDD liegt für mich bei der Testabdeckung und bei einer hohen Strukturqualität im Kleinen [2].

Was jedoch da überhaupt in Code gegossen und strukturiert werden soll… das ergibt sich nicht durch TDD, sondern durch Nachdenken. Den Lösungsansatz zu finden und die Testfälle zu priorisieren, das ist nicht Teil von TDD – muss aber gezeigt werden. Denn wird es nicht gezeigt bzw. wie bei Corey mit dem TDD-Vorgehen vermischt, entsteht entweder eine Überlastung, die die Aufgabenerfüllung behindert. Oder es entstehen “Wunderlösungen”, über die man nur staunen, sie aber eher nicht nachvollziehen kann. Wer “Wunderlösungen” goutiert, der wird es schwer haben, selbst Lösungen für andere Probleme zu finden.

Fußnoten

[1] Damit will ich nicht sagen, dass es keine Teams gibt, die die Aufgaben schaffen. Aber das passiert eben nicht regelmäßig und systematisch. Ich erinnere mich lebhaft an ein Dojo auf den XPdays und an eines auf der DDC im letzten Jahr. 5-10 Teams gab es jeweils. Und keines hat funktionierenden Code für die Kata Roman Numerals bzw. die Kata Zeckendorf (Übersetzung einer ganzen Zahl in eine Zeckendorf-Sequenz) abgeliefert.

[2] Wobei sogar diese Strukturqualität im Kleinen nicht einfach so entsteht, wie ich an anderer Stelle schon ausgeführt habe. Dafür braucht es Refactoring-Kompetenz und –Willen. Die kommen nicht aus TDD. Dafür braucht es aus meiner Sicht noch Hilfestellung, die Refactorings näher legt. Dadurch kann sich ein TDD 2.0 auszeichnen. Aber dazu ein andermal mehr…

Mittwoch, 12. Dezember 2012

TDD skaliert nicht

Dieser Tage beschäftige ich mich intensiv mit TDD. Ich kann sogar sagen, dass ich ein rechter Freund von TDD geworden bin. TDD ist cool – oder genauer: TDD 2.0 :-) - das ist nämlich TDD as if you meant it (TDDaiymi).

Nicht trotz dieser Freundschaft, sondern wegen ihr schaue ich aber auch genauer hin. Und da sehe ich ganz klar eine Beschränkung in TDD. Und das ist die mangelnde Skalierbarkeit.

TDD ist und bleibt eine Methode für 1 Entwickler (oder auch 1 Entwicklerpaar). That´s it.

imageTDD setzt mit einem Test irgendwo an einer Schnittstelle an und treibt von dort eine Struktur aus. Die wird feiner und feiner. Erst eine Methode, dann mehrere; erst eine Klasse, dann mehrere. Growing Object-Oriented Software, Guided by Tests beschreibt das recht gut.

Bei der Lektüre wird die Begrenzung von TDD aber natürlich nicht so deutlich. Daran haben erstens die Autoren kein Interesse und zweitens ist das Buch selbst ein “Single User Medium”. Man liest es allein und kann deshalb dem TDD-Vorgehen gut folgen.

Doch was ist, wenn 2, 3, 5, 10 Leute im Team sind? Dann kann jeder nach TDD vorgehen. Super! Aber was geht jeder dann mit TDD an?

TDD skaliert, wie Holzhacken skaliert. Wenn ein großer Haufen Holz rumliegt, kann man an jeden Holzklotz einen Holzhacker setzen. Das funktioniert, weil das Gesamtergebnis so einfach ist: ein Haufen Holzscheite. Und jeder Holzklotz ist auch so überschaubar, dass man nur schwer 2 oder 3 Holzhacker an einen setzen wird.

Anders ist das bei einem Sägewerk. Da arbeiten auch mehrere Holzarbeiter – aber an ganz verschiedenen Positionen. Die machen eben nicht alle dasselbe. Die tun Verschiedenes – und koordinieren ihre Arbeit.

Genau diese Koordination fehlt TDD jedoch. TDD ist eine Axt, mit der einer Anforderungen zu Code zerkleinern kann. Und was die anderen tun… Egal. Die machen irgendwo anders mit ihren Äxten auch irgendwas.

TDD skaliert also insofern nicht, als dass damit Zusammenarbeit mehrere Entwickler nicht befördert wird. In TDD selbst steckt keine Idee von Teamwork. TDD ist nur eine Methode, die auf eine gegebene Schnittstelle angewendet werden kann, um dahinter Strukturen auszutreiben.

Aber wie kommt man zu dieser Schnittstelle? Wo ist die im Big Picture einer Software angesiedelt? Dazu hat TDD keine Ahnung.

Und deshalb verfallen Teams immer wieder auf die eine unvermeidbare Schnittstelle als Ausgangspunkt, die jede Software hat: die Benutzerschnittstelle. In der werden Features verortet – und dann je 1 Entwickler an 1 Feature gesetzt, um daran mit TDD zu rumzuhacken.

Klar, damit kann man 1, 2, 5 oder 10 Entwickler beschäftigen. Nur kommt dabei jedes Feature nicht maximal schnell voran. Das meine ich mit “TDD skaliert nicht”. In TDD steckt kein Ansatz, eine Anforderung beliebiger Granularität auf mehr als 1 Entwickler zu verteilen, um es in konzertierter TDD-Aktion schnellstmöglich umzusetzen.

Das meine ich gar nicht als Kritik an TDD. Eine Axt kann ich ja auch nicht dafür kritisieren, dass in ihr keine Idee davon steckt, wie man in konzertierter Aktion mit mehreren Holzarbeitern einen Baum fällt. Eine Axt zerteilt das Holz unter ihr. TDD strukturiert Code hinter einer Schnittstelle unter sich.

Wer die Softwareentwicklung skalieren will, wer schneller 1 Anforderung umsetzen will, um schneller Feedback zu bekommen, der sollte von TDD keine Hilfestellung erwarten. Dafür muss man sich woanders umschauen.

Eine Menge von Schnittstellen, die irgendwie zusammen an der Lösung von 1 Anforderung beteiligt sind und auf die mehrere Entwickler gleichzeitig mit TDD angesetzt werden können, muss sich durch eine andere Methode ergeben.

Deshalb sind TDD-Beispiele auch immer überschaubar. Man kann mit TDD allein nichts Größeres angehen. Code Katas – so nützlich sie sein mögen, um die Methode zu üben – sind winzig. Und wer dann nur mit Code Katas TDD übt, der lernt eben auch nicht mehr als TDD. Der lernt Programmierung in Bezug auf eine Schnittstelle. Das ist eine nützliche Kompetenz so wie das Feilen, Sägen, Schweißen usw. Doch nur weil 20 Leute da sind, die alle feilen, sägen, schweißen können, entsteht noch lange keine Dampfmaschine.

Über TDD hinaus müssen wir uns also mit der Arbeitsorganisation beschäftigen. Wir müssen Wege finden, wie wir Anforderungen in etwas transformieren können, das von mehreren Entwicklern gleichzeitig mit TDD umgesetzt werden kann. Was TDD fehlt, ist eine TDD-Vorbereitung. Sich Gedanken über Testfälle zu machen, ist allerdings zu wenig. Denn auch die beziehen sich ja nur auf eine Schnittstelle.

Also: Was tun vor TDD, damit TDD den größten Effekt hat, damit die Entwickler zum Besten eingesetzt werden, damit endlich aus einer Gruppe von Entwicklern ein Team wird?

Montag, 30. Januar 2012

TDD im Flow – Teil 3

Was bisher geschah:

Test #4: Blockierte Entnahme

Nun geht es an den Kern meines Abstrakten Datentyps: die Sequentialisierung.

image[42]_thumb

Ich muss die Entnahme aus den Queues nach Round Robbin einschränken. Es darf nur entnommen werden, wenn eine Queue nicht gerade blockiert wird. Die Blockierung beginnt, wenn ein Worker aus einer unblockierten Queue entnimmt – und wird automatisch aufgehoben, wenn er wieder nach Arbeit fragt.

Anders als im Datenmodell entworfen, implementiere ich die Sperren für Queues in einem Dictionary. Die Einträge darin sind die Flags des Datenmodells. Ich finde es jedoch besser, sie von der Liste der Queues zu trennen. Die Blockade der Entnahme ist ein anderer Aspekt als die Entnahme nach Round Robbin. Beide durch Verwaltung in unterschiedlichen Datenstrukturen zu trennen, scheint mir konsequente Anwendung des SRP.

Falls sich etwas an der Blockadestrategie ändert, muss ich nicht die Datenstruktur für die Entnahme anfassen. Es ist auch nur TryDequeue() betroffen.

internal class NotifyingMultiQueue<T>
{
    …
    readonly Dictionary<string, string> _readLocks =
        new Dictionary<string,string>(); 

    …
    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        var namedQueue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(namedQueue);

        if (_readLocks.ContainsKey(namedQueue.Key) &&
            _readLocks[namedQueue.Key] != workerId)
            return false;

        message = namedQueue.Value.Dequeue();
        _readLocks[namedQueue.Key] = workerId;

        return true;
    }
    …

Diese Implementation erreicht das Ziel natürlich noch nicht ganz. Sie dient nur der Erfüllung eines Tests, ohne die vorherigen zu brechen.

Bevor ich aber den nächsten Test beschreibe: Haben Sie meine TDD Sünde entdeckt?

Ich habe mehr Code geschrieben, als für den Test nötig ist. Die Prüfung, ob der “arbeitsuchende” Worker derjenige ist, der eine Queue bisher blockiert hat, kommt nicht zum Tragen. Dafür müsste ich einen weiteren Test schreiben. Aber das ist nicht nötig, weil der nächste Test dafür sorgen wird, dass diese Prüfung gar nicht mehr nötig ist.

Warum habe ich dann aber _readLocks[namedQueue.Key] != workerId geschrieben? Weil es mir in dem Moment cool vorkam, an diese Feinheit gedacht zu haben. Da hab ich mich von der Idee davontragen lassen… Erst später beim nächsten Test ist mir die Überflüssigkeit der Bedingung aufgefallen.

Ich hatte sie aber nach erfolgreichem Test eingecheckt. Deshalb zeige ich sie Ihnen hier auch. So kann es halt kommen, auch wenn man sich bemüht. Mit Pair Programming wäre das vielleicht nicht passiert. Am Ende ists aber auch kein Beinbruch. Mir ist es ja aufgefallen. Wichtig ist, daraus zu lernen. Nobody is perfect – but everybody should strive for improvement. Oder so ähnlich ;-)

Test #5: Blockierte Queue wieder freigeben

Bisher wurde eine Queue bei Entnahme nur gesperrt. Jetzt muss sie wieder entsperrt werden. Das geschieht, wenn der Worker, der sie gesperrt hat, wieder frei ist. Der ADT bemerkt, wann ein Worker seine Arbeit an einer Nachricht beendet hat daran, dass der Worker wieder um eine Nachricht bittet:

image[47]_thumb

Beim ersten Aufruf von TryDequeue() sperrt w1 die Queue q1. Bei zweiten Aufruf sperrt er q2 – und gibt damit implizit q1 wieder frei, so dass w2 daraus entnehmen kann.

In der Implementation erreiche ich das, indem ich einfach bei Aufruf immer eine eventuell durch den anfragenden Worker gesetzte Sperre lösche:

internal class NotifyingMultiQueue<T>
{
    …

    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        if (_readLocks.ContainsValue(workerId))
            _readLocks.Remove(
                 _readLocks.Where(kvp => kvp.Value == workerId)
                           .Select(kvp => kvp.Key)
                           .First());

        var namedQueue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(namedQueue);

        if (_readLocks.ContainsKey(namedQueue.Key))
            return false;

        message = namedQueue.Value.Dequeue();
        _readLocks[namedQueue.Key] = workerId;

        return true;
    }
    …

Damit entfällt dann auch die Prüfung, ob eine Sperre durch den aktuellen Worker gesetzt worden ist. Der Fall kann nicht mehr eintreten.

Leider ist das Löschen eines Dictionary-Eintrags über den Wert statt dem Key nicht so leicht. Ich muss erst den Key (Queue-Name) aus dem Wert (Worker-ID) ermitteln. Die Linq-Query liest sich etwas umständlich.

Alternativ hätte ich ein zweites Dictionary aufsetzen können, in dem die Worker-ID als Key steht und über den Queue-Namen auf _readLocks zeigt. Aber das würde zusätzlichen Pflegeaufwand bedeuten. So scheint mir der hier eingeschlagene Weg KISS-konform.

Test #6: Blockierte Queue überspringen

Es ist merkwürdig, aber bisher bin ich ohne eine Schleife bei der Entnahme ausgekommen. Wäre ich nicht nach TDD vorgegangen, hätte ich die wahrscheinlich schon gleich am Anfang eingebaut – und die Lösung damit komplexer gemacht.

Mit TDD habe ich dagegen einige Schwierigkeiten schon aus dem Weg geräumt. Anweisungssequenzen sind leichter zu verstehen und zu testen als Schleifen.

Jetzt hilft es aber nichts mehr. Eine Schleife muss sein, wenn blockierte Queues übersprungen werden sollen. Es müssen bei TryDequeue()-Aufruf potenziell ja mehrere Queues geprüft werden.

image_thumb[23]_thumb

TDD besteht aus 3 Phasen: roter Test, grüner Test und Refactoring. Bisher habe ich die letzte Phase übersprungen. Es gab nicht viel zu refaktorisieren. Jetzt wird es mir aber zuviel, was da alles in TryDequeue() passiert. Und auch KeyValuePair ist mir zu wenig aussagekräftig; mehr Domänensprache darf sein.

Zur Befriedigung des neuen Tests füge ich deshalb nicht nur Code hinzu, sondern ziehe auch Code raus in eigene Methoden. Das Listing unterscheidet Änderungen im Rahmen des Refactoring und Änderungen zur Erfüllung der neuen Anforderungen.

internal class NotifyingMultiQueue<T>
{
 
   readonly List<NamedQueue> _queues =
        new List<NamedQueue>();
    readonly Dictionary<string, string> _readLocks =
        new Dictionary<string,string>();

    class NamedQueue
    {
        public string Name;
        public Queue<T> Queue;
    }


    public void Enqueue(T message, string queueName)
    {
        var queue = _queues.Where(nq => nq.Name == queueName)
                           .Select(nq => nq.Queue)
                           .FirstOrDefault();
        if (queue == null)
        {
            queue = new Queue<T>();
            _queues.Add(new NamedQueue{Name=queueName, Queue=queue});
        }
        queue.Enqueue(message);
    }


    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        Free_queue_locked_for_worker(workerId);

        NamedQueue namedQueue = null;
        for (var i = 0; i < _queues.Count(); i++)
        {
            namedQueue = Get_next_queue();
            if (Queue_not_locked(namedQueue)) break;
            namedQueue = null;
        }
        if (namedQueue == null) return false;

        message = namedQueue.Queue.Dequeue();
        Lock_queue_for_worker(workerId, namedQueue);
        return true;
    }
    …


Hier zeigt es sich jetzt, dass TDD letztlich kein Verfahren ist, das zu Unit Tests führt. TryDequeue() ruft jetzt andere Methoden auf, d.h. es integriert. Noch sind diese Methoden einfach und wurden 1:1 aus Code, der eben noch in TryDequeue() stand erzeugt. Bei der Weiterentwicklung des ADT kann es aber jederzeit passieren, dass Änderungen an diesen Methoden nötig werden. Und dann stellt sich die Frage, wie diese Änderungen getestet werden.

Klar, ich kann dafür dann gezielte Tests schreiben. Doch beim Refactoring mit ReSharper sind die “Hilfsmethoden” automatisch als private deklariert worden. Es kostet dann schon einige Überwindung, die auf internal zu setzen und als Units für sich zu testen. Deshalb wird in den meisten Fällen weiter durch das Interface getestet, d.h. mit Integrationstests gearbeitet. Die testen dann natürlich auch immer noch alles andere mit. Vorteil: Black Box Tests sind unabhängig von interner Struktur. Nachteil: Black Box Tests können aufwändig werden, wenn für Kleinigkeiten das Drumherum mit getestet werden muss. Das wird besonders auffallend, sobald Attrappen ins Spiel kommen. Dazu kommt, dass bei Integrationstests eine Fehlerquelle nicht so leicht lokalisiert werden kann.

Fazit

Mit diesem Artikel wollte ich Ihnen zeigen, dass ein expliziter Entwurf von Software mittels Flow-Design nicht bedeutet, alles über Bord zu werfen, was Ihnen lieb und teuer geworden ist: Ich finde, der Abstrakte Datentyp NotifyingMultiQueue<T> ist ein solider Vertreter der Objektorientierung. Und seine Entwicklung ist eine solide Anwendung von TDD.

Darüber hinaus wollte ich Ihnen an einem realen Beispiel zeigen, wie TDD funktionieren kann: mit expliziter Testplanung, in kleinen Schritten. Nicht perfekt, aber good enough.

Schließlich haben Sie gesehen, dass selbst ich als Verfechter von Nachdenken vor dem Codieren nicht dogmatisch bin ;-) Wenn die Codierungsrealität es nahelegt, dann kann ich von meinem Entwurf auch abweichen. Erkenntnisse sind jederzeit willkommen.

Wenn Sie mögen, verfolgen Sie meine TDD-Fortschritte auch im Code. Hier die relevanten Changesets (insb. 43..50) im Mercurial Repository npantarhei.codeplex.com.

image_thumb[24]_thumb

Und nun kommen Sie mit TDD und FD.

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...

Freitag, 27. Januar 2012

TDD im Flow – Teil 2

Was bisher geschah:

Test #2: Ein Worker entnimmt aus mehreren Queues

Der dritte Test in meiner Planung bleibt sinnig. Er führt zu Änderungen am Produktionscode.

image[27]

Den Testcode zu zeigen, lohnt nicht. Er entspricht der Skizze im Bild. Aber hier der Produktionscode:

internal class NotifyingMultiQueue<T>
{
    List<KeyValuePair<string, Queue<T>>> _queues = 
        new List<KeyValuePair<string,Queue<T>>>();
 

    public void Enqueue(T message, string queueName)
    {
        var queue = _queues.Where(nq => nq.Key == queueName)
                           .Select(nq => nq.Value)
                           .FirstOrDefault();
        if (queue == null)
        {
            queue = new Queue<T>();
            _queues.Add(new KeyValuePair<string, Queue<T>>(queueName,
                                                           queue));
        }
        queue.Enqueue(message);
    }


    public bool TryDequeue(string workerId, out T message)
    {
        var queue = _queues[0];
        _queues.RemoveAt(0);

        message = queue.Value.Dequeue();
        return true;
    }
    …

Die “Änderung mit Zukunft” betrifft Enqueue(). Dort werden nun nach Name unterschieden Nachrichten in verschiedene Warteschlangen eingetragen. Dass ich dafür die Warteschlangen in einer Liste statt einem Dictionary organisiere, ist nicht nur einer gewissen Voraussicht geschuldet – ich habe eine Idee, wie ich das Round Robbin Verfahren einfach implementieren kann –, sondern auch dem Wunsch, TryDequeue() für den Moment möglichst einfach zu halten.

Zwar könnte ich Enqueue() noch simpler mit einem Dictionary arbeiten, doch dann könnte TryDequeue() bei der Entnahme nicht durch die Warteschlangen fortschreiten – und sei es auch nur so simpel wie jetzt. In einem Dictionary gibt es keine verlässliche Reihenfolge der Einträge. Ein Fortschreiten durch verschiedene Warteschlangen ist aber nötig, um hier eine neue Äquivalenzklasse anzugehen. Ansonsten ließe sich das Ergebnis dieses Tests auch mit der bisherigen Implementation erreichen.

Test #3: Queuewechsel mit Round Robbin

Jetzt wird es spannend. Nachrichten in separate Queues zu stellen, ist einfach. Sie aber im Round Robbin Verfahren daraus zu entnehmen, das ist schon kniffliger. Das muss ich nun angehen:

image[32]

Dazu müssen auch in den Queues mehrere Nachrichten stehen, weil es ja der Trick beim Round Robbin ist, nicht erst eine Queue abzuarbeiten, sondern für jede Nachricht eine weiter zu rücken.

Oben habe ich mir Gedanken zu einer Datenstruktur gemacht, mit der das möglich ist. Ein hübscher Plan… den ich nun fallen lasse. Nein, den ich schon mit der vorherigen Implementation habe fallen lassen.

Ich baue mir nicht selbst eine verkette Liste von Warteschlangen, sondern nehme eine normale Liste, der ich Queues in ihrer Reihenfolge vorne entnehme und hinten wieder anfüge. So wandern sie im Kreis durch das Fenster des Listenkopfs.

internal class NotifyingMultiQueue<T>
{
    List<KeyValuePair<string, Queue<T>>> _queues =
        new List<KeyValuePair<string,Queue<T>>>();

    …
    public bool TryDequeue(string workerId, out T message)
    {
        var queue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(queue);

        message = queue.Value.Dequeue();
        return true;
    }
    …

Um diesen Test “ergrünen zu lassen” ist nur eine weitere Zeile Code in TryDequeue() nötig. “Pointergehansel” wie im Datenmodell ist nicht nötig. Das hatte ich geahnt beim vorherigen Test und deshalb eine Liste statt eines Dictionary für die benannten Queues gewählt.

Hm… man könnte nun argumentieren, dass hier eine Datenstruktur (Liste) zwei Zwecken dient: der Haltung benannter Queues (insb. für Enqueue()) und dem Durchlaufen der Queues in bestimmter Reihenfolge (TryDequeue()). Diesem Hinweis auf das SRP halte ich aber KISS entgegen: für den Moment stellt diese Verquickung kein Problem dar. TryDequeue() wird dadurch nicht komplizierter. Und Enqueue() eigentlich auch nicht. Im Falle des Wechsels der Entnahmestrategie müsste ich ohnehin beide Methoden anfassen.

Noch ein Testszenario entfällt

Beim nächsten Testszenario muss ich schon wieder feststellen, dass ich übers Ziel hinausgeschossen bin. Es zu erfüllen, bedarf keiner Änderung am Code.

image[37]

Das erkenne ich aber erst jetzt, da ich die Lösung besser verstehe und schon Code geschrieben habe. Macht nichts. Ein Test weniger fällt mir leicht ;-)

Weiter geht es im nächsten Teil…

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...

Donnerstag, 26. Januar 2012

TDD im Flow – Teil 1

Steht Flow-Design eigentlich im Gegensatz zu Test-Driven Design? Nein. Zwar bin ich überzeugt, dass TDD viel weniger wichtig ist, als viele Vertreter agiler Softwareentwicklung glauben, aber deshalb hat TDD doch seinen Platz. An einem Beispiel möchte ich das demonstrieren.

Vor einiger Zeit habe ich Gedanken zu einer Flow Execution Engine geäußert. Die habe ich inzwischen angefangen zu bauen. Ihr Name ist “PantaRhei” und der Quellcode für eine C#-Implementation liegt bei CodePlex: npantarhei.codeplex.com.

Selbstverständlich ist diese Flow Runtime selbst im Flow-Design entstanden. Ihre Aufgabe ist also, Flows wie den, der sie selbst beschreibt, auszuführen. Hier ein Ausschnitt aus dem Modell:

image

Gezeigt ist die asynchrone Verarbeitung von Nachrichten, die von außen hereinkommen:

  • Sie fließen in einen Flow, der bei der Runtime registriert ist, über die Methode ProcessAsync() hinein.
  • Dann werden sie über die Funktionseinheit Asynchronize auf einen Hintergrund-Thread gehoben.
  • Vor der Ausführung wird dann nochmal geprüft, ob die Verarbeitung parallel zu anderen Nachrichten erfolgen soll (Schedule processing).
  • Zwei Parallelverarbeitungsmodi stehen zu Auswahl: echte Parallelverarbeitung, d.h. jede Nachricht an einen Port wird parallel zu anderen Nachrichten verarbeitet, oder eingeschränkte Parallelverarbeitung, da Nachrichten an den selben Port nur sequenziell verarbeitet werden.

Der Flow im Bild ist nicht durch TDD entstanden. Klar. Den habe ich iterativ wachsen lassen: ein bisschen entwerfen (auf dem Papier), ein bisschen implementieren, dann wieder ein wenig entwerfen usw. Im Repository kann man verfolgen, wie es voran gegangen ist.

Die (Sub-)Flows sind trivial in der Implementation. Die Musik spielt in den Operationen, den Blättern des oben grob erkennbaren Schachtelungsbaums. Blätter sind z.B. Register operation, Asynchronize und Execute task.

Diese Operationen sind so einfach, dass ich sie runtergeschrieben habe. Einige ohne Tests, einige mit Tests – hinterher. Ja, der TDD-Freund wird es den Tests ansehen, dass ich sie hinterher geschrieben habe ;-) Macht aber aus meiner Sicht nichts. Ich brauche sie ja nicht, um zu entwerfen, sondern nur zur Feststellung von Korrektheit kleiner Funktionseinheiten.

Nun aber bin ich an einem Punkt, wo ich meinen Modus umschalte. Das will ich einmal dokumentieren, um zu zeigen, wie TDD und FD Hand in Hand gehen können.

Anforderungen

Ganz rechts im Bild finden Sie die Operation Sequentialize. Bei ihr geht es darum, Nachrichten zur Verarbeitung auf einen von mehreren Threads zu heben. Parallelize tut das ganz einfach: Nachrichten werden dem nächsten frei werdenden Thread zugewiesen. Das führt zu unterschiedsloser Parallelverarbeitung aller Nachrichten. Bei Sequentialize soll es dagegen etwas differenzierter vorgehen. Da sollen manche Nachrichten parallel und manche sequenziell verarbeitet werden.

Unterschieden werden Nachrichten bei Sequenzialize nach den Ports, zu denen sie fließen. Nachrichten an unterschiedliche Ports werden parallel verarbeitet, aber Nachrichten an den selben Port werden sequenziell verarbeitet. Damit wird eine häufige Fehlerquelle des concurrent programming automatisch ausgeschaltet.

Ganz einfach ließe sich die sequenzielle Verarbeitung aller Nachrichten an einen Port natürlich dadurch lösen, jeden Port mit einem eigenen Thread und einer Warteschlange auszustatten. Das würde die Verarbeitung insgesamt jedoch eher verlangsamen, dass es immer viel mehr Ports als Prozessorkerne gibt.

Also soll die sequenzielle Verarbeitung der Nachrichten an einen Port bei gleichzeitiger Parallelität aller Ports mit nur wenigen Threads realisiert werden. Sequentialize muss daher für jede Nachricht prüfen, ob sie an einen Port geht, der gerade noch mit der vorhergehenden beschäftigt ist. Falls ja, muss die Nachricht warten; andernfalls kann sie vom nächsten freien Thread verarbeitet werden.

Wenn eine Nachricht warten muss, kann eine andere, die zu einem anderen Port fließt, an den nächsten Thread zugewiesen werden. Die wartende Nachricht darf darüber natürlich nicht vergessen werden.

Lösungsidee

Mir scheint es für dieses Problem keine Lösung in der TPL zu geben, da ich ja keine fixen Task-Netzwerke aufbaue. Also muss ich selbst Infrastruktur basteln. Das sollte kein Hexenwerk sein – aber es ist schwieriger als das, was ich bisher für die Runtime zu tun hatte.

Der Rahmen ist noch simpel:

image

Zur Sequentialisierung in beschriebener Weise, müssen die Nachrichten in einen Puffer geschrieben werden: Enqueue message. Das geschieht auf dem Thread, der sie annimmt für die Verarbeitung (s. Asynchronize in Flow asynchronously).

Die vielen Threads, auf denen Nachrichten dann parallel abgearbeitet werden können, entnehmen sie aus diesem Puffer, sobald sie frei für neue Arbeit sind: Parallel dequeue.

Diese beiden Funktionseinheiten sind trivial. Die Musik spielt im Message store. Der kann nämlich nicht eine simple Warteschlange wie bei Asynchronize sein. Vielmehr muss der Message store dafür sorgen, dass Nachrichten erstens in der Reihenfolge ihres Eingangs herausgegeben werden, zweitens dabei aber eine Einschränkung für Nachrichten an denselben Port gilt.

Das ist eine Funktionalität, die ich nicht eben mal so runterschreibe. Und dazu fällt mir auch gerade kein sinniger Flow ein. Mein Gefühl ist, hier bei einer Funktionseinheit angelangt zu sein, die aus Sicht von Flow-Design ein Blatt, eine Black Box darstellt.

Dennoch hat der Message store eine Struktur: eine Datenstruktur wie auch eine Verarbeitungsstruktur. Wie komme ich an die heran?

Meine Lösungsidee für die Datenstruktur sieht erst einmal so aus:

  • Jeder Port bekommt eine eigene Warteschlange. Damit wird sichergestellt, dass pro Port die Nachrichten in der ursprünglichen Reihenfolge bearbeitet werden.
  • Allerdings hat jede solche Port-Warteschlange eine Flagge, die anzeigt, ob gerade an einer Nachricht dieses Ports gearbeitet wird. Solange das der Fall ist, kann kein Thread eine Nachricht aus dieser Warteschlange entnehmen. Sie ist dann blockiert.
  • Da es mehrere Warteschlange gibt, die mehrere Threads mit Arbeit versorgen, muss es gerecht zugehen. Kein Port darf bevorzugt werden. Deshalb sollten die Warteschlangen von freien Threads im Round Robbin Verfahren abgefragt werden. Entnimmt ein Thread aus einer Warteschlange eine Nachricht, entnimmt der nächste freie Thread aus einer anderen. Die Warteschlangen bilden deshalb einen Kreis in Form einer einfach verketteten Liste mit einem Zeiger auf die nächste zu befragende Warteschlange.

Im Bild sieht das so aus:

image

TDD-Anhänger mögen angesichts von soviel Entwurf sagen, das sei vielleicht nicht so einfach, wie es sein könnte. Darauf sage ich: Mag sein. Aber warum sollte ich nicht eine Lösungsidee haben auf der Basis der Kenntnis der Anforderungen? Warum sollte ich mich dümmer stellen als ich bin? Ob ich die Datenstruktur am Ende so umsetze, ist ja noch eine zweite Frage. Zunächst trenne ich aber die Phasen Lösungsentwurf und Implementierung. Damit entlaste ich die Implementierung. Ich habe dann nämlich schon eine Vorstellung von der Lösung – an die ich mich allerdings auch nicht sklavisch halten sollte.

Es gibt mehrere Werte, die es auszugleichen gilt. Ein Wert mag die einfachst mögliche Implementation sein. Aber ein anderer ist Geschwindigkeit. Und die ist höher, wenn ich eine (zumindest grobe) Struktur habe, auf die ich hinarbeiten kann, ein Ziel. Dann muss ich nämlich nicht dauernd refaktorisieren, weil mich Erkenntnisse während der TDD-Codierung überraschen.

Solange mein Entwurf verständlich ist und mit Augenmaß auch simpel gehalten, finde ich es völlig ok, vorauszudenken. Nein, ich finde es sogar empfehlenswert. Zu oft habe ich nämlich gesehen, dass mit TDD eine Implementation angegangen wird, ohne eine Vorstellung von der Lösung zu haben – um dann nach anfänglichen Erfolgen steckenzubleiben.

Mit einem Entwurf schaffe ich mir sozusagen selbst eine Karte für ein bis dahin unbekanntes Terrain. Und mit Karte wandert es sich leichter. Das heißt nicht, dass ich auf dem Weg nicht auf Hindernisse treffe. Aber ich kann die dann in einem bigger picture verorten und schauen, wie ich sie umwandere.

Und wie sieht meine Lösungsidee für die Verarbeitungsstruktur aus? Da kenne ich nur die Schnittstelle. Ich will die Datenstruktur als abstrakten Datentyp (ADT) realisieren. D.h. nach außen ist von einem Objektgraphen nichts zu sehen. Funktionseinheiten, die mit der Datenstruktur umgehen, sollen es so einfach wie möglich haben. Ich stelle mir das so vor:

class NotifyingMultiQueue<T> {
    public void Enqueue(T message, string queueId) {…}
    public bool TryDequeue(string workerId, out T message) {…}

    public void Wait(int milliseconds) {…}
}

  • Um eine Nachricht zur Verarbeitung auf irgendeinem Thread einzustellen, wird Enqueue() für eine Warteschlange aufgerufen.
  • Um eine Nachricht von der nächsten Warteschlange zur Verarbeitung abzuholen, wird TryDequeue() aufgerufen. Dabei ist anzugeben, wer die Nachricht dann verarbeitet, damit sichergestellt werden kann, dass keine ungewollte Parallelverarbeitung stattfindet.
    Eine erfolgreiche Entnahme aus einer Warteschlange sperrt diese durch Hinterlegung der Id des Workers, an den eine Nachricht ausgegeben wurde. Wenn der Worker dann das nächste Mal eine Entnahme tätigen will, wird die durch seine vorherige Entnahme gesperrte Warteschlange wieder freigegeben. 
  • Und falls gerade keine Nachricht zur Verarbeitung anliegt (TryDequeue() liefert false), kann ein Thread sich mit Wait() schlafen legen, bis es wieder Arbeit gibt.

Das klingt hoffentlich sinnig. Dass das grundsätzlich funktioniert, habe ich schon bei der asynchronen Nachrichtenverarbeitung überprüft. Mit so einer Schnittstelle läuft das Umheben auf andere Threads ordentlich.

Test-Driven Development

Die Anforderungen sind klar, die Schnittstelle ist einfach – aber wie sieht nun der Code dahinter aus? Den könnte ich jetzt versuchen runterzuschreiben; durch die Datenstruktur habe ich ja einen Überblick über das Nötige gewonnen. Aber dabei würde ich mich nicht wohl fühlen. Etwas mehr Systematik darf sein bei einem so wichtigen Bestandteil der Flow Runtime. Wenn die Datenstruktur nämlich nicht sauber aufgesetzt ist, dann kommt es zu Fehlern in der Arbeit mit mehreren Threads – und die sind sicher nur schwer zu reproduzieren und zu lokalisieren.

Wie aber anfangen mit TDD? Jetzt Visual Studio anwerfen und einen ersten Test für eine der Schnittstellenmethoden schreiben? Nein! Am Anfang von TDD steht die Sammlung von Testfällen. Darauf, dass die Ihnen während der Codierung einfallen, sollten Sie sich nicht verlassen. Und schon gar nicht sollten Sie erwarten, dass sie Ihnen in einer guten Reihenfolge einfallen. Damit würden Sie das TDD-Vorgehen überfrachten.

Testfälle für das Ganze, das Sie realisieren wollen, sind vor dem Beginn der Codierung festzulegen. Es sind schließlich die Akzeptanzkriterien für Ihren Code. Wenn Sie die nicht vorher kennen, lügen Sie sich schnell mit der Codierung – selbst wenn die Test-First geschieht – etwas in die Tasche. Außerdem dient die Sammlung der Testfälle dem tieferen Verständnis der Anforderungen.

Hier die ersten 4 Testfälle von 14, die mir zu dieser speziellen Warteschlange eingefallen sind:

image

Sie sind in aufsteigender “Schwierigkeit” sortiert. Mit jedem Testfall wird das Szenario etwas komplizierter. Es gibt ja mehrere Variablen:

  • Die Zahl der Einträge in einer Warteschlange
  • Die Zahl der Warteschlangen
  • Die Zahl der Worker, die auf die Warteschlangen zugreifen
  • Den Blockierungszustand
  • Benachrichtigungszustand

Allerdings gibt es noch ein zweites Sortierkriterium: den Interessantheitsgrad eines Tests bzw. die Relevanz der durch ihn abgedeckten Funktionalität. Deshalb steht am Anfang ein happy day Szenario und nicht die sonst häufig zu sehende Prüfung, ob eine leere Datenstruktur korrekt behandelt wird. Das ist erst der 9. Test.

Vor dem ersten Test bin ich so frei und setze meine Klasse mit den obigen Methoden auf. Das empfinde ich als keinen schlimmen Bruch mit der strengen TDD-Praxis. Alle Methoden werfen die NotYetImplemented Exception. So mache ich mir das Schreiben der Tests etwas einfacher und habe gleich sozusagen ein kleines Backlog im Code.

Gleichfalls lege ich mir in meiner Testklasse das System under Test (SUT) zurecht. Wie mir die Testplanung zeigt, brauche ich ja immer wieder eine Instanz von NotifyingMultiQueue, die via TryDequeue() eine Nachricht zurückgibt.

[TestFixture]
public class test_NotifyingMultiQueue
{
    private NotifyingMultiQueue<string> _sut;
    private string _result;

    [SetUp]
    public void Before_each_test()
    {
        _sut = new NotifyingMultiQueue<string>();
        _result = null;
    }
    …

Test #1: Ein Worker entnimmt aus einer Queue

Der erste Test ist ganz einfach. So soll es ja auch sein. Leicht beginnen und in kleinen Schritten vorgehen:

[Test]
public void Single_worker_takes_from_single_queue()
{
    _sut.Enqueue("a", "q1");
    Assert.IsTrue(_sut.TryDequeue("w1", out _result));
    Assert.AreEqual("a", _result);
}

Die Zeilen setzen die Testplanung treu um:

image

Ich habe länger überlegt, ob ich den ADT als Black Box testen soll. Am Ende habe ich mich dafür entschieden. Auch wenn ich eine hübsche Datenstruktur entworfen habe, will ich mich nicht daran in Tests binden, wenn es nicht absolut nötig ist. Also teste ich die Funktionalität immer durch den API hindurch. Bei der zu erwartenden Größe des Codes scheint es mir vertretbar, ganz auf Integrationstests zu setzen, auch wenn sich intern die funktionalen Strukturen ausdifferenzieren.

Allerdings muss ich dadurch meist paarweise Änderungen vornehmen: bei Enqueue() und bei TryDequeue() bzw. Wait(). Anders kann ich nicht überprüfen, ob eine interne Zustandsveränderung korrekt erfolgt. Auf beiden Seiten muss ich mich also konzentrieren, das KISS Prinzip nicht aus den Augen zu verlieren.

Für den ersten Test ist das aber noch einfach. Das Szenario kann mit einer Queue abgehandelt werden:

internal class NotifyingMultiQueue<T>
{
    Queue<T> _queue = new Queue<T>();

    public void Enqueue(T message, string queueName)
    {
        _queue.Enqueue(message);
    }

    public bool TryDequeue(string workerId, out T message)
    {
        message = _queue.Dequeue();
        return true;
    }
    …

Die Implementation muss zur Zeit nur einen einzigen Test erfolgreich machen. Wenn also nicht alle Parameter benutzt werden oder von Round Robbin nichts zu erkennen ist, dann macht das nichts. Diese Implementation ist die einfachste mögliche, zur “Testbefriedigung”.

Zumindest ist das so, wenn man den Anspruch hat, dass im Code etwas halbwegs sinniges passiert. Oft ist ja bei TDD Demos zu sehen, dass bei den ersten Tests triviale Implementationen vorgenommen werden, z.B.

internal class NotifyingMultiQueue<T>
{
    public void Enqueue(T message, string queueName)
    {}

    public bool TryDequeue(string workerId, out T message)
    {
        message = “a”;
        return true;
    }
    …

Der Test würde damit grün – ansonsten wäre aber nichts gewonnen. Es wäre kein Schritt in Richtung einer Lösung unternommen worden.

Tests sind ja aber kein Selbstzweck. Das Ziel ist nicht, Tests grün zu bekommen. Tests sind nur ein Mittel, um das Ziel zu ereichen. Das ist mit und ohne Tests grundsätzlich dasselbe: Produktionscode, der die Anforderungen erfüllt.

Es ist gut, wenn kleinschrittige Tests zu einem Wachstum der Implementation auch in kleinen Schritten führt. Triviales Wachstum aber, von dem man weiß, dass es nicht belastbar ist im Sinne der Anforderungen, sollten Sie vermeiden.

Ein Testszenario entfällt

Nicht nur Implementation kann allerdings trivial wachsen. Auch bei Tests besteht die Gefahr. Das weiß man aber nicht immer, wenn man den Test formuliert. Der folgende schien mir beim Entwurf noch sinnig:

image

Nach der einfachen, aber nicht trivialen Implementation zu Test #1 war dieser Test jedoch überflüssig. Es wäre sofort grün gewesen. Ich habe ihn deshalb ausgelassen.

Die Maxime dahinter lautet: Tests, die du keiner Änderung an der Implementation führen (d.h. die nicht zuerst rot sind), sollten nicht geschrieben werden. Sie treiben den Produktionscode nicht voran. Sie gehören zur selben Äquivalenzklasse wie ein anderer Test. Hier wäre es die Äquivalenzklasse “Ein Worker entnimmt aus einer Queue” gewesen.

Würde ich eine Queue implementieren, wäre der Test nötig. Aber ich benutze in meinem ADT eine Queue. Und deren Funktionstüchtigkeit muss ich nicht auch nochmal testen. Darauf verlasse ich mich. In Bezug auf benutzte ADTs sind meine Tests also Integrationstests.

Weiter geht es im nächsten Teil…

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...