Testgetriebene Entwicklung

Ein Überblick über testgetriebene Entwicklung.


Testgetriebene Entwicklung (TDD) bezeichnet einen Programmierstil, bei dem Tests geschrieben werden, um die Umsetzung des Programmdesigns im Code zu leiten.

Vor dem Programmieren werden ein oder mehrere Tests geschrieben (insbesondere Unit-Tests). Die Tests sollen einen Aspekt des Verhaltens des Programms abdecken, der sich auf eine einzelne Funktion oder Methode konzentrieren kann. Das Schreiben der Tests ist ein Weg, die Anforderungen an das Programm und die allgemeine Architektur in ein implementierungsspezifisches Design zu überführen. Die Tests werden ausgeführt, und sie sollten fehlschlagen, weil der Code noch nicht implementiert ist. Der Code wird implementiert und die Tests werden erneut ausgeführt. Wenn die Tests bestehen, ist die Implementierung des Verhaltens entweder fertig, oder es müssen vielleicht noch weitere Tests geschrieben werden. Wenn die Tests nicht bestehen, wird der Code debuggt und die Tests werden erneut ausgeführt. Der Zyklus aus Testen und Programmieren wird so lange wiederholt, bis alle notwendigen Tests bestehen. Dann ist die Implementierung für diesen Aspekt des Programmverhaltens fertig ... vorerst.

Refactoring

Refactoring ist das Umschreiben von Code, um sein Design zu verbessern. Es ist nicht einfach nur das Umschreiben von Code, um Fehler zu beheben. Manchmal wird das Ändern von Code, damit er die Tests besteht, als „Refactoring“ bezeichnet. Auch wenn das Ändern des Codes möglicherweise eine Verbesserung des Designs mit sich bringt, um die Tests zu bestehen: Reines Debuggen verbessert nicht unbedingt das Design des Codes und ist damit nicht unbedingt Refactoring.

Das folgende Beispiel zeigt Debuggen ohne Refactoring:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

Das folgende Beispiel zeigt Refactoring und danach Debuggen:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDD und Python auf Exercism

Der Python-Track von Exercism verwendet bei seinen Übungen die TDD-Methodik. Die Unit-Tests sind bereits geschrieben. Du kannst dir die Tests ansehen, um genauer zu verstehen, was nötig ist, damit eine Lösung besteht. Möglicherweise wird dir auch schon ein Stub für die Lösung vorgegeben.

Fehlersuche bei einem fehlgeschlagenen Test im Web-Editor auf Exercism

Wenn bei einer Python-Lösung ein oder mehrere Tests fehlschlagen, haben die entsprechenden Aufgaben keinen grünen Hintergrund. Der erste fehlgeschlagene Aufgabenbereich wird aufgeklappt und seine Kopfzeile sieht ungefähr so aus

Task 1 Extract coordinates -

Ein Klick auf das Minuszeichen klappt die Aufgabe ein, sodass wir uns andere Aufgaben ansehen können. Fürs Erste bleiben wir aber bei dieser Aufgabe.

Darunter befindet sich ein aufgeklappter Test-Bereich, der ungefähr so aussieht

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

wobei Tisbury Treasure die Übung angibt und get_coordinate die fehlgeschlagene Funktion oder Methode.

Test 1 ist normalerweise eine Art Vorlage mit einem Codeabschnitt zum Einrichten der Tests. Es enthält keine Informationen über die konkret fehlgeschlagenen Tests. Gegen Ende steht dort so etwas wie

