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.

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 selbstpycodestyle
: Ein Tool, das Code auf die Einhaltung von Coding-Conventions hin überprüftpep8
: Ein Dokument, in dem die gängigen Coding-Conventions für Python festgeschrieben sindpytest-pycodestyle
: Verknüpft die Überprüfung vonpycodestyle
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:
import pytest
import sys
def multiply(faktor_1: int, faktor_2: int) -> int:
"""Methode, die ein Ganzzahlprodukt über Summen berechnet"""
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
pass
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
# Beispielaufruf der Methode:
print("Methode, die ein Produkt mit Summen abbildet: 7x8=" +
str(multiply(7, 8)))
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).

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:
def multiply(faktor_1: int, faktor_2: int) -> int:
"""Methode, die ein Ganzzahlprodukt über Summen berechnet"""
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
pass
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
Daher erwarten wir zu Recht, dass unser 1x1 Test scheitern wird, und so ist es auch:
pytest -v .\multiply.py

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:
def multiply(faktor_1: int, faktor_2: int) -> int:
"""Methode, die ein Ganzzahlprodukt über Summen berechnet"""
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
return 1
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
(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:
def test_happy_path_kleines_einmaleins():
""" Ein paar Beispiele aus dem kleinen 1x1"""
assert multiply(4, 7) == 28
assert multiply(6, 9) == 54
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:
def multiply(faktor_1: int, faktor_2: int) -> int:
"""Methode, die ein Ganzzahlprodukt über Summen berechnet"""
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
ergebnis = 0
for i in range(faktor_2):
ergebnis+=faktor_1
return ergebnis
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
Und siehe da: der Test läuft durch:
multiply.py::test_happy_path_einmaleins PASSED [ 50%]
multiply.py::test_happy_path_kleines_einmaleins PASSED [100%]
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:
def test_happy_path_ein_negativer_faktor():
""" Äquivalenzklasse: mind. ein Argument negativ"""
assert multiply(-9, 10) == -90
assert multiply(7, -8) == -56
assert multiply(-5, -6) == 30
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:
def multiply(faktor_1: int, faktor_2: int) -> int:
"""Methode, die ein Ganzzahlprodukt über Summen berechnet"""
# ---- >8 --- hier den Quelltext einfügen -- 8< ---
ergebnis = 0
if faktor_2>0:
for i in range(faktor_2):
ergebnis+=faktor_1
else:
for i in range(-faktor_2):
ergebnis-=faktor_1
return ergebnis
# ---- >8 --- hier den Quelltext einfügen -- 8< ---```
Und siehe da: alle Tests läuft durch:
multiply.py::test_happy_path_einmaleins PASSED [ 33%]
multiply.py::test_happy_path_kleines_einmaleins PASSED [ 66%]
multiply.py::test_happy_path_ein_negativer_faktor PASSED [100%]
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:
def test_happy_path_0():
""" Ein Faktor ist 0"""
assert multiply(2, 0) == 0
assert multiply(0, 3) == 0
assert multiply(0, 0) == 0
4. Iteration - Green (Code)
Auch das gibt es: der Test wird direkt bestanden. So ein Glück!
multiply.py::test_happy_path_einmaleins PASSED [ 25%]
multiply.py::test_happy_path_kleines_einmaleins PASSED [ 50%]
multiply.py::test_happy_path_ein_negativer_faktor PASSED [ 75%]
multiply.py::test_happy_path_0 PASSED [100%]
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!
def test_happy_path_grosse_zahlen():
""" Grenzwert: Maximum / Minimum: keine Obergrenze für int in Python
daher wird mit sys.maxsize die größte Zahl getestet, die als Index in Array verwendet wird"""
assert multiply(sys.maxsize, 1) == sys.maxsize
assert multiply(-sys.maxsize, 1) == -sys.maxsize
#assert multiply(1,sys.maxsize) == sys.maxsize
#assert multiply(1,-sys.maxsize) == -sys.maxsize
5. Iteration - Green (Code)
Auch dieser Test läuft direkt durch. Wie praktisch!
multiply.py::test_happy_path_einmaleins PASSED [ 20%]
multiply.py::test_happy_path_kleines_einmaleins PASSED [ 40%]
multiply.py::test_happy_path_ein_negativer_faktor PASSED [ 60%]
multiply.py::test_happy_path_0 PASSED [ 80%]
multiply.py::test_happy_path_grosse_zahlen PASSED [100%]
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.
def test_scary_path_float_statt_int():
"""Äquivalenzklasse Gleitkommazahlen statt int"""
assert (multiply(3.3, 1) < 3.31) and (multiply(3.3, 1) > 3.29)
assert (multiply(3, 1.1) < 3.31) and (multiply(3, 1.1) > 3.29)
assert multiply(1.1, 3.3) == None
assert (multiply(1.0, 3.3) < 3.31) and (multiply(1.0, 3.3) > 3.29)
assert (multiply(1.1, 3.0) < 3.31) and (multiply(1.1, 3.0) > 3.29)
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:
# Nur ein Faktor float: tauschen
if (type(faktor_2) == float) and (type(faktor_1) == int):
faktor_1, faktor_2 = faktor_2, faktor_1
elif (type(faktor_2) == float) and (type(faktor_1) == float):
# kein Faktor int => raus hier.
return None
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.
# Float ohne Dezimalstellen? => Nutze als int Wert
if (type(faktor_1) == float) and (int(faktor_2) == faktor_2):
faktor_2 = int(faktor_2)
# Float ohne Dezimalstellen? => Nutze als int Wert
if (type(faktor_1) == float) and (int(faktor_1) == faktor_1):
faktor_1 = int(faktor_1)
Nach ein bisschen Feinjustage wird der Test irgendwann grün:
multiply.py::test_happy_path_einmaleins PASSED [ 16%]
multiply.py::test_happy_path_kleines_einmaleins PASSED [ 33%]
multiply.py::test_happy_path_ein_negativer_faktor PASSED [ 50%]
multiply.py::test_happy_path_0 PASSED [ 66%]
multiply.py::test_happy_path_grosse_zahlen PASSED [ 83%]
multiply.py::test_scary_path_float_statt_int PASSED [100%]
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:
# Float ohne Dezimalstellen? => Nutze als int Wert
if (type(faktor_1) == float) and (int(faktor_1) == faktor_1):
faktor_1 = int(faktor_1)
Die neue Funktion könnte etwa so aussehen:
def float_zu_int_wenn_keine_dezimalstellen(zahl: float):
"""Wandelt einen float in einen int um, wenn die Zahl
keine Dezimalstellen hat."""
if (type(zahl) == float) and (int(zahl) == zahl):
zahl = int(zahl)
return zahl
In der ursprünglichen Funktion verbleiben nur die beiden Aufrufe:
faktor_1 = float_zu_int_wenn_keine_dezimalstellen(float_1)
faktor_2 = float_zu_int_wenn_keine_dezimalstellen(float_2)
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).
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.