Ein Test, der nach dem Code geschrieben wird, prüft meist, was der Code tut, nicht, was er tun sollte. Testgetriebene Entwicklung (TDD) behebt das, indem sie die Reihenfolge umdreht: Sie schreiben den Test, sehen ihn fehlschlagen und schreiben dann den kleinsten Code, der ihn bestehen lässt. Weil der Test existiert, bevor der Code existiert, den er prüft, kann er nicht stillschweigend bestätigen, was der Code gerade zufällig zurückgibt. Kent Beck hat diese Schleife in seinem Buch Test-Driven Development: By Example von 2002 als red, green, refactor formalisiert. Der Name ist geblieben.

Was der Red-Green-Refactor-Zyklus wirklich macht

Jeder Durchlauf des Zyklus hat drei Schritte, und einen davon zu überspringen macht daraus etwas anderes.

Red. Schreiben Sie einen Test für ein Verhalten, das noch nicht existiert, und führen Sie ihn aus. Er muss fehlschlagen. Besteht er sofort, existiert das Verhalten entweder schon, oder der Test prüft nichts Echtes. Ein roter Lauf ist der Beweis, dass der Test den Bug erkennen kann, den er erkennen soll.

Green. Schreiben Sie die kleinstmögliche Menge Produktionscode, die den Test bestehen lässt. Nicht den besten Code. Den minimalen Code. Einen hartkodierten Rückgabewert zu liefern ist ein legitimer Green-Schritt, solange Sie wissen, dass Sie genau das getan haben, und nicht dabei stehen bleiben.

Refactor. Bei bestehendem Test räumen Sie auf, was Sie gerade geschrieben haben: Duplikate entfernen, oder eine Funktion extrahieren, wenn das gerechtfertigt ist. Der Test muss dabei durchgehend grün bleiben, denn nur so wissen Sie, dass sich das Verhalten nicht geändert hat, nur die Form des Codes.

Dann wählen Sie das nächste Verhalten und fangen von vorn an. Eine Iteration dauert Minuten: ein if-Zweig, ein Randfall, nie ein ganzes Feature.

Ein TDD-Beispiel in Python: die FizzBuzz-Kata

FizzBuzz ist klein genug, dass Sie den Rhythmus sehen statt des Problems. Für eine gegebene Zahl wird "Fizz" zurückgegeben, wenn sie durch 3 teilbar ist, "Buzz", wenn sie durch 5 teilbar ist, "FizzBuzz", wenn sie durch beide teilbar ist, und sonst die Zahl selbst.

Beginnen Sie mit dem Fall, der keines davon ist:

1
2
3
4
5
6
# test_fizzbuzz.py
from fizzbuzz import fizzbuzz


def test_returns_the_number_as_a_string():
    assert fizzbuzz(1) == "1"
1
2
3
4
$ pytest -q
ImportError while importing test module 'test_fizzbuzz.py'.
E   ModuleNotFoundError: No module named 'fizzbuzz'
1 error in 0.01s

Rot, und Sie mussten den Fehlschlag nicht künstlich herbeiführen: fizzbuzz existiert noch nicht, also kann pytest das Testmodul nicht einmal importieren. Das ist trotzdem ein gültiges Rot: Die Lösung ist in beiden Fällen dieselbe. Das Kleinste, das es bestehen lässt:

1
2
3
# fizzbuzz.py
def fizzbuzz(n):
    return str(n)
1
2
3
$ pytest -q
.
1 passed in 0.01s

Grün. Nächstes Verhalten: Vielfache von 3.

1
2
def test_returns_fizz_for_multiples_of_three():
    assert fizzbuzz(3) == "Fizz"

Das schlägt fehl (assert '3' == 'Fizz'), und die minimale Lösung ist ein einziger Zweig:

1
2
3
4
def fizzbuzz(n):
    if n % 3 == 0:
        return "Fizz"
    return str(n)

Vielfache von 5 folgen demselben Muster, also fügen Sie den Test und das passende elif hinzu. Schreiben Sie dann den Fall, der die Nachlässigkeit der ersten beiden Schritte abfängt: eine Zahl, die durch beide teilbar ist.

1
2
def test_returns_fizzbuzz_for_multiples_of_fifteen():
    assert fizzbuzz(15) == "FizzBuzz"
1
2
3
4
$ pytest -q
...F
E   assert 'Fizz' == 'FizzBuzz'
3 passed, 1 failed in 0.01s

15 % 3 == 0 wird zuerst geprüft, also gibt die Funktion "Fizz" zurück, bevor sie überhaupt bei 15 % 5 nachsieht. Hier zählt die Reihenfolge. Die Lösung ist ein Zweig für 15, der vor den anderen beiden läuft:

1
2
3
4
5
6
7
8
def fizzbuzz(n):
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
1
2
3
$ pytest -q
....
4 passed in 0.01s

Wieder grün, und jetzt hat die Testdatei etwas, das sich zu refactoren lohnt: vier fast identische Funktionen. Sie zu einem einzigen parametrisierten Test zusammenzufassen zählt genauso als Refactor wie alles, was Sie an fizzbuzz.py tun würden, solange das Grün die ganze Zeit hält.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    "n, expected",
    [
        (1, "1"),
        (3, "Fizz"),
        (5, "Buzz"),
        (15, "FizzBuzz"),
    ],
)
def test_fizzbuzz(n, expected):
    assert fizzbuzz(n) == expected

