Testen mit pytest
Von `assert` zu pytest, das Lesen eines Fehlerberichts, `pytest.raises`, `parametrize`, `tmp_path` und Fixtures — und welche Art von Code sich leicht testen lässt.
Wie möchtest du lernen?
Das Problem, das wir lösen
Bisher haben wir Code auf genau eine Weise „getestet“: ausführen, die Ausgabe lesen und entscheiden, ob sie richtig aussah.
Das funktioniert einmal. Beim zweiten Mal fangen die Probleme an.
Das Programm aus Kapitel 25 hatte TAX_RATE bei 0.15. Angenommen, er wird zu 0.18. Das Programm läuft, jede Zahl wird ausgegeben, nichts wirft einen Fehler — und du hast keine Möglichkeit zu wissen, ob sich etwas geändert hat, es sei denn, du erinnerst dich zufällig an die vorherige Ausgabe.
Und genau das, was man sich merken müsste, merkt man sich irgendwann nicht mehr.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert ist ein einfacher Satz: „Das soll wahr sein.“ Ist es wahr, passiert nichts. Ist es nicht wahr, bricht das Programm mit einem AssertionError ab.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorDas ist das Fundament. Dieses Kapitel baut ein Werkzeug darauf auf — pytest —, das Hunderte solcher Behauptungen prüft und, wenn eine fehlschlägt, sagt, warum.
Am Ende dieses Kapitels kannst du
- Eine Testdatei schreiben und
pytestausführen - Einen Fehlerbericht lesen und daraus das Problem finden
- Fehler mit
pytest.raisestesten - Einen Test über viele Fälle laufen lassen, mit
@pytest.mark.parametrize - Code, der Dateien verarbeitet, mit
tmp_pathtesten - Sagen, welcher Code leicht zu testen ist, und warum
Was du vorher brauchst: Dataclasses und Typ-Hinweise.
Ein erster Test
Zwei Regeln, mehr nicht: Der Dateiname beginnt mit test_, und genauso der Name der Testfunktion. (pytest sucht auch nach Dateien, die auf _test.py enden, aber in diesem Kurs beginnen wir die Namen mit test_.)
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75Dann pytest -q:
.. [100%]
2 passed in 0.00sZwei Punkte, zwei bestandene Tests. pytest findet die Dateien, findet die Funktionen und führt sie aus — irgendwo muss nichts registriert werden. Die Zeit am Ende (0.00s) kann auf deinem Rechner ein bisschen anders aussehen.
Der Fehlerbericht ist das Wichtige
Setz im zweiten Test von test_pricing.py absichtlich eine falsche Zahl ein — 51.0 statt 51.75 — und führe pytest -q noch einmal aus:
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sDas .F in der ersten Zeile bedeutet, dass der erste Test bestanden hat und der zweite fehlgeschlagen ist. Ein einfaches assert hat uns nur AssertionError gegeben — ein einziges Wort, ohne Information. Hier bekommen wir:
- Welcher Test (
test_three_items) und welche Zeile (test_pricing.py:9) - Das genaue
assert, das kaputtging, markiert mit> assert 51.75 == 51.0— was tatsächlich herauskam, verglichen mit dem, was erwartet warwhere 51.75 = line_total(15.0, 3)— woher diese Zahl kam- Die Zählung in der letzten Zeile:
1 failed, 1 passed
Das heißt Assertion-Introspektion, und sie ist der wichtigste Grund, pytest zu benutzen. Viele Testwerkzeuge brauchen dafür eine eigene Methode wie assertEqual(a, b); hier reicht ein gewöhnliches ==.
Fehler sind auch Verhalten
Kapitel 24 hat gesagt, dass eine Funktion bei ungültiger Eingabe raise auslösen soll. Auch dieses Verhalten ist ein Versprechen, und deshalb lässt es sich testen:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01sDer pytest.raises-Block sagt: „Dieser Fehler soll hier drin passieren.“ Passiert er nicht, schlägt der Test fehl. Zum Beispiel, wenn ein Test namens test_one_is_rejected einen ValueError von line_total(15.0, 1) erwartet — obwohl die Menge 1 gültig ist:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01sDer match=-Teil prüft, dass die Meldung diesen Text enthält. Es lohnt sich, ihn zu schreiben, denn die Regel aus Kapitel 24 — den problematischen Wert in die Fehlermeldung schreiben — wird dadurch selbst zu einem getesteten Versprechen.
Ein Test, viele Fälle
Vier Funktionen für vier Fälle zu schreiben ist langweilig und lädt zu Copy-Paste-Fehlern ein.
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedMit pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================Vier getrennte Tests aus einer einzigen Funktion. Jeder Name enthält seine Werte, sodass du bei einem Fehler sofort siehst, welcher es ist. Als Schleife geschrieben würde der erste Fehler alle weiteren stoppen, und man würde nicht sehen, welche Werte schuld sind.
Setz im zweiten Fall 51.0 statt 51.75 und führe pytest -q aus; der Anfang des Berichts zeigt die Werte ebenfalls:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0Wenn eine Datei gebraucht wird — tmp_path
Tests für Code, der Dateien liest wie in Kapitel 23, brauchen eine Datei. Eine Datei dauerhaft im Repository zu halten ist eine schlechte Angewohnheit: Die Tests hängen dann miteinander zusammen, und ein Test, der die Datei verändert, bringt einen anderen zum Scheitern.
Angenommen, reader.py hat diese Funktion — sie gibt die nicht leeren Zeilen einer Textdatei zurück:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest kann jedem Test einen eigenen leeren Ordner geben. test_reader.py:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path ist ein pathlib.Path — das Path aus Kapitel 23, das mit / zusammengefügt wird, genau wie dort. Der Name des Parameters sagt pytest, was es liefern soll. Jeder Test bekommt einen neuen Ordner, sodass zwei Tests denselben Dateinamen benutzen können, ohne sich gegenseitig zu stören.
Wiederholte Vorbereitung — fixture
Wenn mehrere Tests dieselbe Eingabe brauchen, kann man sie einmal schreiben und ihr einen Namen geben. Hier ist order_total eine weitere Funktion in pricing.py, die eine ganze Bestellung summiert — die vollständige Datei steht im vollständigen Beispiel weiter unten:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01sWie bei tmp_path erkennt pytest die Fixture am Namen des Parameters. Und die Funktion läuft für jeden Test neu, sodass selbst dann, wenn ein Test die Liste verändert, der nächste trotzdem eine neue bekommt — das Problem der gemeinsam genutzten Daten aus Kapitel 21, durch das Design beseitigt.
Die Falle der Fließkommazahlen
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sDas altbekannte Problem aus Kapitel 5 — und Tests sind oft der Ort, an dem es zum ersten Mal Ärger macht.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx sagt: „Nahe genug ist gut genug.“ Benutze es in jedem Test mit Fließkommazahlen — außer die Zahl wurde bereits mit round() auf feste Stellen gebracht, wie es line_total tut.
Was man testen sollte
Eine Regel ist mehr wert als alle anderen: Eine Funktion, die Werte entgegennimmt und einen Wert zurückgibt, ist leicht zu testen.
Erinnerst du dich an die reinen Funktionen aus Kapitel 21? Dieses Kapitel zeigt, warum sie sich lohnen. Um line_total zu testen, brauchte es keine Datei, keine Eingabe, keine Vorbereitung — nur den Aufruf und einen Blick auf das Ergebnis.
Eine Funktion dagegen, die etwas ausgibt, nach einer Eingabe fragt oder eine Datei schreibt, braucht vor dem Test zusätzliche Vorbereitung. Deshalb nahm report in Kapitel 23 keine Datei entgegen: Es nahm eine Liste und gab eine Liste zurück.
Dass Tests schwer zu schreiben sind, liegt meist nicht an den Tests, sondern an der Form des Codes.
Was man testet, in drei Arten: was normalerweise passiert, die Grenzfälle (null, leer, eins) und was fehlschlagen soll.
Ein vollständiges Beispiel
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sVier Dinge, die einen Blick wert sind.
Elf Tests aus sechs Testfunktionen. parametrize macht den Unterschied, und jeder Fall wird einzeln gezählt.
Die Namen beschreiben das Verhalten, nicht den Code. test_quantity_below_one_is_rejected sagt dir, was das Programm verspricht; test_line_total_2 sagt dir nichts. Dieser Name ist das Erste, was ein Fehlerbericht zeigt, deshalb lohnt es sich, ihn zu einem Satz zu machen.
Der Fall 0.01 ist nicht zufällig gewählt. Es ist der kleinste mögliche Preis — ein Grenzfall, und genau dort ist das Verhalten von round() am ehesten fraglich. Fehler stecken an den Rändern, nicht in der Mitte.
Der letzte Test enthält ein indirektes Versprechen. order_total prüft selbst nichts — es verlässt sich auf line_total. Wenn jemand eines Tages ein try/except in order_total einbaut, um fehlerhafte Zeilen stillschweigend zu überspringen, schlägt dieser Test fehl und fragt: „Hast du das wirklich so gemeint?“
Wenn es schiefgeht
no tests ran Der Dateiname beginnt nicht mit test_ (und endet nicht auf _test.py), oder der Funktionsname beginnt nicht mit test_. Beides wird benötigt.
ModuleNotFoundError: No module named 'pricing' Führe pytest in dem Ordner aus, in dem die Dateien liegen.
Ein Test besteht und prüft nichts Das assert fehlt. Eine Funktion nur aufzurufen gilt als bestanden, solange sie nichts auslöst.
fixture 'order' not found Das @pytest.fixture fehlt, oder der Name stimmt nicht mit dem des Parameters überein.
Ein Vergleich von Fließkommazahlen schlägt fehl, obwohl die Zahlen richtig aussehen Benutze pytest.approx, oder round() innerhalb der Funktion selbst.
DID NOT RAISE Der Code im pytest.raises-Block hat keinen Fehler ausgelöst. Entweder ist der Code falsch, oder die Erwartung des Tests ist es.
Ein Test besteht allein, schlägt aber zusammen mit den anderen fehl Die Tests teilen sich etwas — oft eine Liste oder ein Dictionary auf Modulebene. Die veränderlichen gemeinsamen Daten aus den Kapiteln 21 und 26. Benutze eine fixture.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Was steht in der letzten Zeile des Berichts, wenn du pytest -q ausführst?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) ist nicht 5. Was sagt pytest -q?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- AEin `AssertionError`
- B1 failed — wie es sein soll
- Cno tests ran
- D1 passed — der Test besteht
Der erste Test hat einen Artikel in den Warenkorb gelegt. Was sieht der zweite?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — der zweite Test bekommt eine frische, leere Liste
- D1 failed, 1 passed — der zweite sieht einen Artikel
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Nimm library.py aus Kapitel 27 — Book und Shelf — und schreib test_library.py daneben.
Mindestens diese Tests:
- Mehrere Fälle von
total_pages()überparametrize, einschließlich eines leeren Regals pytest.raisesmitmatch=dafür, dasspages=0abgelehnt wirdlongest()gibtNonebei einem leeren Regal zurückby_authorgibt genau die Bücher dieses Autors zurück, und eine leere Liste für einen Autor, der nicht vorkommt- Ein Test mit
tmp_path, der das Regal als JSON schreibt und wieder einliest
Dann fünf Experimente:
- Entferne das
assertaus einem Test und rufe nur die Funktion auf. Besteht der Test? - Benenne die Testdatei in
check_library.pyum und führpytestaus. Was passiert? (Heißt sielibrary_test.py, wird sie gefunden — warum?) - Entferne die Prüfung
pages < 1inBook. Welche Tests schlagen fehl, und sagt der Bericht, warum? - Bau absichtlich einen Fehler in
total_pages()ein — etwasum(...) + 1. Zeigt der Fehlerbericht sowohl die tatsächliche als auch die erwartete Zahl? - Schreib einen Test, der
0.1 + 0.2 == 0.3prüft. Dann behebe ihn mitpytest.approx.
Das dritte Experiment lehrt am meisten. Eine gute Testsammlung sagt dir, wenn sie kaputtgeht, welches Versprechen gebrochen wurde — und genau das hält ein Programm sicher, wenn du es sechs Monate später änderst.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen