Testgetriebene Entwicklung (TDD) Schritt für Schritt
Testgetriebene Entwicklung besteht aus 3 Schritten:
- Rot: Schreib ausreichend viele fehlschlagende Unit Tests.
- Grün: Schreib nur so viel Code (nicht mehr), dass alle Unit Tests grün sind.
- Blau: Falls nötig, refaktorisiere den Code und Deine Unit Tests.
Testgetriebene Entwicklung
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
Blau - Refactoring
TDD Schritt für Schritt Demo
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
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...
Meine Commits entsprechen nicht den Zyklen, sondern den Wechseln beim Bowling Game Kata, wenn es im Wasa von 2 Entwickler implementiert wird 😉
Kommentare