post

Tests, bevor es ein Produkt gibt

Frühe Tests helfen, ein Problem zu begreifen, auch wenn der Code später komplett ersetzt wird.

≈ 8 Min. Lesezeit

Der Reflex bei etwas Neuem ist, sofort zu bauen. Ein Endpunkt, eine Maske, ein Datenmodell – irgendetwas Sichtbares, das man vorzeigen kann. Ich habe mir angewöhnt, an genau dieser Stelle einen Schritt langsamer zu werden und zuerst einen Test zu schreiben. Nicht, um den Code abzusichern – den gibt es ja noch gar nicht –, sondern weil der Test mich zwingt, das Problem in einem einzigen, überprüfbaren Satz zu benennen. Und dieser Satz überlebt fast immer den Code, den ich danach schreibe und ein paar Tage später wieder wegwerfe.

Genau das ist der Punkt, den ich hier ausbreiten will. Ein Test ist nicht nur Qualitätssicherung am Ende. Er ist ein Werkzeug, um ein Problem zu begreifen, lange bevor ein Produkt existiert. Und der Ertrag bleibt auch dann, wenn von der ersten Implementierung nichts übrig ist.

Katas: üben, ohne dass ein Produkt entsteht

Der konkrete Anlass für diesen Text sind Katas. Eine Kata ist eine kleine, klar umrissene Programmieraufgabe, die man nicht löst, um sie loszuwerden, sondern um an ihr zu üben – die String Calculator Kata, die Bowling-Game-Kata, römische Zahlen, FizzBuzz. Man kennt die Lösung oft schon. Trotzdem macht man sie wieder, im Coding Dojo mit dem Team oder allein an einem ruhigen Vormittag, weil es nicht um das Ergebnis geht, sondern um den Weg dorthin.

Das Interessante an einer Kata ist, dass am Ende bewusst kein Produkt steht. Es gibt keinen Kunden, keine Deadline, kein Deployment. Was bleibt, ist die Erfahrung, wie sich ein Problem anfühlt, wenn man es Testschritt für Testschritt aufdröselt. Genau diese Haltung nehme ich mit in echte Projekte. In der frühen Phase eines Features weiß ich oft noch nicht, wie die Lösung aussieht. Ich weiß nur, welches Verhalten am Ende beobachtbar sein muss. Und das lässt sich als Test formulieren, bevor auch nur eine Zeile Produktivcode existiert.

Nehmen wir die String Calculator Kata. Die Aufgabe klingt trivial: eine Funktion, die eine Zeichenkette mit Zahlen entgegennimmt und ihre Summe liefert. Der erste Test ist maximal langweilig – und trotzdem die wichtigste Zeile des Tages.

describe("String Calculator", () => {
  it("returns 0 for an empty string", () => {
    expect(add("")).to.equal(0);
  });

  it("sums two comma-separated numbers", () => {
    expect(add("1,2")).to.equal(3);
  });
});

Bevor ich add schreibe, habe ich zwei Entscheidungen getroffen, die vorher unausgesprochen waren: Der leere Fall ist nicht undefiniert, sondern null. Und das Trennzeichen ist zunächst das Komma. Zwei kleine Beispiele – und schon ist eine Diskussion möglich, die sonst erst im Code-Review oder, schlimmer, im Betrieb aufgetaucht wäre.

Der Test als Denkwerkzeug

Kent Beck hat das in Test-Driven Development by Example auf eine einfache Schleife gebracht: einen Test schreiben, der fehlschlägt, ihn mit dem kleinstmöglichen Schritt grün bekommen, dann aufräumen. Rot, grün, refactor.

flowchart LR
  Red[Test schreiben,<br/>er schlägt fehl] --> Green[kleinster Schritt<br/>bis grün]
  Green --> Refactor[aufräumen,<br/>ohne Verhalten zu ändern]
  Refactor --> Red

Der entscheidende Effekt ist nicht die Testabdeckung. Es ist das Feedback über das Design. Wenn ich für einen einzigen Testfall drei Objekte aufsetzen, vier Abhängigkeiten stellen und eine halbe Datenbank mocken muss, dann sagt mir der Test etwas über meinen Entwurf, nicht über meinen Testfleiß: Die Grenze sitzt an der falschen Stelle. Ein Test, der schwer zu schreiben ist, ist fast immer ein Hinweis auf eine Kopplung, die ich noch nicht bezahlt, aber schon eingegangen bin.

Deshalb schreibe ich den Test zuerst, auch wenn ich weiß, dass der Code darunter ein Wegwerfstück ist. Der Test ist die Frage, der Code ist nur der erste Antwortversuch. Die Frage ist das Wertvolle. Wenn ich den ersten Antwortversuch nach zwei Tagen komplett ersetze – weil ich das Problem inzwischen besser verstehe –, bleibt die Frage stehen und prüft die neue Antwort genauso streng wie die alte.

Specification by Example

Der zweite Baustein, der für mich untrennbar dazugehört, ist Specification by Example, so wie Gojko Adzic es beschreibt. Die Idee: Statt eine Anforderung abstrakt zu formulieren („der Rabatt gilt für Neukunden"), schreibt man sie als konkretes Beispiel auf, das gleichzeitig Fachsprache, Erwartung und automatisierbare Prüfung ist.

Scenario: A new customer gets the welcome discount
  Given a customer with no previous orders
  When the customer places an order of 100 EUR
  Then the invoice total is 90 EUR

Dieses kleine Szenario leistet mehr, als es aussieht. Es ist in einer Sprache geschrieben, die eine Fachexpertin lesen und korrigieren kann. Es legt die Erwartung auf eine Zahl fest, über die man streiten kann – sind es wirklich 90, oder greift der Rabatt vor oder nach der Mehrwertsteuer? Und es lässt sich an die Ausführung binden, sodass die Beschreibung nicht neben dem Code veraltet, sondern mit ihm mitläuft.

In der Praxis ist das Beispiel oft der Moment, in dem ein Missverständnis auffliegt. „Neukunde" hieß für die Entwicklung „noch keine Bestellung", für den Vertrieb aber „seit zwölf Monaten keine Bestellung". Solange die Anforderung abstrakt blieb, waren sich alle einig. Erst das konkrete Beispiel mit den 100 EUR hat die beiden Deutungen sichtbar gemacht. Genau dafür schreibe ich Beispiele früh: nicht, um Code zu testen, sondern um Annahmen zu testen, die noch niemand ausgesprochen hat.

Die Grenze ist die eigentliche Entscheidung

Wenn ich einen Test formuliere, treffe ich implizit eine Architekturentscheidung: Ich lege fest, an welcher Grenze ich prüfe. Und diese Grenze bestimmt, was ich später frei ändern kann und was nicht.

Prüfe ich zu tief – auf einzelne Methodenaufrufe, interne Reihenfolgen, private Hilfsstrukturen –, dann klebt der Test an der Implementierung. Jede Umgestaltung, die das Verhalten gar nicht ändert, lässt ihn rot werden. Solche Tests bestrafen Refactoring, statt es abzusichern, und das Team lernt schnell, sie zu meiden oder blind anzupassen. Prüfe ich dagegen an einer sinnvollen Grenze – am beobachtbaren Verhalten der Einheit, an dem, was ein anderer Teil des Systems tatsächlich von ihr sieht –, dann darf sich innen alles ändern, solange außen das Versprechen hält.

Das ist der Punkt, an dem Testen aufhört, eine reine Handwerksfrage zu sein, und zur Entwurfsfrage wird. Die Grenze, an der ich teste, ist dieselbe Grenze, an der ich das System später aufschneiden, austauschen oder ersetzen kann. Ein gut gesetzter Test beschreibt eine Naht im System. Ein schlecht gesetzter Test zementiert eine Interna. Deshalb lohnt sich vor der Tool-Frage – Mocha oder Jasmine, Sinon oder handgeschriebene Stubs – die inhaltliche Frage: Welches Verhalten will ich festhalten, und welches will ich bewusst offen lassen?

Wann sich das lohnt – und wann nicht

Ich will nicht behaupten, dass jede Zeile mit einem Test beginnen muss. Ein Spike, mit dem ich nur herausfinde, ob eine Bibliothek überhaupt taugt, braucht keinen Test – er ist selbst das Experiment und wird danach gelöscht. Ein reiner Durchstich, um ein Layout zu sehen, auch nicht.

Der Trade-off läuft entlang einer einzigen Achse: Wie gut verstehe ich das Problem schon? Wenn die Aufgabe klar ist und nur noch getippt werden muss, kostet Test-First mehr, als es bringt. Wenn ich dagegen nicht sicher bin, was „richtig" überhaupt heißt – und das ist in der frühen Produktphase der Normalfall –, dann ist der Test das billigste verfügbare Werkzeug, um Klarheit zu erzwingen. Gerade weil ich weiß, dass der Code Wegwerfware ist, will ich das Wenige, das ich lerne, in einer Form festhalten, die den Code überlebt.

So gesehen ist Test-First in der Erkundungsphase kein Widerspruch zum Wegwerfen. Es ist die Methode, mit der ich aus einem Wegwerf-Prototyp trotzdem dauerhaftes Wissen herausziehe: die eine Zeile, die das erwartete Verhalten festhält, bleibt, auch wenn alles andere geht.

Der Fehlerpfad entscheidet über Releasefähigkeit

Es gibt einen Sprung zwischen „läuft in der Demo" und „läuft im Betrieb", und genau diesen Sprung machen frühe Tests sichtbar. Im Demo-Modus sieht fast jedes System sauber aus, weil man den glücklichen Pfad zeigt: gültige Eingabe, erreichbarer Dienst, genau ein Durchlauf. Interessant wird es daneben.

Wenn ich einen Import baue, ist die spannende Frage nicht, ob er eine korrekte Datei einliest, sondern was bei der halb kaputten passiert. Wird der Lauf wiederholt, entstehen dann Duplikate, oder ist er idempotent? Was passiert bei einem Timeout mitten in der Verarbeitung – bricht er sauber ab oder bleibt das System in einem halben Zustand? Welche Logzeile hilft mir nachts um drei, den Fehler zu verstehen, statt ihn nur zu bemerken? Diese Fragen wirken trocken, aber sie sind der eigentliche Unterschied zwischen einem Prototyp und etwas, das man einem Kunden gibt.

Ich schreibe deshalb früh mindestens einen Test für den unangenehmen Fall, nicht nur für den schönen. Ein Test, der prüft, dass eine doppelte Verarbeitung eben nicht zwei Rechnungen erzeugt, sagt mehr über die Tragfähigkeit des Entwurfs aus als zehn Tests für das erwartbare Ergebnis. Und er sagt es zu einem Zeitpunkt, an dem die Änderung noch billig ist.

Was das messbar bringt

Das alles wäre folklore, wenn es nur ein gutes Gefühl erzeugte. Der Nutzen ist konkreter. Missverständnisse werden im Beispiel sichtbar, nicht erst in der Abnahme – das verkürzt den teuersten aller Zyklen, den zwischen „falsch verstanden" und „gemerkt, dass falsch verstanden". Refactoring wird billiger, weil Tests an der richtigen Grenze das Verhalten absichern, statt die Implementierung einzufrieren; das Team traut sich, aufzuräumen, statt Code zu umgehen. Und die Übergabe wird leichter, weil ein Beispiel in Fachsprache eine bessere Dokumentation ist als jeder Fließtext, der neben dem Code veraltet.

Wenn ich einen Effekt herausgreifen müsste, dann diesen: kürzere Rückkopplung. Ein Beispiel, das eine Annahme prüft, verwandelt eine Diskussion, die sonst Wochen später im Betrieb geführt würde, in eine Diskussion von fünf Minuten am Whiteboard. Das ist keine weiche Verbesserung. Es ist der Unterschied zwischen einem Team, das an der Sache arbeitet, und einem, das an seinen Missverständnissen arbeitet.

Was bleibt, wenn der Code geht

Zurück zum Anfang. Der Grund, warum ich Tests schreibe, bevor es ein Produkt gibt, ist nicht Disziplin und auch nicht Vollständigkeit. Es ist, dass der Test die eine Sache festhält, die ich behalten will, während alles andere noch im Fluss ist: das erwartete Verhalten, benannt an einer Grenze, über die man streiten kann.

Der Code der ersten Woche ist fast immer falsch, und das ist in Ordnung – er war nie als Antwort gemeint, sondern als Frage in ausführbarer Form. Wenn ich ihn wegwerfe und neu schreibe, prüft derselbe Test die neue Fassung genauso streng. Genau deshalb üben wir an Katas, ohne je ein Produkt zu bauen: nicht das Ergebnis trägt, sondern das Urteil darüber, welches Verhalten zählt. Und dieses Urteil ist das Einzige, was ein Team wirklich mitnehmen kann, wenn der erste Wurf längst gelöscht ist.

Kommentare