One or more variations of this test failed. Details can be found under each [variant#].

Ein Klick auf das ⌄ klappt den Test ein.

Darunter befindet sich ein eingeklappter Test, der ungefähr so aussieht:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

Wie es aussieht, hängt von der Breite ab, die für den rechten Bereich eingestellt ist. Ein Klick auf das > klappt den Test auf. Eingabedaten und erwartete Ergebnisdaten werden wahrscheinlich in einem Codeabschnitt angezeigt. Die Daten können für alle Tests dieser Aufgabe gelten. Unten, im Abschnitt Test Failure, steht der genaue Grund, warum dieser Test fehlgeschlagen ist. Das kann ungefähr so aussehen:

AssertionError: ['2A'] != '2A'

In diesem konkreten Fall zeigt es, dass der Rückgabewert ['2A'] nicht gleich dem erwarteten Wert '2A' war.

Wenn wir uns den Code für get_coordinate ansehen, sehen wir, dass er so implementiert ist

def get_coordinate(record):
    return [record[1]]

Wenn wir die eckigen Klammern der Liste entfernen (z. B. return record[1]) und die Tests erneut ausführen, bestehen die Tests für Aufgabe 1.

Wenn eine oder mehrere Aufgaben weiterhin fehlschlagen, wird der obige Prozess mit jeder einzelnen wiederholt, bis alle Tests bestehen.

Manchmal sind die erwarteten Daten und die zurückgegebenen Daten zu groß, um vollständig in den Abschnitt Test Failure zu passen. Das kann ungefähr so aussehen:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

Es sind möglicherweise trotzdem genug Daten vorhanden, um zu erkennen, wo das Problem liegt. Im obigen Fall werden zwei Zeilenumbrüche zurückgegeben (z. B. \n\n(\'Brass Spyglass), obwohl nur einer erwartet wird (z. B. \n(\'Brass Spyglass).

Wenn alle Tests bestehen

Herzlichen Glückwunsch! Alle Tests sind bestanden. Wie geht es weiter? Die Lösung könnte sofort veröffentlicht werden. Oder, jetzt wo der Code funktioniert: Wenn du ihn aus irgendeinem Grund refaktorieren möchtest, kannst du den Code ändern und eine weitere Iteration einreichen. Wenn du denkst, dass der Code besser sein könnte, aber nicht weißt, wie, kannst du Mentoring für die Lösung anfordern. Wenn ein Mentor verfügbar ist, meldet er sich vielleicht bei dir mit Ideen für andere Lösungsansätze. Wenn du deine Lösung veröffentlichst, kannst du Kommentare erlauben, und andere Lernende können die Gelegenheit nutzen, Kommentare zu schreiben oder Fragen zu stellen.

Auf Leistung optimieren

Auch wenn „Vorzeitige Optimierung ist die Wurzel allen Übels“ (ein Ausspruch, der sowohl Tony Hoare als auch Donald Knuth zugeschrieben wird), kommt doch der Moment, in dem man, obwohl die Lösung funktioniert, ihre Leistung verbessern möchte. Einer dieser Momente kann der sein, in dem die Lösung einige Tests besteht, bei anderen aber das Zeitlimit überschreitet. Es kann hilfreich sein, genau zu wissen, wie viel Zeit ein Stück Code benötigt. Mit dem Modul timeit kannst du die Ausführung von Code bis hinunter zu sehr kleinen Zeiträumen messen. Die Funktion timeit kann bis zu fünf Argumente annehmen: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None). Der Parameter stmt legt den tatsächlichen Code fest, der ausgeführt und gemessen wird. Der Parameter number bestimmt, wie oft der Code in stmt ausgeführt wird. Der Parameter setup legt den Code fest, der nur einmal ausgeführt wird, um die Ausführung des Codes in stmt vorzubereiten. Die Ausführungszeit des setup-Codes ist in der Gesamtzeit enthalten. Je öfter der Code in stmt ausgeführt wird, desto weniger fällt die setup-Zeit pro Durchlauf ins Gewicht. Der Parameter timer erlaubt es, einen anderen Timer als den Standard zu übergeben. Das Standardargument für den Parameter timer ist perf_counter, was für die meisten Fälle ausreichen sollte. Das Standardargument für den Parameter number ist 1_000_000. Der Parameter globals gibt einen Namensraum an, in dem der Code ausgeführt wird. Das Standardargument für den Parameter globals ist None.

Das folgende Beispiel zeigt, wie du mit timeit herausfindest, wie lange es dauert, festzustellen, ob ein Satz alle englischen Vokale enthält:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

Eine Million Ausführungen dauerten durchschnittlich 4.965089999896008e-07 Sekunden pro Aufruf (etwa 497 Nanosekunden pro Aufruf).

Im folgenden Beispiel wird geprüft, ob es Zeit spart, den Aufruf von casefold aus der List Comprehension herauszunehmen:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

Eine Million Ausführungen dauerten durchschnittlich 4.923898000270128e-07 Sekunden pro Aufruf (etwa 492 Nanosekunden pro Aufruf). Der Aufruf von casefold außerhalb der List Comprehension sparte also etwa 5 Nanosekunden pro Aufruf, insgesamt also rund 5 Millisekunden bei einer Million Aufrufen.

Mit cProfile kannst du Code ebenfalls profilieren; es ist jedoch nicht so fein, da es nur bis zu Millisekunden hinuntergeht.