Zum Inhalt springen

11. Testen mit pytest

Zu Zen-Modus wechseln

Jedes Programm wird getestet. Die Frage ist nur, wie: durch händisches Ausprobieren nach jeder Änderung, oder durch Testfälle, die per Knopfdruck ablaufen. Händisches Testen hat einen fundamentalen Makel: Es prüft nur, was gerade im Kopf ist. Eine Änderung an Funktion A kann Funktion B brechen, die vor drei Wochen zuletzt ausprobiert wurde, und niemand merkt es. Solche Regressionen (etwas, das funktioniert hat, funktioniert nach einer Änderung nicht mehr) sind die häufigste Fehlerart in wachsenden Programmen.

Automatisierte Tests sind Programme, die das eigene Programm prüfen: Sie rufen Funktionen mit bekannten Eingaben auf und vergleichen das Ergebnis mit dem erwarteten Wert. Einmal geschrieben, laufen alle Tests bei jeder Änderung erneut, in Sekunden. Das Standardwerkzeug dafür in Python ist pytest.

Installiert wird pytest mit dem Paketmanager pip (ausführlicher in Kapitel 10):

Terminal-Fenster
pip install pytest

Gegeben eine Funktion in der Datei timecode.py:

def frames_to_timecode(frames, fps=25):
"""Convert an absolute frame number to a timecode HH:MM:SS:FF."""
if frames < 0:
raise ValueError(f"frame number must be >= 0, got {frames}")
total_seconds = frames // fps
return (f"{total_seconds // 3600:02d}:{(total_seconds % 3600) // 60:02d}:"
f"{total_seconds % 60:02d}:{frames % fps:02d}")

Die Tests dazu stehen in einer eigenen Datei test_timecode.py im selben Ordner:

from timecode import frames_to_timecode
def test_zero_frames():
assert frames_to_timecode(0) == "00:00:00:00"
def test_one_hour():
assert frames_to_timecode(90000) == "01:00:00:00"
def test_mixed_value():
assert frames_to_timecode(90061) == "01:00:02:11"

Ausgeführt wird mit dem Kommando pytest im Projektordner:

$ pytest
================= test session starts =================
collected 3 items
test_timecode.py ... [100%]
================== 3 passed in 0.01s ==================

Die Konventionen dahinter: pytest sammelt automatisch alle Dateien, deren Name mit test_ beginnt, und führt darin alle Funktionen aus, deren Name mit test_ beginnt. Ein Test besteht, wenn er ohne Fehler durchläuft; er scheitert, wenn ein assert fehlschlägt oder eine unerwartete Exception auftritt. Mehr Infrastruktur ist nicht nötig: kein Framework-Klassengerüst, keine Registrierung, nur Funktionen mit assert.

Der Wert zeigt sich beim Scheitern. Wird in der Funktion ein Fehler eingebaut (etwa % 60 durch % 61 ersetzt), meldet pytest präzise, welcher Test mit welchen Werten gescheitert ist:

def test_mixed_value():
> assert frames_to_timecode(90061) == "01:00:02:11"
E AssertionError: assert '01:00:02:11' == '01:00:02:12'

pytest zeigt bei einem fehlgeschlagenen assert beide Seiten des Vergleichs an. Aus der Differenz lässt sich die Fehlersuche direkt starten.

Die Testpyramide: welche Art Test schreiben wir hier?

Abschnitt betitelt „Die Testpyramide: welche Art Test schreiben wir hier?“

Der Test oben prüft eine einzelne Funktion isoliert, ohne Datenbank, ohne Netzwerk, ohne Benutzeroberfläche. Das ist ein Unit-Test (Modultest): die kleinste und häufigste Testart. Er ist schnell (Bruchteile einer Millisekunde), stabil und zeigt beim Scheitern präzise auf die verantwortliche Stelle. Alles in diesem Kapitel sind Unit-Tests.

Unit-Tests sind aber nicht die einzige Art. Übliche Darstellung ist die Testpyramide, die Tests nach Umfang des geprüften Ausschnitts staffelt:

Die Software-Testpyramide mit drei Ebenen: unten breit die Unit-Tests (isolierte Funktionen, Methoden, Klassen; großer Umfang, schnell, geringer Aufwand), in der Mitte die Integrationstests (Zusammenspiel von Komponenten, Modulen, Datenbanken), oben schmal die E2E-Tests (Browser-Tests, UI-Flows, System-Integration; geringer Umfang, langsam, hoher Aufwand). Die linke Achse zeigt den Test-Umfang, die rechte Geschwindigkeit und Kosten.
Abb. 11.1: Die Testpyramide. Von unten nach oben nehmen Umfang des geprüften Ausschnitts sowie Geschwindigkeit und Kosten zu, während die empfohlene Anzahl der Tests abnimmt.
  • Unit-Tests (unten, breit) prüfen einen einzelnen Baustein für sich. Es sollen die meisten sein, weil sie am günstigsten zu schreiben und am aussagekräftigsten beim Scheitern sind.
  • Integrationstests (Mitte) prüfen das Zusammenspiel mehrerer Bausteine, etwa ob die Fachlogik korrekt mit einer echten Datei oder Datenbank zusammenarbeitet. Sie sind langsamer und finden Fehler, die zwischen den Teilen liegen und die kein Unit-Test sieht.
  • End-to-End-Tests (oben, schmal) treiben das komplette System wie ein echter Benutzer durch, etwa über die Oberfläche von der Eingabe bis zum Ergebnis. Sie sind am realitätsnächsten, aber auch am langsamsten und brüchigsten, deshalb setzt man sie sparsam ein.

Die Form ist Absicht: viele schnelle Unit-Tests als breites Fundament, darüber wenige teure Tests für das Zusammenspiel. In diesem Kapitel bleiben wir bewusst auf der untersten Ebene; die anderen Ebenen werden erst relevant, sobald mehrere Komponenten und äußere Systeme zusammenkommen.

Gute Testfälle folgen einem festen Aufbau aus drei Schritten, bekannt als Arrange-Act-Assert:

def test_withdrawal_reduces_balance():
# arrange: set up the objects under test
account = Account("Ada", 100)
# act: perform the one operation being tested
account.withdraw(30)
# assert: check the result
assert account.balance == 70
  • Arrange: die Ausgangslage herstellen (Objekte anlegen, Testdaten vorbereiten).
  • Act: genau die eine Operation ausführen, um die es geht.
  • Assert: das Ergebnis prüfen.

Dazu zwei Regeln, die Tests wartbar halten:

  1. Ein Test, eine Aussage. Ein Testfall prüft ein Verhalten. Zwanzig asserts in einer Funktion bedeuten: Beim ersten Fehlschlag bricht der Test ab, und die restlichen 19 Prüfungen laufen gar nicht; das Testergebnis verliert an Aussagekraft. Lieber mehrere kleine Tests.
  2. Der Name beschreibt das geprüfte Verhalten. test_withdrawal_reduces_balance sagt beim Scheitern sofort, welche Eigenschaft verletzt ist. test_1 und test_account sagen nichts. Die Testnamen bilden zusammen eine lesbare Spezifikation der Klasse.

Nicht die Menge der Tests zählt, sondern die Auswahl. Drei Kategorien decken das Wesentliche ab:

  • Normalfälle: typische, gültige Eingaben (frames_to_timecode(90061)).
  • Grenzfälle: die Ränder des Gültigen: 0, der kleinste und größte erlaubte Wert, leere Listen, leere Strings. Fehler wohnen bevorzugt an Grenzen, weil dort < mit <= und “letztes Element” mit “eines zu weit” verwechselt wird.
  • Fehlerfälle: ungültige Eingaben, bei denen das Programm kontrolliert reagieren muss.

Fehlerfälle prüfen heißt: sicherstellen, dass die richtige Exception kommt. Dafür bietet pytest den Kontextmanager pytest.raises:

import pytest
from timecode import frames_to_timecode
def test_negative_frames_raise():
with pytest.raises(ValueError):
frames_to_timecode(-1)

Der Test besteht, wenn der Block die erwartete Exception auslöst, und scheitert in beiden anderen Fällen: wenn keine Exception kommt oder eine andere. Damit ist auch das Fehlerverhalten aus Kapitel 7 abgesichert; die Zusage “negative Werte werden abgelehnt” ist jetzt eine geprüfte Eigenschaft statt einer Absichtserklärung.

Die drei Timecode-Tests oben unterscheiden sich nur in Eingabe und Erwartung; der Rest ist Kopie. Für solche Fälle gibt es die Parametrisierung: ein Test, viele Datensätze.

import pytest
from timecode import frames_to_timecode
@pytest.mark.parametrize("frames, expected", [
(0, "00:00:00:00"),
(1, "00:00:00:01"),
(24, "00:00:00:24"),
(25, "00:00:01:00"),
(90000, "01:00:00:00"),
(90061, "01:00:02:11"),
])
def test_frames_to_timecode(frames, expected):
assert frames_to_timecode(frames) == expected

pytest führt die Funktion sechsmal aus, einmal pro Tupel, und weist jeden Durchlauf einzeln aus. Neue Testfälle sind jetzt eine Datenzeile statt einer kopierten Funktion; entsprechend niedrig ist die Schwelle, Grenzfälle wie das Paar 24/25 (Framewechsel zur vollen Sekunde) tatsächlich aufzunehmen. Die Liste der Tupel ist zugleich eine kompakte, lesbare Dokumentation des Funktionsverhaltens.

