Erste Unit-Tests in Python

https://oer-informatik.de/python_einstieg_pytest

tl/dr; (ca. 8 min Lesezeit): Wir wollen uns jetzt im Kreis drehen: Ziel definieren - umsetzen - aufräumen. In kleinen Schritten. So lange, bis das Programm fertig ist. Diese Technik nennt sich Testgetriebene Entwicklung, dazu benötigen wir das Testframework pytest und wenige Minuten Zeit. Nebenbei lernen wir auch noch Coding-Conventions kennen.

Das Framework pytest

Jedes Programm sollte systematisch getestet werden. Mit manuellen Tests kommt man dabei schnell an die Grenzen: Der Aufruf ist zeitaufwändig und fehlerträchtig. Insbesondere bei Anpassungen des Codes sollte häufig mit Tests sichergestellt werden, dass keine unerwünschte Seiteneffekte auftreten. Diese sogenannten Regressionstests sind nur automatisiert umsetzbar.

“Eye testing with the School Health Services, October 1946” by Queensland State Archives is marked with CC PDM 1.0
“Eye testing with the School Health Services, October 1946” by Queensland State Archives is marked with CC PDM 1.0

Zur Testautomatisierung bietet Python vor allem zwei Frameworks: das integrierte Modul unittest, dass einen objektorientierten Aufbau aufweist (wie viele andere xUnitTest-Frameworks) und das externe Modul pytest.

Ich möchte hier zunächst pytest nutzen, da es einen einfacheren Aufbau hat, ohne Verständnis für die Objektorientierung zu nutzen ist, weniger gegen Python-Konventionen verstößt und mit sehr wenig Boilerplate-Code (umständlicher Code, viel Code um wenig zu sagen) auskommt. Es ermöglicht einfache Testmethoden, die auch direkt in der zu testenden Datei stehen können, muss aber zunächst installiert werden:

Installation von pytest:

Python bietet mit dem Paketverwalter pip (steht für pip installs packages) einen internen Paketverwalter, mit dem wir direkt neue Pakete nachladen können. Wir nutzen dieses Tool um folgende Pakete zu installieren:

  • pytest: Das Testframework selbst

  • pycodestyle: Ein Tool, das Code auf die Einhaltung von Coding-Conventions hin überprüft

  • pep8: Ein Dokument, in dem die gängigen Coding-Conventions für Python festgeschrieben sind

  • pytest-pycodestyle: Verknüpft die Überprüfung von pycodestyle mit pytest

In einer Shell der Wahl im jeweiligen Betriebssystem können die für pytest notwendigen Pakete mit entsprechenden Benutzerrechten folgendermaßen installiert werden:

#> pip install -U pytest pycodestyle pep8 pytest-pycodestyle

Hinter einem Proxy muss die Option --proxy http://IP_DES_PROXYS:PROXYPORT angefügt werden, beispielsweise in meinem Fall:

#> pip install --proxy http://10.1.1.3:8080 -U pytest pycodestyle pep8 pytest-pycodestyle

Sollte pip nicht gefunden werden, ist vermutlich der Python-Pfad nicht in der lokalen PATH-Umgebungsvariable gesetzt.

In der Windows-PowerShell kann man schnell mit $env:path -split ";" prüfen, ob sich Python in dieser Liste befindet und ggf. für eine Powershell-Sitzung mit Hilfe von $Env:Path += ";c:\dieser\pfad" ergänzen.

Unter Linux kann man den Pfad mit echo $PATH ausgeben und temporär für diese Terminal-Sitzung ergänzen mit PATH=$PATH:/dieser/pfad

Hat die Installation geklappt? Dann müsste sich die installierte pytest-Version aus der Shell heraus anzeigen lassen:

#> pytest --version
pytest 6.2.5

Wenn Pytest nicht gefunden wird, kann es auch helfen, Python selbst zu bitten, das Modul auszuführen (je nach OS ist hier python oder python3 nötig):

#> python -m pytest --version
pytest 6.2.5

Erster Test-Test

