Testgetriebene Entwicklung (TDD) Schritt für Schritt

Testgetriebene Entwicklung besteht aus 3 Schritten:

  1. Rot: Schreib ausreichend viele fehlschlagende Unit Tests.
  2. Grün: Implementiere nur so viel Code, dass alle Unit Tests erfolgreich durchlaufen.
  3. Blau: Falls nötig, refaktorisiere den Code und Deine Unit Tests.
Dieser Blog-Artikel demonstriert TDD anhand des Clean Code Bowling Game Katas in Kotlin mit JUnit Tests. Außerdem betrachten wir wie KI unsere Arbeitsweise bei TDD verändert. Passen die Idee von TDD und aktuelle KI-Tools wie GitHub Copilot oder ChatGPT überhaupt noch zusammen?

Testgetriebene Entwicklung (TDD)

Ich kenne testgetriebene Entwicklung aus Extreme Programming (von Kent Beck) und Clean Code (von Robert C. Martin). Beide beschreiben den TDD-Zyklus als Entwicklungsprozess in 3 sich wiederholenden Schritten. Im folgenden Bild seht Ihr den Zyklus mit entsprechenden Farben, abgeleitet aus dem Status der Testergebnisse.


Rot - Test schlägt fehl

In der ersten Phase ROT wird mindestens ein neuer Testfall geschrieben oder ein bestehender erweitert. Wichtig ist dabei, dass dieser Testfall dann fehlschlägt. Daher die Status Farbe Rot. Es ist erlaubt mehrere Testfälle zu schreiben. Wichtig ist vor allem, dass die Tests vor dem eigentlich Code geschrieben werden. Meistens schlägt der angepasste Test aufgrund einer Assertion (Überprüfung einer Testbedingung) fehl. Am Anfang kann der Test auch aufgrund eines Compile-Fehlers fehlschlagen, wenn z. B. eine nicht existierende Klasse getestet werden soll. Weiter unten demonstriere ich das.

Grün - Implementierung des Testfalls

In der zweiten Phase GRÜN wird die Implementierung des Testfalls bzw. der produktive Code geschrieben. Dabei ist es wichtig möglichst minimalistisch vorzugehen. Schreibt nur den notwendigen Code, so dass der Testfall grün wird. Mehr produktiven Code schreibt ihr erst wieder einen nächsten TDD-Zyklus. Ein neuer Testfall zur Überprüfung einer Boolean-Methode erwartet z. B. true als Rückgabe. Die notwendige bzw. minimale Implementierung dieser Methode ist "return true" - mehr nicht!

Manche EntwicklerInnen fangen im ersten Zyklus mit Skeleton Implementierungen an, damit sie fehlschlagende Testfälle aufgrund von Compile-Fehler und die trivialen Methoden Implementierungen überspringen. Passt dabei aber auf, dass ihr wirklich bei einer Skeleton Implementierung bleibt, da ihr ansonsten keine testgetriebene Entwicklung mehr macht.

Blau - Refactoring

Die dritte Phase ist optional. Aus Clean Code Büchern wisst ihr, wie wichtig sauberer Code ist. Nehmt euch immer die Zeit zu refaktorisieren bzw. aufzuräumen, wenn alle Test grün sind. Dann seht ihr durch die Tests direkt, ob eure Änderungen etwas kaputt gemacht haben oder ob das Refactoring erfolgreich war und alles funktioniert. Das Refaktorisieren betrifft sowohl den produktiven Code als auch die Unit Tests. Sauberer Code wird überall benötigt, schaut euch dazu diesen Blog-Artikel an: JUnit5andSpringBootTest.html

TDD Schritt für Schritt Demo

In einem vorherigen Blog-Artikel stellte ich das Bowling Game Kata vor, siehe clean-code-dojo.html. Mittlerweile implementierte ich dieses Kata mittles testgetriebener Entwicklung. Die Implementierung habe ich in der Programmiersprache Kotlin mit JUnit 5 gemacht. Ich zeige euch hier die einzelnen Schritte aus den ersten TDD-Zyklen. Den fertigen Code mit einem Commit nach jedem Zyklus findet ihr in GitHub: https://github.com/elmar-brauch/cleanCodeDojo/.../GameTest.kt

1. Zyklus: ROT

class GameTest {
@Test
fun bowlingGame() {
val game = Game()
}
}

  • Vorab habe ich nur ein leeres Projekt erstellt. Der Test bowlingGame schlägt tatsächlich fehl, weil die Klasse Game zu diesem Zeitpunkt noch nicht existiert - also rot wegen Compile-Fehler.

1. Zyklus: GRÜN

class Game {}

  • Das ist die minimale Implementierung, um den Test aus dem ersten Zyklus grün zu bekommen...
  • Den optionalen Refactoring Schritt können wir überspringen.

2. Zyklus: ROT

@Test
fun bowlingGame() {
val game = Game()
game.role(0)
}

  • Wieder Fehlschlag wegen Compile-Fehler, da Methode role nicht existiert.

2. Zyklus: GRÜN

class Game {
fun role(pins: Int) {}
}

  • Erneut nur eine minimale Implementierung, um den Compile-Fehler zu beheben.
  • Ihr könnt diese Zyklen auch zusammenfassen, indem ihr in ROT direkt den Test aus Zyklus 2 schreibt oder mit der Skeleton Implementierung startet.

3. Zyklus: ROT

@Test
fun roleOnly0() {
val game = Game()
game.role(0)
assertEquals(0, game.score())
}

@Test
fun roleOnly1() {
val game = Game()
game.role(1)
assertEquals(1, game.score())
}

  • Hier hätte man auch 2 Zyklen machen können: Compile-Fehler für Methode score und Test für role(0) und role(1).

3. Zyklus: GRÜN

class Game {
private val pinsRolled = IntArray(21)

fun role(pins: Int) {
pinsRolled[0] = pins
}

fun score(): Int {
return pinsRolled.sum()
}
}

  • Nachträgliche Anmerkung: Für pinsRolled ein Array der Größe 21 zu verwenden, ist zu diesem Zeitpunkt zu früh und ein Verstoß gegen das Prinzip den minimales Code zu schreiben. Ein einfacher Int reicht in diesem Zyklus.

3. Zyklus: BLAU

Die Test-Klasse kann nun refaktorisiert werden, indem wir game als Klassenattribut verwenden:
class GameTest {
val game = Game()

@Test
fun roleOnly0() {
game.role(0)
assertEquals(0, game.score())
}

@Test
fun roleOnly1() {
game.role(1)
assertEquals(1, game.score())
}
}

4. Zyklus: ROT

class GameTest {
private val game = Game()

@Test
fun roleOnly0() {
roleMany(0, 20)
assertEquals(0, game.score())
}

@Test
fun roleOnly1() {
roleMany(1, 20)
assertEquals(20, game.score())
}

private fun roleMany(pins: Int, roles: Int) {
for (i in 1..roles)
game.role(pins)
}
}
  • Statt einem Wurf machen wir nun viele bzw. 20, so dass die Assertion im roleOnly1 Test fehlschlägt.

4. Zyklus: GRÜN

class Game {
private val pinsRolled = IntArray(21)
private var role = 0

fun role(pins: Int) {
pinsRolled[role++] = pins
}
...
}
  • Zum Fixen des fehlgeschlagenen Tests, mache ich nur Anpassungen in der Methode role.

4. Zyklus: BLAU

@ParameterizedTest
@CsvSource("0,0,20,", "20,1,20")
fun roleTest(expectedScore: Int, pins: Int, roles: Int) {
roleMany(pins, roles)
assertEquals(expectedScore, game.score())
}
  • Mittels parametrisierten Tests können wir die JUnit Tests roleOnly0 und roleOnly1 zusammenfassen. Die Testfälle für das perfekte Spiel (nur Strikes) "300,10,12" und nur Spares "150,5,21" können wir dann später einfach in der @CsvSource aufnehmen.
    Weitere Details zu parametrisierten Tests mit JUnit 5 findet ihr hier: JUnit5andSpringBootTest.html

5. Zyklus: ROT

@ParameterizedTest
@CsvSource("0,0,20,", "20,1,20", "150,5,21")
fun roleTest(expectedScore: Int, pins: Int, roles: Int)...
  • In diesem Zyklus füge ich den neuen Testfall "Spare in jeder Runde" bzw. "150,5,21" hinzu. In diesem Testfall wirft der Bowling Spieler mit jedem Wurf 5 Pins um. 

5. Zyklus: GRÜN

class Game {

private val pinsRolled = IntArray(21)
private var currentRole = 0

fun role(pins: Int) {
pinsRolled[currentRole] = pins
currentRole++
}

fun score(): Int {
var sum = pinsRolled[18] + pinsRolled[19] + pinsRolled[20]
for (role in 0..17) {
sum += pinsRolled[role]
if (spare(role))
sum += pinsRolled[role +
2]
}
return sum
}

private fun spare(role: Int) = role % 2 == 0 &&
pinsRolled[role] + pinsRolled[role + 1] == 10
}
  • Hier habe ich ein Refaktoring schon während der Implementierung gemacht, ich habe das Klassen Attribut role in currentRole umbenannt, da es ein Pointer zum aktuellen Wurf ist.
  • Ansonsten wurde die Berechnung der Punkte in der Methode score so angepasst, dass die Punkte des nächsten Wurfs nach einem Spare doppelt zählen.
  • Außerdem habe ich eine Hilfsmethode spare zum Erkennen von Spares geschrieben. Um unseren Test grün zu bekommen, muss ich in diesem Fall noch nicht zwischen Spare und Strike unterscheiden. Daher akzeptiert die spare Methode in diesem Zyklus auch Strikes. 
  • Strikes werden zu diesem Zeitpunkt nicht richtig berechnet, da es dafür auch noch keinen Testfall gibt.

Die restlichen Zyklen...

Mittlerweile sollte das Vorgehen beim testgetriebenen Entwickeln klar geworden sein. In den folgenden Zyklen schreibe ich noch Tests für Spiele mit Strikes und Fehlerbehandlungen, wenn z.B. ein weiterer Wurf gemacht wird, obwohl das Spiel vorbei ist oder eine negative Zahl an die Methode role übergeben wird. Das zeige ich hier aber nicht mehr.

Wenn euch der komplette Code interessiert, schaut ihn euch hier in GitHub an: https://github.com/elmar-brauch/cleanCodeDojo/.../GameTest.kt
Meine Commits entsprechen nicht den Zyklen, sondern den Wechseln beim Bowling Game Kata, wenn es im Wasa von 2 Entwickler implementiert wird 😉
Falls ihr mit den Kampfsport-Begriffen nichts anfangen könnt: clean-code-dojo.html

Auswirkungen von KI auf testgetriebene Entwicklung

Künstliche Intelligenz verändert zunehmend unsere tägliche Arbeit als EntwicklerInnen – auch beim testgetriebenen Entwickeln. Tools wie GitHub Copilot, ChatGPT oder Windsurf schreiben nicht nur Code, sondern auch Testfälle. Doch was bedeutet das für TDD?

KI als Testgenerator

KI kann beim Schreiben von Unit Tests unterstützen, indem sie Vorschläge basierend auf bestehenden Code-Strukturen generiert. Dennoch bleibt es essenziell, Testfälle vor dem produktiven Code zu formulieren – wie es TDD verlangt. Die Überlegung "Was soll mein Code eigentlich tun?" lässt sich nicht an eine KI delegieren. Die Qualität der Tests hängt weiterhin davon ab, wie gut wir das gewünschte Verhalten verstehen und formulieren.

Unterstützung in Phase GRÜN

Wenn es darum geht, die minimale Implementierung zu schreiben, kann eine KI durchaus passende Code-Vorschläge machen. Gerade bei einfachen Funktionen spart das Zeit. Aber Achtung: Wer testgetrieben entwickelt, möchte ganz bewusst nicht sofort die komplette Lösung sehen. Die Herausforderung liegt im disziplinierten Vorgehen – und hier kann ein KI-Codevorschlag vom TDD-Weg ablenken.

Hilfe beim Refactoring

In der optionalen BLAU-Phase kann KI nützlich sein: Sie erkennt doppelte Logik, schlägt sauberere Strukturen vor und hilft beim Umbau von Code. Auch hier gilt: Nutze KI als Assistent, nicht als Architekt. Letztlich entscheidet der Mensch, was „clean“ ist – und was nicht.

KI-Driven Development als Anti-Pattern?

Ein wachsender Trend ist es, zuerst Code durch KI generieren zu lassen und anschließend Tests hinzuzufügen – also genau umgekehrt wie bei TDD. Das führt oft zu Tests, die nur bestätigen, was ohnehin schon programmiert wurde. Die Gefahr: Man verliert den prüfenden, hinterfragenden Blick, der TDD so wertvoll macht.

Fazit

Wenn ihr bisher TDD nicht verwendet habt, hoffe ich, dass euch dieser Artikel zeigt, wie man testgetriebene Entwicklung ganz konkret umsetzen kann – mit kleinen Schritten, klaren Zyklen und einem bewussten Fokus auf das Warum hinter dem Code. Ob mit oder ohne KI: TDD hilft uns, strukturiert zu denken, Fehlverhalten früh zu erkennen und robuste Software zu schreiben.

Künstliche Intelligenz kann dabei eine wertvolle Unterstützung sein – beim Schreiben von Tests, bei der minimalen Implementierung oder beim Refactoring. Aber sie ersetzt keine saubere Denkarbeit. TDD bleibt ein Werkzeug für uns Menschen, um Kontrolle über unsere Software zu behalten – gerade weil KI unsere Entwicklung beschleunigt und automatisiert. Erst testen, dann coden – das ist und bleibt der Kern.

Probiert es doch einfach mal aus – am besten mit einem Kata wie dem Bowling Game. Denn genau dafür wurden sie gemacht: Um gute Entwicklungspraktiken zu trainieren. Schritt für Schritt. Test für Test.

Kommentare

Beliebte Posts aus diesem Blog

CronJobs mit Spring

Kernkonzepte von Spring: Beans und Dependency Injection

OpenID Connect mit Spring Boot 3