11. Testen mit pytest
Testen mit pytest
Abschnitt betitelt „Testen mit pytest“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):
pip install pytestDer erste Test
Abschnitt betitelt „Der erste Test“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:
- 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.
Anatomie eines Testfalls
Abschnitt betitelt „Anatomie eines Testfalls“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:
- 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. - Der Name beschreibt das geprüfte Verhalten.
test_withdrawal_reduces_balancesagt beim Scheitern sofort, welche Eigenschaft verletzt ist.test_1undtest_accountsagen nichts. Die Testnamen bilden zusammen eine lesbare Spezifikation der Klasse.
Welche Fälle gehören getestet?
Abschnitt betitelt „Welche Fälle gehören getestet?“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.
Exceptions testen
Abschnitt betitelt „Exceptions testen“Fehlerfälle prüfen heißt: sicherstellen, dass die richtige Exception kommt. Dafür bietet pytest den Kontextmanager pytest.raises:
import pytestfrom 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.
Parametrisierte Tests
Abschnitt betitelt „Parametrisierte Tests“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 pytestfrom 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) == expectedpytest 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.
Tests bei Vererbung und Polymorphismus
Abschnitt betitelt „Tests bei Vererbung und Polymorphismus“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 pytestfrom 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.
Testen als Arbeitsweise
Abschnitt betitelt „Testen als Arbeitsweise“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.raisesund Testreihen mit@pytest.mark.parametrizeprü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).
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 19 - pytest I
- Aufgabe 20 - pytest II: Der Vertragstest