parametrize — ein Test, viele Fälle
Kopierte Tests und Schleifen innerhalb eines Tests durch @pytest.mark.parametrize ersetzen. Test-IDs, ids= und pytest.param, gestapelte Dekoratoren, Werte und Fehler in einer Tabelle sowie typische Fehler.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine kleine Funktion, die eine Prüfungspunktzahl in eine Note umwandelt, grading.py:
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C FUm sie gründlich zu testen, müssen viele Punktzahlen geprüft werden, besonders an den Grenzen: 80 und 79, 60 und 59, 40 und 39. Der erste Impuls ist meist, einen Test zu kopieren und zwei Werte anzupassen:
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sDas funktioniert, skaliert aber nicht. Drei Funktionen drücken ein und dasselbe aus — „diese Punktzahl ergibt diese Note“ — und nur zwei Werte unterscheiden sich. Zehn weitere Fälle bedeuten zehn weitere Kopien, die synchron gehalten werden müssen.
Die nächste Idee ist daher eine Liste von Fällen und eine Schleife innerhalb eines einzigen Tests. Zwei der erwarteten Noten unten sind absichtlich falsch — 79 sollte B sein und 40 sollte C sein:
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sDieser Bericht ist schlimmer, als er aussieht. Es gab zwei falsche Fälle, aber du erfährst nur von einem: Das erste fehlschlagende assert beendet den Test, der Fall für 40 wurde also gar nicht ausgeführt. Der Bericht verrät nicht, welche Punktzahl fehlgeschlagen ist — du musst selbst herausfinden, dass 'B' == 'C' von 79 stammte. Und „1 failed“ fasst fünf Fälle zu einer einzigen Tatsache zusammen. Behebe den ersten, führe den Test erneut aus, und erst dann stößt du auf den zweiten.
Was du willst, ist die Kürze der Schleife kombiniert mit den aussagekräftigen Berichten der einzelnen Funktionen: eine Funktion, viele separate Tests.
Am Ende dieses Kapitels kannst du
- Eine Testfunktion mit
@pytest.mark.parametrizeüber viele Fälle ausführen - Mehrere Werte pro Fall übergeben und die von pytest erzeugten Test-IDs lesen
- Fälle mit
ids=und mitpytest.param(..., id=...)benennen - Einen einzelnen Fall als
xfailoderskipmarkieren, ohne die anderen zu beeinflussen - Zwei parametrize-Dekoratoren zu jeder Kombination ihrer Werte verknüpfen
- Fälle mit „gibt einen Wert zurück“ und „löst einen Fehler aus“ in einer Tabelle kombinieren
- Typische parametrize-Fehler an ihren Fehlermeldungen erkennen
Voraussetzungen: Floats mit `pytest.approx` vergleichen.
Bevor du den Test schreibst
Parametrize ist eine Methode, um eine Tabelle abzubilden. Die Arbeit vor dem ersten Testcode besteht also darin, diese Tabelle aufzubauen und zu entscheiden, ob eine Tabelle überhaupt die passende Form ist.
1. Formuliere den Vertrag. grade(score) verspricht: Eine ganze Punktzahl von 0 bis 100 geht hinein; eines von "A", "B", "C", "F" kommt heraus; alles außerhalb von 0–100 löst einen ValueError aus, dessen Meldung die Punktzahl nennt. Die Funktion hat keine Seiteneffekte — keine Dateien, keine Bildschirmausgaben —, es gibt also nichts vorzubereiten oder aufzuräumen. Genau das macht sie zu einem perfekten Kandidaten: Jeder Fall lautet „diese Eingaben, jenes Ergebnis“.
2. Überprüfe dein Setup. Du brauchst die aktive virtuelle Umgebung, ein installiertes pytest und grading.py muss aus der Testdatei importierbar sein — in diesen Beispielen liegt die Datei im selben Ordner direkt neben den Tests. Mehr nicht: parametrize ist fest in pytest eingebaut und contextlib gehört zur Standardbibliothek.
3. Plane die Testfälle. Grenzwerte sind am wichtigsten, denn dort entstehen Fehler bei >= im Vergleich zu >. Jede Regel erhält den Wert auf beiden Seiten ihrer Grenze:
| Fall | Eingabe | Erwartet | |---|---|---| | Obergrenze des Bereichs | 100 | "A" | | niedrigstes A | 80 | "A" | | höchstes B | 79 | "B" | | niedrigstes B | 60 | "B" | | höchstes C | 59 | "C" | | niedrigstes C | 40 | "C" | | höchstes F | 39 | "F" | | Untergrenze des Bereichs | 0 | "F" | | knapp unter dem Bereich | -1 | ValueError | | knapp über dem Bereich | 101 | ValueError |
Jede Zeile wird zu einem Testfall. Die Spalte „Fall“ ist keine bloße Dekoration — sie wird später zur Test-ID, sodass ein Fehlschlag dir genau verrät, welche Regel verletzt wurde.
4. Entscheide, was du nicht testest. Nicht jede Punktzahl von 0 bis 100: 95 und 85 testen dieselbe Regel wie 80 und kosten nur Laufzeit, ohne neue Informationen zu liefern. Ein Wert auf jeder Seite jeder Grenze genügt. Teste nicht Pythons eigene Vergleichsoperatoren und packe nicht zwei unterschiedliche Verhaltensweisen in denselben Test, nur weil sie in eine Tabelle passen — wenn die Zeilen unterschiedliche Assertions erfordern, gehören sie in separate Tests.
Der Rest des Kapitels verwandelt diese Tabelle Schritt für Schritt in Code, Werkzeug für Werkzeug.
Ein Argument, viele Werte
Der Dekorator übernimmt den Namen eines Arguments und eine Liste von Werten dafür. Pytest erzeugt einen Test pro Wert und übergibt den Wert unter diesem Namen:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================Eine Funktion, drei Tests, jeder mit einer eigenen Zeile und einem eigenen Namen. Der Teil in eckigen Klammern ist die Test-ID, die aus dem Wert gebildet wird. score ist keine Fixture und hat keinen Standardwert — der Name im Dekorator-String muss lediglich exakt zu einem Parameter der Funktion passen.
Mehrere Argumente pro Fall
Die meisten Fälle benötigen eine Eingabe und ein erwartetes Ergebnis. Gib beide durch ein Komma getrennt an und übergebe jeden Fall als Tupel in derselben Reihenfolge. Hier ist die Schleife von vorhin noch einmal, mit denselben zwei falschen Erwartungen:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sVergleiche das mit der Schleife. Jeder Fall wurde ausgeführt. Beide Fehlschläge werden gemeldet, jeweils unter einer ID — test_grade[79-C] —, die den Fall benennt. Die Zählung ist ehrlich: 2 failed, 3 passed. (Mit dem standardmäßigen langen Traceback gibt pytest zusätzlich die Argumente jedes fehlschlagenden Falls aus: score = 79, expected = 'C'.)
Die Namen können als einzelner String geschrieben werden, "score,expected" oder "score, expected" — Leerzeichen nach Kommas sind erlaubt — oder als Liste von Strings, ["score", "expected"]. Beide Varianten erzeugen exakt dieselben Tests. Der String ist die übliche Form; die Liste ist praktisch, wenn Namen dynamisch per Code erzeugt werden.
Wie die IDs entstehen
Bei Zahlen, Strings, Booleans und None setzt pytest den Wert selbst in die ID und verbindet die Werte eines Falls mit -. Bei allem anderen — einer Liste, einem Dictionary, einem Objekt — versucht es das gar nicht erst. Es verwendet stattdessen den Argumentnamen plus die Position des Falls:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2 bedeutet: „value und expected von Fall Nummer 2, beginnend bei null gezählt“. Die ID ist eindeutig, verrät dir aber inhaltlich gar nichts. Wenn ein solcher Fall fehlschlägt, musst du die Liste von oben nach unten abzählen, um ihn zu finden. Das ist der Grund, warum du Fälle selbst benennen solltest.
Fälle benennen: ids=
Übergib an ids= eine Liste von Strings, einen pro Fall, in derselben Reihenfolge:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================Jetzt erklärt die Ausgabe, warum jeder Fall existiert. Die Schwachstelle: Die Namen stehen in einer zweiten Liste, getrennt von ihren Fällen. Fügst du mittendrin einen Fall ein und vergisst seinen Namen, verschiebt sich jeder nachfolgende Name um eine Position.
ids= akzeptiert auch eine Funktion. Pytest ruft sie einmal für jeden Wert auf (nicht für jeden Fall) und verbindet die Ergebnisse mit -:
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================Eine Funktion eignet sich gut für Werte, deren automatische ID unbrauchbar ist, wie etwa Objekte. Wenn sie für einen Wert None zurückgibt, greift pytest für diesen Wert auf die automatische ID zurück.
Ein Fall nach dem anderen: pytest.param
pytest.param umschließt einen einzelnen Fall und ermöglicht es dir, Metadaten direkt daran anzuhängen: eine id=, die unmittelbar neben ihren Werten steht, sowie marks=, die ausschließlich für diesen einen Fall gelten.
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================Der Fall round-up dokumentiert eine noch nicht getroffene Entscheidung — soll 59.5 zu einem B aufgerundet werden? —, ohne den gesamten Testlauf fehlschlagen zu lassen, und die anderen Fälle bleiben unberührt. (xfail und skip erhalten später ein eigenes Kapitel; wichtig ist hier zunächst, dass ein Mark auf einen einzelnen Fall angewendet werden kann.)
Da die ID ein aussagekräftiger Name ist, kann ein einzelner Fall gezielt ausgeführt werden: pytest "test_param_obj.py::test_grade_cases[perfect]". Setze den Ausdruck in Anführungszeichen — die Shell behandelt eckige Klammern als Sonderzeichen.
Zwei Dekoratoren: jede Kombination
Staple zwei parametrize-Dekoratoren und pytest führt den Test für jede Paarung ihrer Werte aus — das kartesische Produkt. Zwei Währungen mal drei Beträge ergeben sechs Tests:
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================Der Dekorator, der der Funktion am nächsten steht, liefert den ersten Teil der ID. Staple Dekoratoren nur dann, wenn die Eingaben wirklich unabhängig voneinander sind — jeder Betrag sollte mit jeder Währung funktionieren. Wenn nur bestimmte Paare sinnvoll sind, liste diese Paare als Tupel in einem einzigen Dekorator auf. Und achte auf die Multiplikation: Drei Dekoratoren mit jeweils zehn Werten ergeben bereits eintausend Tests.
Werte und Fehler in einer Tabelle
Manchmal teilen sich Fälle auf in „gibt diesen Wert zurück“ und „löst jenen Fehler aus“. Du kannst die Erwartung selbst zu einem Parameter machen und sie mit with betreten:
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontext ist ein Kontextmanager, der nichts tut: with nullcontext("C") as expected setzt einfach expected = "C", sodass die normalen Fälle das assert wie gewohnt ausführen. In den Fehlerfällen dient pytest.raises als Kontextmanager: grade löst darin eine Exception (Ausnahme) aus, das assert wird nie zu Ende geführt und der Test besteht, weil die richtige Exception aufgetreten ist. Wenn keine Exception ausgelöst wird, erhältst du das bekannte DID NOT RAISE.
Für eine kurze Tabelle ist das sehr übersichtlich. Wenn der Test-Body jedoch if-Anweisungen benötigt, um die beiden Arten zu unterscheiden, teile ihn besser in zwei Tests auf. Beachte auch die IDs: expectation0 ist wieder die Regel für Objekte. Das vollständige Beispiel behebt das mit pytest.param.
Eine Klasse parametrisieren
Platziere den Dekorator an einer Testklasse, und jede Testmethode darin erhält den Parameter:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================Zwei Methoden mal zwei Werte: vier Tests. Jede Methode in der Klasse muss score als Parameter akzeptieren, sonst schlägt die Collection fehl.
Ein vollständiges Beispiel
shipping.py:
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================Drei Funktionen, vierzehn Tests, und jede Zeile erklärt genau, was geprüft wurde. Drei Punkte verdienen besondere Aufmerksamkeit:
Erstens: Die IDs in test_cost nennen den Grund für jeden Fall — part-kg-rounds-up bezeichnet die geschützte Geschäftsregel, was 1.2-local-70 nicht leisten könnte.
Zweitens: test_first_kg_is_flat stapelt Dekoratoren, weil Gewicht und Zone wirklich unabhängig voneinander sind. Zudem vergleicht der Test das Ergebnis der Funktion mit ihr selbst statt mit einer festen Zahl — so übersteht er künftige Tarifänderungen unbeschadet.
Drittens: pytest.param(..., id=...) verwandelt expectation2 in negative. Die Tabelle aus Werten und Fehlern liest sich nun wie eine Spezifikation.
Wenn etwas schiefgeht
function uses no argument 'scores' Ein Name im Dekorator-String stimmt mit keinem Parameter der Funktion überein — hier "scores,expected" gegenüber def test_grade(score, expected). Das schlägt bereits bei der Collection fehl, noch bevor irgendein Test ausgeführt wird:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found Das Gegenteil: Die Funktion besitzt einen Parameter, den der Dekorator nirgends nennt. Pytest sucht daraufhin nach einer gleichnamigen Fixture und findet keine. Füge den Namen zum Dekorator-String hinzu.
the number of names (2) ... must be equal to the number of values (1) Ein Fall hat die falsche Anzahl von Werten — beispielsweise (40,) in einer Liste für "score,expected":
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)IDs wie [score0] und ein TypeError, der ein Tupel erwähnt Bei einem Argumentnamen wird jeder Wert aus der Liste als Ganzes übergeben. Schreibst du dort aus Gewohnheit Tupel, erhält die Funktion ein Tupel:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================Die IDs liefern den Hinweis: Eine einfache Zahl hätte [80] ergeben, [score0] verrät also, dass der Wert etwas anderes war. Schreibe bei einem einzelnen Argument [80, 95].
Ein Fall besteht isoliert, schlägt aber zusammen mit anderen fehl Parameterwerte werden einmalig erzeugt, wenn das Modul gesammelt (collected) wird, und dasselbe Objekt wird an jeden Test übergeben, der es anfordert. Verändert ein Test einen veränderlichen Wert (Mutable) — eine Liste, ein Dictionary —, bekommt der nächste Test diese Änderung zu spüren:
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sÜbergib stattdessen etwas Unveränderliches, wie etwa das Tupel (), und baue innerhalb jedes Tests mit cart = list(items) ein frisches Objekt auf. Dann startet jeder Test mit seiner eigenen leeren Liste und beide bestehen.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Mit pytest -v ausgeführt: Welche vier Test-IDs erscheinen in welcher Reihenfolge?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[1-x], test_pair[2-y]
- Btest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Ctest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Dtest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
grade(80) und grade(95) geben beide "A" zurück, trotzdem schlagen beide Tests fehl. Warum?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- ADie IDs `score0` und `score1` kollidieren mit dem Funktionsnamen
- BDie Werte müssen ein Tupel aus Tupeln sein, keine Liste
- CBei einem einzelnen Namen wird jeder Wert als Ganzes übergeben, `score` ist also das Tupel `(80,)`, nicht `80`
- Dparametrize kann nicht mit nur einem Argumentnamen verwendet werden
Zwei Erwartungen hier sind falsch (79 sollte B sein, 40 sollte C sein). Was meldet pytest?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — eine Zeile pro Fall
- B1 failed — nur für den ersten falschen Fall; der Fall mit 40 wird gar nicht ausgeführt
- C1 failed — für den Fall mit 40, den letzten falschen
- D3 failed, weil ein Fehlschlag jeden Fall fehlschlagen lässt
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
Schreibe password.py mit einer Funktion check_password(text), die eine Liste von Problemen zurückgibt: "too short" bei weniger als 8 Zeichen, "no digit" ohne Ziffer, "no letter" ohne Buchstabe. Ein gültiges Passwort gibt [] zurück. Alles, was kein str ist, löst einen TypeError aus.
Schreibe anschließend test_password.py mit:
- Einem parametrisierten Test mit mindestens sechs Fällen, geschrieben mit
pytest.paramund einerid=, die aussagt, worum es in dem Fall geht ("seven-chars","digits-only"und so weiter) - Zwei gestapelten Dekoratoren — drei gültige Passwörter mal zwei Suffixe (
""und"!") —, die prüfen, dass alle sechs[]zurückgeben - Einer Tabelle, die normale Fälle (
nullcontext) mitTypeError-Fällen (pytest.raises) mischt —Noneund der Integer12345678sind gute Eingaben für die zweite Kategorie - Einem mit
xfailmarkierten Fall für eine Regel, die du noch nicht implementiert hast, wie etwa „kein Großbuchstabe“
Führe pytest -v aus und lies die Liste der IDs so, als hätte sie jemand anderes geschrieben. Könntest du die Passwort-Regeln allein anhand der IDs benennen? Wenn nicht, benenne die Fälle um, bis es klappt.
Lasse dann absichtlich eine Erwartung fehlschlagen und führe den Test erneut aus. Überprüfe, dass genau eine Zeile fehlschlägt, dass ihre ID dir verrät, welcher Fall es ist, und dass alle anderen Fälle trotzdem ausgeführt wurden.
Lösung
Beginne mit dem Vertrag, danach kommt die Tabelle. check_password erwartet einen str und gibt eine Liste aller gefundenen Probleme in festgelegter Reihenfolge zurück; ein Nicht-str löst TypeError aus, statt etwas zurückzugeben. password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================Warum es so aufgebaut ist:
- Das Grenzwerte-Paar steht an erster Stelle.
eight-chars-okundseven-charsliegen auf beiden Seiten der Längenregel — der Stelle, an der ein Flüchtigkeitsfehler zwischen<und<=sichtbar würde. Jede andere Regel erhält einen Fall, der ausschließlich diese eine Regel verletzt, sodass ein Fehlschlag präzise auf eine einzige Zeile incheck_passwordverweist. emptyprüft die Reihenfolge der Liste. Es ist der einzige Fall, der alle drei Regeln verletzt. Er stellt sicher, dass die Probleme in einer festen Reihenfolge zurückgegeben werden — worauf sich Aufrufer verlassen können, die diese Meldungen Nutzern anzeigen.- Die erwarteten Werte sind in jedem Fall frische Listen, und der Test liest sie nur. Nichts wird verändert (mutiert), sodass die Falle mit geteilten Objekten aus „Wenn etwas schiefgeht“ hier nicht auftreten kann.
- Stapeln passt zu
test_good_passwords, weil Suffix und Passwort voneinander unabhängig sind: Jedes gute Passwort sollte mit oder ohne!gültig bleiben. Die IDsabcdef12-undabcdef12-!sind ohne Nachhilfe lesbar, weshalb keinids=nötig war. - Die Fehlertabelle verwendet
pytest.param-IDs, weilnullcontext- undpytest.raises-Objekte sonst alsexpectation0,expectation1auftauchen würden.match="int"prüft, dass die Fehlermeldung den falschen Typ ausdrücklich nennt, statt nur irgendeinenTypeErrorzu akzeptieren. - Der
xfail-Fall hält eine künftige Regel fest, ohne den Testlauf scheitern zu lassen. Sobald die Großbuchstaben-Regel ergänzt wird, besteht dieser Fall und pytest meldet ihn alsXPASS— das Signal, das Mark wieder zu entfernen.
Liest man die IDs von oben nach unten durch, ergeben sich daraus direkt die Regeln: acht Zeichen, mindestens eine Ziffer, mindestens ein Buchstabe, nur Strings, Großbuchstaben geplant. Genau daran zeigt sich, ob die Namen gut gewählt sind.
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