Der Reihenfolge-Bug kam nur ans Licht, weil der Test für 15 schon vor der Lösung existierte. Schreiben Sie den Code zuerst und hören Sie bei “3 ergibt Fizz, 5 ergibt Buzz, sieht fertig aus” auf, und fizzbuzz(15) gibt "Fizz" zurück, ohne dass etwas in der Suite das anzeigt.

Warum test-first geschriebener Code lose gekoppelt bleibt

TDD verändert das Design, bevor überhaupt ein Test läuft, weil Sie den Code so schreiben müssen, dass der Test ihn aufrufen kann. Nehmen Sie eine Klasse, die sich ihr Datenbank-Repository selbst intern baut. Genau mit diesem Muster beginnt Was ist Dependency Injection?. Sie können ihr in einem Test keine Attrappe übergeben, weil nichts in ihrer Signatur das Austauschen erlaubt. Den Test zuerst zu schreiben drängt diese Abhängigkeit in einen Parameter oder ein Konstruktor-Argument, noch bevor der Produktionscode überhaupt existiert, der sich dagegen sträuben könnte. Die lose Kopplung fügen Sie nicht nachträglich als Bonus hinzu. Ohne sie läuft der Test, den Sie gerade schreiben wollen, nicht.

Derselbe Druck bringt Funktionen dazu, nur eine Sache zu tun. Muss ein Test drei nicht zusammenhängende Zustände aufbauen, um ein Verhalten zu prüfen, macht die getestete Funktion drei nicht zusammenhängende Dinge. Teilen Sie sie auf, und der Test wird kürzer.

Wann TDD nicht verwenden

Der Zyklus braucht ein paar Voraussetzungen: Sie kennen das gewünschte Verhalten, bevor Sie es schreiben, die Tests laufen in Sekunden erneut, und der Code hat eine Stelle, an der ein Test-Double eingesetzt werden kann. Fehlt eine davon, zahlt sich der Zyklus nicht mehr aus. Selbst wenn alle zutreffen, hat er einen blinden Fleck.

Sie kennen die Form der Sache noch nicht. Wenn Sie eine unbekannte API erkunden oder einen Spike schreiben, um zu sehen, ob ein Ansatz überhaupt machbar ist, schreiben Sie zuerst Wegwerfcode. Lernen Sie, wie die richtige Abstraktion aussieht, und schreiben Sie dann Tests gegen die Version, die Sie behalten wollen. Tests für Code, den Sie gleich löschen, sind doppelt investierter Aufwand.

Die Feedback-Schleife ist langsam. TDD bedeutet, Tests nach jeder kleinen Änderung erneut laufen zu lassen. Eine Suite, die bei jedem Lauf eine echte Datenbank oder das Netzwerk anfasst, macht aus jedem dieser Wiederholungen eine Wartezeit. Unit-Tests vs Integrationstests vs End-to-End-Tests erklärt, warum die Geschwindigkeit auf Unit-Ebene daher rührt, dass Sie diese Abhängigkeiten austauschen. Wenn die einzigen Tests, die Sie für ein Stück Code schreiben können, langsame Integrationstests sind, bedeutet Test-Driving dieses Codes Warten, und Menschen hören auf, Dinge zu tun, die sie warten lassen.

Es gibt noch keine Nahtstelle. Legacy-Code mit Konstruktoren, die direkt auf echte Infrastruktur zugreifen, und Funktionen, die sich gegenseitig direkt aufrufen, lässt sich von dort aus nicht testgetrieben entwickeln. Es gibt keine Stelle, an der ein Double hineinpasst. Was ist Spaghetti-Code? beschreibt den Weg hinein: ein Characterization-Test, der zuerst das bestehende Verhalten festnagelt, geschrieben gegen den Code, so wie er ist. Sobald dieses Netz existiert, funktioniert Red-Green-Refactor für die nächste Änderung.

Zu viel Mocking lässt schlechtes Design trotzdem durch. Ein Test, der jede Abhängigkeit mockt, kann bei einer Funktion grün werden, die intern ein Chaos ist, solange ihre Aufrufe dem entsprechen, was die Mocks erwarten. TDD lenkt Ihre Aufmerksamkeit auf die eine Einheit, an der Sie gerade arbeiten. Hin und wieder müssen Sie trotzdem einen Schritt zurücktreten und fragen, ob die Einheiten die richtige Form haben.

Wie Sie mit TDD anfangen: einen Bug per TDD fixen

Wählen Sie für Ihren ersten Versuch einen Bug, kein Feature. Schreiben Sie einen Test, der ihn reproduziert: rufen Sie die Funktion mit dem Input auf, der den Fehler auslöst, prüfen Sie den Output, den Sie wirklich wollen, und sehen Sie ihn aus dem richtigen Grund fehlschlagen, bevor Sie die Lösung anfassen. Die Lösung wird zum Green-Schritt, und der Test bleibt in der Suite, damit der Bug nicht unbemerkt zurückkehren kann. Committen Sie pro Durchlauf einmal. Eine Spur kleiner, immer grüner Commits macht git bisect beim nächsten Mal schnell, wenn etwas kaputtgeht und niemand mehr weiß, wo.

Verwandte Artikel