In einem ersten Beispiel soll eine Funktion testgetrieben entwickelt werden, die das Produkt zweier Ganzahlen über Summen darstellt. Als Rumpf können wir die Imports, die leere Funktion und ein Funktionsaufruf dienen, den wir in einer Datei multiply_kata.py speichern:

Die Funktion multiply() enthält noch keinerlei Logik, kann aber schon getestet werden. Wir fügen einen einfachen ersten Test ein:

Noch wird dieser Test scheitern. Aber das wollen wir erstmal selbst sehen.

Aufruf der Tests

Wir können diesen Test folgendermaßen aufrufen (oftmals übernimmt dies die genutzte Entwicklungsumgebung, wir wollen aber mal über die Console ’ran:)

pytest -v .\multiply_kata.py

Wollen wir zusätzlich die Coding-Conventions überprüfen:

pytest --pycodestyle -v .\multiply_kata.py

Um schließlich Details zu erhalten, welche Coding-Conventions hier verletzt wurden, können wir uns weitere Informationen anzeigen lassen:

pycodestyle -v --show-source  --show-pep8 .\multiply_kata.py

Ab jetzt geht’s testgetrieben weiter…

Im Idealfall wird jetzt testgetrieben entwickelt. Im Vorfeld sollten die zu implementierenden Anforderungen soweit bekannt sein, dass für diese systematisch Testfälle ermittelt werden können (Grenzwertanalyse, Äquivalenzklassenbildung).

Die Phasen der Testgetriebenen Entwicklung (test driven development, TDD)
Die Phasen der Testgetriebenen Entwicklung (test driven development, TDD)

Von da an läuft das Programmieren in einem “Red-Green-Refactor”-Zyklus ab, benannt nach den drei Phasen, die immer wieder aufeinander folgen:

  • Red: Ein neuer Testfall wird bestimmt (oder ausgewählt). Die zugehörigen Parameter, Nebenbedingungen und das erwartete Ergebnis werden festgelegt (der Spezifikation entnommen) und dem Testfall ein aussagefähiger Name gegeben. Der Testfall wird implementiert. Zunächst scheitert dieser Testfall noch. (Bei vielen Frameworks wird der Testfall in rot angezeigt, daher der Name der Phase). In dieser Phase wird der zu testende Code selbst nicht angepasst.

  • Green Nun wird der zu testende Code so angepasst, dass er die Spezifikation für diesen Testfall (und zunächst nur diesen) erfüllt. Sonderfälle, die nicht mit diesem Testfall enthalten sind, sollen zunächst noch nicht umgesetzt werden. Es wird so lange Code angepasst, bis der neue Testfall (und alle bisherigen Testfälle, wenn es zu Seiteneffekten kam) erfüllt sind (“grün” werden - daher der Name. Der Testcode selbst wird in dieser Phase nicht verändert.

  • Refactor: Die Aufräumphase. Die Tests sind implementiert , die Anforderungen werden bereits erfüllt. Funktional soll an beidem nicht mehr geändert werden. Aber ist der Code und der Test auch clean? Sind die Namen verständlich, wurden Coding Conventions eingehalten, Designprinzipien und Designpattern korrekt angewendet? Sind Methoden zu groß und müssen ausgelagert werden? Gibt es Redundanzen, die über Umbau eliminiert werden können? In dieser Phase werden Code und Testcode optimiert, ohne das Verhalten selbst anzupassen, beides wird refaktorisiert (daher der Name). Wenn alles sauber ist, geht es in die nächste Iteration.

1. Einstieg: Ein mal eins…

1. Iteration - Red (Test)

Der erste Testfall wurde ja oben bereits erstellt. Wir haben mit dem Test die Anforderung festgelegt und dokumentiert, dass unser Programm die erste Einmaleins-Aufgabe lösen kann. Der Funktionsname fängt mit test an (damit das pytest-Framework ihn findet), wurde mit happy_path bezeichnet, da gewünschtes Verhalten getestet wird:

Die Methode hat nach wie vor keinen Code, lediglich ein pass-Statment:

Daher erwarten wir zu Recht, dass unser 1x1 Test scheitern wird, und so ist es auch:

pytest -v .\multiply.py
Ausgabe von pytest: 100% Failure
Ausgabe von pytest: 100% Failure

1. Iteration - Green (Code)

Nächste Phase: wir fassen den Test nicht mehr an, und versuchen nur, Code zu schreiben, damit der Test bestanden wird. Wir sind mal etwas gemein und stellen uns doof: Der test ist ja erfüllt, wenn unsere Funktion eine “1” zurückgibt. Dann machen wir das doch einfach:

(Das ist natürlich überzogen, aber soll hier das schrittweise Vorgehen verdeutlichen.)

Und siehe da: der Test läuft durch:

1. Iteration - Refactor (Aufräumen)

Wir haben so wenig Code erstellt, da gibt es noch nichts aufzuräumen. Also auf zur nächsten Runde:

2. Das kleine Einmaleins

2. Iteration - Red (Test)

Wir wollen jetzt das kleine Einmaleins umsetzen. Also erstellen wir einen Testfall mit ein paat klassischen 1x1-Aufgaben:

Am Code wird in diese Phase nichts geändert. Wie erwartet scheitert dieser Testfall.

2. Iteration - Green (Code)

Jetzt wird es schon etwas knackiger. Wir wollen das Produkt mit Hilfe von Summen darstellen. Also benötigen wir eine Schleife, die solange Aufsummiert, wie es der zweite Faktor angibt:

Und siehe da: der Test läuft durch:

2. Iteration - Refactor (Aufräumen)

Viel Chaos haben wir noch nicht angerichtet. Müssen irgendwelche Variablen umbenannt werden? Irgendwelche Methoden extrahiert? Nein? Dann weiter zur nächsten Runde!

3. Negative Zahlen

3. Iteration - Red (Test)

Bislang kann unsere Funktion nur mit positiven Zahlen rechnen, das soll sich ändern. Wir erstellen daher einen Testfall mit je einem oder zwei negativen Faktoren:

Erwartungsgemäß scheitert auch diese Funktion. Die erste Zusicherung (-9, 10) läuft sogar bei unserem derzeitigen Programm fehlerfrei durch. Aber mit negativem zweiten Faktor scheitert es.

3. Iteration - Green (Code)

Wir müssen also prüfen, ob faktor_2 negativ ist - und für diesen Fall unsere Rechenvorschrift ändern:

Und siehe da: alle Tests läuft durch:

3. Iteration - Refactor (Aufräumen)

Kritischer Blick auf die Funktions- und Variablennamen, auf die Funktionslänge: Muss irgendetwas angepasst werden? Funktionen extrahiert? Nein? Dann geht es weiter:

4. Argumente mit dem Wert 0

4. Iteration - Red (Test)

Positive und negative Zahlen haben wir probiert. Aber was passiert dazwischen? Es sollte ein Testfall für Argumente mit dem Wert 0 ergänzt werden:

4. Iteration - Green (Code)

Auch das gibt es: der Test wird direkt bestanden. So ein Glück!

4. Iteration - Refactor (Aufräumen)

Ist der Code noch übersichtlich genug? Ein kritischer Blick ist immer gut!

5. Grenzen des Definitionsbereichs

5. Iteration - Red (Test)

Wie verhält sich unsere Funktion an den Grenzen des Definitionsbereichs? Dazu müssen wir natürlich z.T. selbst definieren, was sie machen soll. In einigen Programmiersprachen gibt es eine maximale Ganzzahl, die in einem spezifischen Datentypen abgebildet werden kann. Wenn unser Ergebnis (oder unsere Argumente) sich in dieser Größenordnung befinden, soll ggf. gar kein Wert zurückgegeben werden, sondern z.B. None. Wir kommen also wieder an den Punkt, wo uns die Tests helfen, das Programm und das zugrunde liegende Problem zu verstehen.

Da es keine Obergrenze für int in Python gibt, testen wir einfach mit einer sehr großen Zahl: sys.maxsize ist etwa 9,2 mit 18 Nullen. Mehr ist als Index in Arrays nicht erlaubt. Vorsicht: die beiden auskommentierten Testfälle laufen seeeeeeeeeeeeeeeeeeeeeeehr lange!

5. Iteration - Green (Code)

Auch dieser Test läuft direkt durch. Wie praktisch!

5. Iteration - Refactor (Aufräumen)

Sind die Tests noch in einer sinnvollen Reihenfolge und weiß man direkt, was gemeint ist? Jetzt wäre der richtige Zeitpunkt dafür, Coding Conventions umzusetzen, z.B. auch per pep8.

6. Wir verlassen de Happy Path

6. Iteration - Red (Test)

Was passiert eigentlich, wenn die Funktion nicht so genutzt wird, wie vorgesehen? Wenn z.B. der falsche Datentyp übergeben wird? Wir können selbst (ggf. in Absprache mit dem Product Owner oder Auftraggeber) definieren, wie sich unsere Funktion dann verhalten soll. Es gibt Leute, die meinen, das eine Testsuite (also eine Ansammlung von Testfällen) genau der richtige Ort ist, um so etwas zu dokumentieren.

Was soll eigentlich passieren, wenn statt eines int-Werts ein float übergeben wird? Mit unserer Summen-Methodik wird schnell klar: einer der Faktoren kann ein float sein, ohne, dass unser Algorithmus grundlegend geändert werden muss. Wenn beide Faktoren ein float sind geben wir ein None zurück (bis wir uns eingehender mit Ausnahmenbehandlung beschäftigen).

Da wir float nicht auf Gleichheit prüfen können, prüfen wir auf ein Intervall.

6. Iteration - Green (Code)

Jetzt wird es ein bisschen hakelig: es darf nur faktor_1 ein float sein. Falls faktor_2 eine Gleitkommazahl ist, faktor_1 aber nicht, dann soll einfach getauscht werden. Wenn beide eine Gleitkommazahl sind wird None zurückgegeben:

Darüber hinaus müssen wir prüfen, ob es sich um einen float handelt, den wir verlustfrei in einen int umwandeln können. Falls ja, dann sollten wir das auch tun. Damit im Anschluss die obigen Überlegungen greifen können, müssen wir diesen Codeabschnitt vor den obigen stellen.

Nach ein bisschen Feinjustage wird der Test irgendwann grün:

6. Iteration - Refactor (Aufräumen)

Diesmal haben wir geschludert und sollten den Abschnitt etwas aufräumen. Folgende Sequenz kommt nahezu identisch zweimal vor und hat verdächtig viele Kommentare: hier sollten wir eine Funktion extrahieren:

Die neue Funktion könnte etwa so aussehen:

In der ursprünglichen Funktion verbleiben nur die beiden Aufrufe:

Fazit

Natürlich war das hier deutlich kleinschrittiger, als man das in der Praxis häufig macht. Was bei der Programmierung aber häufig fehlt ist der Mut zu kleinen Schritten. Denn nur so bleibt der Code gut getestet, lesbar, erweiterbar. Und nur so bleiben wir auf lange Sicht schnell und produktiv.

Quellen und offene Ressourcen (OER)

Die Ursprungstexte (als Markdown), Grafiken und zugrunde liegende Diagrammquelltexte finden sich (soweit möglich in weiterbearbeitbarer Form) in folgendem git-Repository:

https://gitlab.com/oer-informatik/python-basics/erste-schritte-mit-python

Sofern nicht explizit anderweitig angegeben sind sie zur Nutzung als Open Education Resource (OER) unter Namensnennung (H. Stein, oer-informatik.de) freigegeben gemäß der Creative Commons Namensnennung 4.0 International Lizenz (CC BY 4.0).

Creative Commons Lizenzvertrag


Hinweis zur Nachnutzung

Dieses Werk und dessen Inhalte sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Erste Schritte mit Python” von Hannes Stein, Lizenz: CC BY 4.0. Die Quellen dieses Werks sind verfügbar auf GitLab.

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: