Testgetriebene Entwicklung (TDD) Schritt für Schritt

Testgetriebene Entwicklung besteht aus 3 Schritten:

  1. Rot: Schreib ausreichend viele fehlschlagende Unit Tests.
  2. Grün: Schreib nur so viel Code (nicht mehr), dass alle Unit Tests grün sind.
  3. Blau: Falls nötig, refaktorisiere den Code und Deine Unit Tests.
In diesem Blog-Artikel werde ich das anhand des Clean Code Bowling Game Katas in der Programmiersprache Kotlin mit JUnit Tests demonstrieren.

Testgetriebene Entwicklung

Ich kenne testgetrieben Entwicklung aus Extreme Programming (von Kent Beck) und Clean Code (von Robert C. Martin). Dort wird der TDD-Zyklus als Entwicklungsprozess in 3 sich wiederholenden Schritten beschrieben. Im folgenden Bild zeige ich den Zyklus mit den entsprechenden Farben, die aus dem Status der Testergebnisse abgeleitet sind.


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 auch 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 aber auch aufgrund eines Compile-Fehlers fehlschlagen, wenn z.B. eine Klasse getestet werden soll, die noch nicht existiert. Weiter unten werde ich das demonstrieren.

Grün - Implementierung des Testfalls

In der zweiten Phase GRÜN wird nun die Implementierung des Testfalls bzw. der produktive Code geschrieben. Hier ist es wichtig möglichst minimalistisch vorzugehen und nur den notwendigen Code zu schreiben, damit der Testfall grün wird. Wenn ihr mehr produktiven Code schreiben wollt, müsst ihr erst wieder einen neuen TDD-Zyklus starten. Wenn ich z.B. einen Testfall geschrieben habe, der überprüft, dass eine Boolean-Methode true zurückgibt, so ist die notwendige bzw. minimale Implementierung dieser Methode "return true" - mehr nicht!

Manche EntwicklerInnen fangen im ersten Zyklus gerne 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 solltet ihr wissen, wie wichtig sauberer Code ist. Daher nehmt euch immer dann die Zeit zu refaktorisieren bzw. aufzuräumen, wenn alle Test grün sind. Dann seht ihr nämlich durch die Tests direkt, ob ihr etwas kaputt gemacht habt oder ob das Refactoring erfolgreich war und noch alles funktioniert. Das Refaktorisieren betrifft sowohl den eigentlich Code als auch die Unit Tests. Sauberer Code wird überall benötigt, schaut euch dazu auch gerne diesen Blog-Artikel von mir an: JUnit5andSpringBootTest.html

TDD Schritt für Schritt Demo

In einem vorherigen Blog-Artikel habe ich das Bowling Game Kata vorgestellt, siehe clean-code-dojo.html. Mittlerweile habe ich dieses Kata mittles testgetriebener Entwicklung implementiert. Die Implementierung habe ich in 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()
}
}

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.
  • Die hier gezeigte Implementierung unterscheidet sich auch von der Implementierung aus meinem Blog-Artikel zum Clean Code Dojo und Katas - auch ich will beim Training immer mal was neues machen 😄

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 ist 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

Fazit

Wenn ihr bisher TDD nicht verwendet habt, hoffe ich, dass euch dieser Artikel zeigt, wie man es einfach tun kann. In allen aktuellen Software-Entwicklungsmethoden wird TDD als Vorgehensmodell empfohlen und die berühmtesten Entwickler predigen es. Also versucht es doch auch einmal und macht es am Anfang ruhig anhand von einem Kata, wie dem Bowling Game, denn diese wurde zum Üben gemacht.

Kommentare

Beliebte Posts aus diesem Blog

OpenID Connect mit Spring Boot 3

Authentifizierung in Web-Anwendungen mit Spring Security 6

Validierung per Annotation in Java und Spring