Der Lehrplan verlangt Testfälle “unter Berücksichtigung von Vererbung und Polymorphismus”, und dafür gibt es einen konkreten Grund. Eine Klassenhierarchie gibt ein Versprechen ab: Jede Subklasse von MediaAsset erfüllt den Vertrag der Basisklasse (Kapitel 6). Dann muss dieses Versprechen auch pro Subklasse geprüft werden, denn genau dort entstehen die Fehler: Eine neue Subklasse überschreibt eine Methode und verletzt dabei unbemerkt eine Zusicherung der Basisklasse.

Die Standardtechnik ist der Vertragstest: eine Testklasse, die den Vertrag prüft, und pro Subklasse eine abgeleitete Testklasse, die nur festlegt, welches Objekt getestet wird. Tests werden nämlich ebenfalls vererbt:

import pytest
from media import ImageAsset, VideoAsset, AudioAsset
class MediaAssetContract:
"""Contract tests every MediaAsset subclass must pass."""
def create_asset(self):
raise NotImplementedError
def test_describe_returns_nonempty_string(self):
asset = self.create_asset()
assert isinstance(asset.describe(), str)
assert asset.describe() != ""
def test_size_is_positive(self):
asset = self.create_asset()
assert asset.size_megabytes() > 0
def test_describe_contains_filename(self):
asset = self.create_asset()
assert asset.filename in asset.describe()
class TestImageAsset(MediaAssetContract):
def create_asset(self):
return ImageAsset("sunset.jpg", 4_200_000, 6000, 4000)
class TestVideoAsset(MediaAssetContract):
def create_asset(self):
return VideoAsset("intro.mp4", 380_000_000, 90, 25)
class TestAudioAsset(MediaAssetContract):
def create_asset(self):
return AudioAsset("voiceover.wav", 52_000_000, 300)

pytest führt jede der drei geerbten Prüfungen für jede der drei Subklassen aus, insgesamt also neun Tests. Der Mechanismus dahinter ist die Vererbung selbst: TestImageAsset erbt die Testmethoden von MediaAssetContract, und der polymorphe Aufruf self.create_asset() liefert in jeder Testklasse das jeweils passende Objekt. Die Vertragsklasse selbst beginnt absichtlich nicht mit Test, damit pytest sie nicht direkt (ohne konkretes Objekt) auszuführen versucht.

Der Gewinn: Kommt eine neue Subklasse ModelAsset hinzu, besteht ihre Anbindung an die Testsuite aus vier Zeilen, und sämtliche Vertragsprüfungen laufen automatisch auch für sie. Subklassenspezifisches Verhalten (etwa megapixels() beim Bild) bekommt zusätzliche Tests in der jeweiligen Testklasse.

Abschließend drei Gewohnheiten, die aus dem Werkzeug eine Arbeitsweise machen:

  • Tests laufen vor jedem Commit. Die Kombination mit Git (Kapitel 8) liegt auf der Hand: Ein Commit, dessen Tests grün sind, ist ein belastbarer Stand. Wer in der Historie zurückgeht, weiß bei jedem solchen Commit, dass er funktioniert hat.
  • Jeder behobene Fehler bekommt einen Test. Vor der Korrektur wird ein Test geschrieben, der den Fehler nachstellt und scheitert; nach der Korrektur besteht er. Damit ist ausgeschlossen, dass derselbe Fehler unbemerkt zurückkehrt, und die Fehlersuche hat ein klares Erfolgskriterium.
  • Testbarkeit ist ein Qualitätsmerkmal des Entwurfs. Eine Funktion, die sich schwer testen lässt (weil sie Eingabe, Berechnung und Ausgabe vermischt), ist meist auch schlecht strukturiert. Die Berechnung in eine eigene, testbare Funktion zu ziehen verbessert beides zugleich. Dieses Kriterium wird bei der GUI-Entwicklung (Kapitel 14) wichtig: Fachlogik, die von der Oberfläche getrennt ist, bleibt automatisiert testbar.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: den Zweck automatisierter Tests (Regressionen erkennen, Erwartungen dokumentieren) und die pytest-Konventionen erklären.
  • Einordnen: die Testpyramide beschreiben und Unit-Tests von Integrations- und End-to-End-Tests abgrenzen.
  • Anwenden: Testfälle nach Arrange-Act-Assert schreiben, benennen und mit pytest ausführen und auswerten.
  • Analysieren: Testfälle systematisch auswählen: Normalfälle, Grenzfälle, Fehlerfälle.
  • Anwenden: erwartete Exceptions mit pytest.raises und Testreihen mit @pytest.mark.parametrize prüfen.
  • Entwerfen: für eine Klassenhierarchie Vertragstests definieren, die jede Subklasse bestehen muss.
  • Anwenden: Tests in den Arbeitsablauf integrieren (vor jedem Commit, bei jeder Fehlerbehebung).
  • Aufgabe 19 - pytest I
  • Aufgabe 20 - pytest II: Der Vertragstest