Hero Image
- Philipp Ludewig

Learning Log 1: How to TDD in the age of AI

Nach einem Jahr des Reisens ist es wieder Zeit in die Tasten zu hauen. Aber was ist jetzt der beste Weg? Das heißt es nun vor den nächsten Job-Interviews herauszufinden. Den Anfang mache ich mit einem Vortrag von Matteo Vaccari über TDD im Zeitalter von AI. Ich frage mich: Kann ich Tests trauen, die ein Agent selbst schreibt? Was bringt es, Testfälle als Spezifikationen festzulegen und wann sollte ich im Entwicklungsprozess mit dem Agenten interagieren? Ich habe mir ein kleines Beispiel bauen lassen, Fehler eingebaut und aufgeschrieben, was ich gelernt habe. Viel Spaß!

Nach zweijährigem Hiatus von Deutschland bin ich nun heimgekehrt. Wer meine sporadischen Updates verfolgt hat, weiß, dass wir erst in Kanada gelebt und dann eine einjährige Reise um die Welt angetreten haben. Es fühlt sich verrückt an, nach der ganzen Zeit wieder in Deutschland zu sein. Die Temperaturen sind noch gar nicht so niedrig, und dennoch friere ich schon als ob es tiefer Winter wäre. Nach dem ewigen Sommer, den wir auf der Reise erlebt haben, sind nun die Bäume endlich in gelb-orange Farbe getaucht. Unsere Reise führte uns durch Zentral- und Südamerika nach Südost- und Ostasien. Wir haben so viel erlebt, das müsste ich erst einmal verarbeiten, doch eine Verschnaufpause ist uns nach dieser Reise noch nicht vergönnt.

Meine Ersparnisse sind nach diesem Jahr deutlich geschrumpft, sodass nun eine neue Einnahmequelle vonnöten ist. Da kam es mir ganz recht, dass mich am Anfang der Woche gleich zwei Recruiter:innen angeschrieben haben. Das freut mich natürlich aber nun muss ich erstmal so viel nachholen um mich im Interviews nicht zu blamieren. Ich kann an einer Hand abzählen, wie oft ich im letzten Jahr Zeit hatte, etwas zu programmieren. Meine Ex-Arbeitskollege Chris von meiner Zeit bei DNAstack, erzählte mir das er nur noch Agenten für sich programmieren lässt. Wie funktioniert sowas und bekommt man damit sauberen Code zustande? Das gilt es nun herauszufinden!

Der Vortrag: Green or to Green, ist das Feature fertig oder flunkert der Agent?

Den Anfang mache ich mit einem Vortrag von Matteo Vaccari: A Harness for Behaviour: Ensuring Ai-generated Code Does What We Want - TDD in the Age of AI. Darin berichtet Matteo über seine Herangehensweise mit Agenten zu entwickeln. Kurz gesagt: Beim Programmieren mit Agentic AI können wir den Agenten nicht vertrauen. Wir können auch Tests, die ein Agent schreibt, nicht vertrauen, Stichwort Reward Hacking. Aber auch am Anfang der Entwicklungsphase, bei der ersten iteration eines Tests, gibt es eine Schwäche. Birgitta Böckeler hat TDD im Agent-Loop untersucht (Artikel). Sie weist darauf hin: Schreibt der Agent Test und Code selbst, zeigt ein roter Test nur, dass der Agent ihn ausgeführt und ein Scheitern gesehen hat. Ob der Grund der richtige war, sagt das nicht. Was ist also die Lösung?

Reward Hacking: KI-Agenten werden darauf trainiert, ein messbares Ziel zu erreichen, zum Beispiel einen bestehenden Test. Die Organisation METR hat beobachtet, dass aktuelle Modelle dabei teils schummeln: Sie verändern die Tests oder den Bewertungscode, schauen sich eine vorhandene Lösung ab oder nutzen Lücken in der Aufgabe aus, statt das eigentliche Problem zu lösen (METR, 2025). Im Alltag sieht das so aus: Statt den Produktionscode zu implementieren, passt der Agent die Assertions an, bis der Test besteht. Laut METR wissen die Modelle dabei oft sogar, dass sie gegen die Absicht der Nutzer handeln. Ein aktueller Überblick des MIRI kommt zu dem Schluss, dass leistungsfähigere Modelle raffinierter schummeln und zunehmend bei Tests betrügen (MIRI, 2026).

Die Lösung, die Matteo Vaccari vorschlägt: Die Akzeptanzkriterien werden als Customer Tests formuliert. Die Fälle stehen als Spezifikationen in Dateien, in seinem Beispiel als YAML-Tabellen, und ein kleiner TestRunner führt sie aus. Das System bleibt dabei eine Black Box. Der Agent darf diese Dateien nicht umschreiben, um die Tests grün zu bekommen. Er muss zwingend den Code ändern. Das ist dem Ansatz "Approved Scenarios" von Ivett Ördög nah, den Böckeler als guten Baustein für Vertrauen in Tests nennt. Sie beschreibt ihn als halbmanuelles Testen mit einem eigenen Runner pro Anwendung: Man bestätigt Szenarien gründlich und friert sie ein, und jede spätere Verletzung muss man neu freigeben.

Mein Aha-Moment: Ich hatte Matteo so verstanden das ein Agent mittels der Spezifikationen die Tests und den Code erstellt. Stattdessen enthalten die YAML Dateien nun die Testcases welche in den Test klassen wie bei einem Parametrisierten Test aufgerufen werden. Smart, denn dadurch fällt sofort auf wenn ein Agent versucht zu schummeln, indem es die YAML Datei ändert.

Ausprobieren geht über Studieren

Ok genug der Theorie, wie könnte dieser Ansatz in einem Projekt aussehen? Um das zu verstehen, habe ich mit Claude (Opus) zuerst das Gelernte aus dem Video diskutiert. Aus diesem Gespräch ist ein Prompt entstanden, mit dem mir ein Agent lokal ein Beispiel erstellte. Das sollte mir zeigen, wie YAML-Fälle, Reader und Runner konkret aussehen.

Spoiler: Hier das prompt zum auszuprobieren

Baue mir ein kleines, vollständiges Java-Beispielprojekt, das zeigt, wie man Tests als YAML-Tabellen ("Customer Tests") statt als handgeschriebenen Testcode organisiert, und wann Mockito dabei trotzdem sinnvoll ist. Das Projekt dient zum Lernen, also soll es klein und gut lesbar sein.

Technik

  • Java 21, Spring Boot 3, Maven, JUnit 5
  • YAML einlesen mit Jackson (jackson-dataformat-yaml)
  • Mockito (mockito-core, über spring-boot-starter-test)
  • Alles im Speicher, keine Datenbank

Fachlich

Preise in einer Fremdwährung anzeigen.

  • PriceController: GET /price?item=apfel&currency=USD
  • PriceService: holt den Wechselkurs über das Interface ExchangeRateClient, nutzt PriceConverter, gibt das Ergebnis zurück.
    • Ist der Kurs nicht erreichbar: fachlicher Fehler RATE_UNAVAILABLE UND es wird genau einmal ein Alarm über AlertSender verschickt.
    • Unbekannte Währung: UNKNOWN_CURRENCY (kein Alarm).
    • Unbekanntes Produkt: UNKNOWN_ITEM (kein Alarm).
  • PriceConverter: reine Funktion, wendet den Kurs an und rundet auf 2 Nachkommastellen (HALF_UP). BigDecimal. Negative Beträge und 0 sind gültige Eingaben.
  • ExchangeRateClient: Interface. HttpExchangeRateClient ist die echte Implementierung (HTTP-Aufruf, URL konfigurierbar).
  • AlertSender: Interface zur Außenwelt (z. B. E-Mail an den Betrieb). LoggingAlertSender ist die einfache Implementierung, die nur ins Log schreibt.
  • Kleiner fester Produktkatalog im Code (apfel = 1.00 EUR, birne = 2.50 EUR).
  • Basiswährung ist EUR.

Verzeichnisstruktur (genau so anlegen)

src/main/java/com/example/shop/ ShopApplication.java, PriceController.java, PriceService.java, ExchangeRateClient.java, HttpExchangeRateClient.java, AlertSender.java, LoggingAlertSender.java, PriceConverter.java

src/test/java/com/example/shop/ support/YamlCaseLoader.java acceptance/AcceptanceCasesTest.java service/ServiceCasesTest.java service/FakeExchangeRateClient.java service/RateAlertTest.java (Mockito, siehe unten) converter/PriceConverterCasesTest.java

src/test/resources/cases/ acceptance/.yaml, service/.yaml, converter/*.yaml

Lege außerdem eine CLAUDE.md im Projektwurzelverzeichnis an mit den Testregeln aus dem Abschnitt "Regeln" unten.

Testprinzipien

  1. Die YAML-Dateien SIND die Tests. Es wird kein Testcode aus ihnen generiert. Pro Ebene gibt es genau einen Runner (@TestFactory), der alle YAML-Dateien seines Ordners liest und pro Fall einen DynamicTest erzeugt.
  2. Black Box: nur Eingabe und sichtbares Ergebnis. Keine Prüfung, wer wen aufruft, außer in RateAlertTest (siehe unten).
  3. Daten statt Code: Ein neuer Fall ist ein neuer YAML-Eintrag, kein neuer Java-Code.
  4. Eigene Klassen werden nicht gemockt. PriceService läuft in den Tests mit dem ECHTEN PriceConverter. Ersetzt wird nur, was an die Außenwelt geht.
  5. Jede Ebene hat ihre eigene Mini-Sprache in Fachbegriffen:
    • converter: Tabelle mit amount, rate, expect. Direkt auf PriceConverter. Viele Fälle: Rundung (z. B. 1.005), 0, negativ, große Beträge.
    • service: given (Wechselkurs oder rate_service: unavailable), when (price mit item und currency), then (price oder error). Der Runner baut PriceService mit FakeExchangeRateClient, der aus given gefüttert wird, und einem AlertSender, der nichts tut. Fälle: Normalfall, Kurs nicht erreichbar, unbekannte Währung, unbekanntes Produkt.
    • acceptance: given rates, when get_price, then status und price. Der Runner geht durch den echten Controller (@SpringBootTest mit MockMvc). Der externe Endpunkt wird durch einen kleinen stateful Simulator ersetzt (eingebetteter JDK-HttpServer, der Kurse aus given ausliefert). Nur wenige Fälle, nur Verdrahtung und HTTP-Statuscodes. Rundung wird hier NICHT noch einmal getestet.
  6. Nichts doppelt testen: Rundung nur in converter, Geschäftsregeln nur in service, Verdrahtung nur in acceptance.
  7. Bei einem fehlgeschlagenen Fall gibt der Runner den Namen des Falls und den Dateinamen aus.

Mockito: wann ja, wann nein

Es gibt genau einen Test, der Mockito mit verify einsetzt: RateAlertTest (normaler JUnit-5-Test, keine YAML-Datei). Er soll zeigen, wo das sinnvoll ist, weil der Aufruf selbst das Verhalten ist, das der Betrieb sieht.

  • ExchangeRateClient wird mit Mockito GESTUBBT (when(...).thenReturn(...) bzw. thenThrow(...)), AlertSender wird gemockt.
  • Fall 1: Kurs nicht erreichbar -> RATE_UNAVAILABLE UND verify(alertSender, times(1)).send(...)
  • Fall 2: Kurs erreichbar -> verifyNoInteractions(alertSender)
  • Fall 3: unbekannte Währung -> verifyNoInteractions(alertSender)
  • Der echte PriceConverter wird NICHT gemockt.
  • Schreibe über die Testklasse einen kurzen Kommentar, warum hier verify erlaubt ist: Der Alarm ist ein sichtbarer Effekt auf die Außenwelt, nicht ein interner Aufruf.
  • Zeige in einem zweiten Abschnitt der README ein BEWUSST SCHLECHTES Gegenbeispiel (nur als Text-Codeblock, NICHT als ausführbaren Test): ein Test, der PriceConverter mockt und nur verify(converter).convert(...) prüft, und erkläre in 3 Sätzen, warum der nichts beweist und beim Refactoring bricht.

Regeln für die CLAUDE.md

  • Tests sind YAML-Dateien in src/test/resources/cases/. Pro Ebene ein Runner.
  • Dateien in src/test/resources/cases/ nur ändern, wenn ich es ausdrücklich sage. Wenn ein Fall geändert werden müsste, frag mich zuerst.
  • Keine Mocks für eigene Klassen. Mockito nur zum Stubben von Rückgabewerten an der Grenze zur Außenwelt (HTTP, Datenbank, E-Mail, Zahlung).
  • verify() nur, wenn der Aufruf selbst das fachliche Verhalten ist (z. B. "genau ein Alarm geht raus"), und nur wenn ich es ausdrücklich erlaube.
  • Keine Commits mit roten Tests. Keine Tests überspringen oder löschen.

Ablauf

  1. Zeig mir zuerst die geplante Mini-Sprache pro Ebene mit je einem Beispielfall und den Plan für RateAlertTest. Warte auf mein Okay, bevor du alles baust.
  2. Dann baue alles komplett, inklusive aller Klassen mit Logik und aller Fälle (ca. 8 bis 10 converter, 4 bis 5 service, 2 bis 3 acceptance, 3 in RateAlertTest).
  3. Führe mvn test aus und stelle sicher, dass alles grün ist.
  4. Fault-Injection-Demo: Baue nacheinander vier plausible Fehler in den Produktionscode ein: falscher Rundungsmodus, vertauschte Währungsrichtung, falscher Fehlercode, und der Alarm wird bei RATE_UNAVAILABLE zweimal verschickt. Zeige jeweils, welcher Test den Fehler fängt, und mache ihn wieder rückgängig.
  5. Refactoring-Demo: Verschiebe die Umrechnung aus PriceService in eine neue Klasse (PriceCalculator), ohne das Verhalten zu ändern. Zeige, dass alle YAML-Fälle und RateAlertTest grün bleiben, und erkläre, warum das mit einem gemockten Converter nicht so gewesen wäre. Mache es danach wieder rückgängig.
  6. Erstelle eine kurze README.md mit Struktur, Prinzip und dem Abschnitt zum schlechten Gegenbeispiel.

Das Ergebnis des Prompts war bei mir ein solides Java Repository. Die geforderten Fälle hat der Agent als YAML-Dateien im Projekt abgelegt, und eine Hilfsklasse liest sie in den Tests ein. Das erinnert mich schon sehr an Behaviour Driven Development mit Cucumber.

Spoiler: Beispiel YAML-Datei
# Normalfälle: Preis aus dem Katalog (EUR) in der gewünschten Währung.
cases:
 - name: Birne in USD
   given:
     rates: { USD: "1.10" }
   when:
     price: { item: birne, currency: USD }
   then:
     price: "2.75"

 - name: Euro braucht keinen Kurs, auch wenn der Kursdienst ausfällt
   given:
     rate_service: unavailable
   when:
     price: { item: apfel, currency: EUR }
   then:
     price: "1.00"
Spoiler: Beispiel Testklasse
class ServiceCasesTest {

   @TestFactory
   Stream<DynamicTest> serviceCases() {
       return YamlCaseLoader.tests("service", c -> {
           PriceService service = new PriceService(givenRates(c), new PriceConverter(), message -> { });

           PriceService.Result result = service.price(c.text("/when/price/item"), c.text("/when/price/currency"));

           if (c.has("/then/price")) {
               assertNull(result.error(), "unerwarteter Fehler");
               assertEquals(c.text("/then/price"), result.price().toPlainString());
           } else {
               assertEquals(c.text("/then/error"), String.valueOf(result.error()));
           }
       });
   }
   private static FakeExchangeRateClient givenRates(Case c) {
       FakeExchangeRateClient rates = new FakeExchangeRateClient();
       if (c.has("/given/rate_service") && c.text("/given/rate_service").equals("unavailable")) {
           rates.makeUnavailable();
       }
       c.data().at("/given/rates").properties()
               .forEach(e -> rates.setRate(e.getKey(), new BigDecimal(e.getValue().asText())));
       return rates;
   }
}

Dann habe ich es ausprobiert und selbst zwei Fehler in den Code eingebaut, einen anderen Rundungsmodus und einen falschen Rückgabewert im PriceService. Beide Male schlug ein Test fehl. Auch das Refactoring gelang ohne Probleme, die Entkopplung funktioniert also. Die Komplexität des Codes ist natürlich sehr gering. Ob das dann im Alltag funktioniert wird sich zeigen. Was sind deine Erfahrungen dazu?

Mein aktualisierter Workflow

Ich denke so werde ich ab jetzt ein Feature in Zukunft implementieren:

  1. Feature im Plan-Modus beschreiben.
  2. Zusammen mit der AI die Testfälle entwerfen und als YAML-Dateien ablegen.
  3. Den Agenten das Feature implementieren lassen, ohne die freigegebenen Fälle zu ändern.
  4. Das Ergebnis reviewen.

Der Schutz vor Agenten-Trickserei liegt in der Trennung: Die Fälle sind Daten, der Runner ist Code. Eine geänderte erwartete Zahl fällt im Diff sofort auf, eine gelockerte Assertion im Testcode weniger. Matteo betont vor allem "Daten statt Code".

Vor einem Jahr konnte ich bereits Erfahrungen mit Agentic Developement bei DNAstack sammeln. Jeden Test den ein Agent damals schrieb, und die liebten viele Tests zu schreiben, musste ich überprüfen. Die Pull Requests der anderen Entwickler:innen wurden mit steigender Adaption von agentic supported development über die Zeit immer größer. Das führte dazu das ich mehr Zeit mit reviews der PRs verbrachte als mich um meine Tickets zu kümmern. Um die Entwickler:innen bei den Reviews zu entlasten und Cognitive Debt zu bekämpfen, also die wachsende Lücke zwischen dem vorhandenen Code und dem, was das Team davon versteht, haben wir angefangen, kleinere Pull-Requests zu schreiben. Das half uns wieder produktiver zu werden. Umso toller finde ich es, dass Javier López Fernández in seinem Artikel "How to talk with AI?" ein großes Review am Ende ebenfalls für überholt hält. Er will kleine Interaktionen mit der AI: pro Schritt eine einzelne Test-Assertion oder eine kurze Interface-Definition, die sich in Sekunden prüfen lässt, dazu Trunk-Based Development und laufendes Review. Für mich klingt das weniger danach, die Programmierung an einen Agenten abzugeben, und mehr nach gemeinsamem Arbeiten, ähnlich wie Pair Programming. Mein Ablauf lässt den Agenten dagegen ein ganzes Feature allein umsetzen. Javiers Weg gefällt mir bisher eher. Ich vertraue den Agenten aus meiner Erfahrung nicht zu qualitativen Code zu schreiben. Es wird sich zeigen ob Agentic-Loop oder Agentic Pairing besser performen wird. Ich habe da ein OpenSource Projekt mit dem ich das in naher Zukunft mal ausprobieren werde. Bleibt also gespannt!

Was haben wir nun gelernt?

Der Exkurs über den Vortrag von Matteo und die Artikel von Birgitta Böckeler und Javier López Fernández hat mir einiges klarer gemacht. Einem Agenten und den Tests, die er selbst schreibt, kann ich nicht ohne Weiteres trauen. Das kannte ich schon aus meiner Arbeit bei DNAstack. Wenn die Testfälle als Daten vorab festliegen, fällt jede Änderung daran im Review auf, sofern ich zuerst allein die Testdateien erstelle. In meinem kleinen Beispiel haben die Tests die beiden eingebauten Fehler gefangen, und das Refactoring hat auch nichts kaputt gemacht.

Eine Best Practice gibt es aus meiner Sicht noch nicht. Mein Eindruck ist, dass es zwei Lager gibt: kleine Schritte mit laufendem Review und Trunk-Based Development (López Fernández) auf der einen Seite, Spezifikation vorab und ein Agenten-Loop (Vaccari, Approved Scenarios von Ivett Ördög erweitern) auf der anderen.

Wo es bei mir hakt

Der TestRunner ist ja Java-Code und in meinem Beispiel durch nicht geschützt. Nur die YAML-Dateien sind per Regel in der CLAUDE.md gesperrt. Ein Agent könnte also am TestRunner schummeln.

Hierbei kannst du dich zwischen den Lagern entscheiden. Entweder bist du Teil des optimistischen Lagers, dann wirst du noch mehr AI einsetzten: Ein zweiter Agent prüft, ob die Fälle wirklich eingelesen und ausgeführt werden. Er hätte ein anderes Ziel, nämlich Probleme zu finden, während der erste Tests grün bekommen soll. Ob das funktioniert, weiß ich nicht. Beide Agenten laufen meist auf demselben Modell, und auch ein Prüfer könnte sich die Arbeit leicht machen.

Im anderen Lager das AIs kritischer sieht würde ich vermuten das der Mensch mehr gefragt ist. Nur ein händisches Review kann wirkliche Klarheit schaffen ob die Tests testen was sie sollen. Ich schätze da werden wir nicht herumkommen.

So geht es weiter!

Das ist erst der Anfang dieser Serie. Als Nächstes will ich verstehen, was ein überhaupt ein Harness ist und was sind eigentlich Spec-Files? Danach geht es um Token-Ökonomie: Wann nehme ich welches Modell, wie wähle ich es automatisch für jede Aufgabe, wie senke ich die Kosten, und wie verfolge ich verbrauchte Token über die Zeit? Außerdem ist da noch die Frage, ob Code überhaupt noch gelesen werden sollte. Außerdem was ist eigentlich dieses neue ATDD? Du siehst ich stehe noch vor einem guten Berg an offenen Fragen, aber jetzt muss ich erstmal Fotos der Reise sortieren und meine Shorts gegen lange Hosen tauschen.

Während ich diesen Post verfasse, erreicht mich die traurige Nachricht, dass Patrick Stewart verstorben ist. Er wird für mich immer der beste Captain der Enterprise sein. Rest in Peace und Godspeed, Captain 🖖