parametrize — één test, veel gevallen
Gekopieerde tests en lussen binnen een test vervangen door @pytest.mark.parametrize. Test-ID's, ids= en pytest.param, het stapelen van decorators, waarden en fouten in één tabel, en de veelgemaakte fouten.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een kleine functie die een examenscore omzet in een cijfer, 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 FOm dit goed te testen, moet je veel scores controleren, vooral de grenzen: 80 en 79, 60 en 59, 40 en 39. De eerste manier waarop iedereen dat schrijft, is door een test te kopiëren en twee waarden aan te passen:
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.01sHet werkt, maar het schaalt niet. Drie functies zeggen hetzelfde — "deze score levert dit cijfer op" — en slechts twee waarden verschillen van elkaar. Tien extra gevallen betekent tien extra kopieën die je synchroon moet houden.
Het volgende idee is daarom een lijst met gevallen en een lus binnen één test. Twee van de verwachte cijfers hieronder zijn met opzet fout — 79 moet B zijn, en 40 moet C zijn:
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.01sDat rapport is slechter dan het lijkt. Er waren twee foute gevallen, maar je hoort er maar over één: de eerste falende assert beëindigt de test, dus het geval voor 40 is nooit uitgevoerd. Het rapport vermeldt niet welke score faalde — je moet zelf afleiden dat 'B' == 'C' afkomstig was van 79. En "1 failed" telt vijf gevallen als één enkel feit. Los de eerste op, voer de tests opnieuw uit, en pas dan kom je de tweede tegen.
Wat je wilt is de beknoptheid van de lus gecombineerd met de rapportage van de kopieën: één functie, veel afzonderlijke tests.
Aan het einde van dit hoofdstuk kun je
- Eén testfunctie uitvoeren over veel gevallen met
@pytest.mark.parametrize - Meerdere waarden per geval doorgeven en de test-ID's lezen die pytest genereert
- Gevallen benoemen met
ids=en metpytest.param(..., id=...) - Een enkel geval als
xfailofskipmarkeren zonder de andere aan te raken - Twee parametrize-decorators combineren tot elke mogelijke combinatie van hun waarden
- Gevallen van "retourneert een waarde" en "werpt een error op" combineren in één tabel
- Veelvoorkomende fouten bij parametrize herkennen aan hun foutmeldingen
Voorkennis: floats vergelijken met `pytest.approx`.
Voordat je de test schrijft
Parametrize is een manier om een tabel vast te leggen. Het werk vóór enige testcode bestaat dus uit het opstellen van die tabel, en bepalen of een tabel überhaupt de juiste vorm is.
1. Formuleer het contract. grade(score) belooft: een score van 0 tot 100 gaat erin; een van "A", "B", "C", "F" komt eruit; alles buiten 0–100 werpt een ValueError op waarvan de melding de score noemt. De functie heeft geen side-effects — geen bestanden, geen printopdrachten — dus er valt niets klaar te zetten of op te ruimen. Dat maakt het zo geschikt: elk geval is "deze invoer, dat resultaat".
2. Controleer je setup. Je hebt de virtuele omgeving actief nodig, pytest geïnstalleerd, en grading.py moet importeerbaar zijn vanuit het testbestand — in deze voorbeelden staat het naast de tests in dezelfde map. Verder niets: parametrize zit ingebouwd in pytest, en contextlib zit in de standaardbibliotheek.
3. Plan de gevallen. Randwaarden zijn het belangrijkst, want dat is waar fouten met >= tegenover > zich voordoen. Elke regel krijgt de waarde aan weerszijden van zijn grenslijn:
| Geval | Invoer | Verwacht | |---|---|---| | bovenkant van het bereik | 100 | "A" | | laagste A | 80 | "A" | | hoogste B | 79 | "B" | | laagste B | 60 | "B" | | hoogste C | 59 | "C" | | laagste C | 40 | "C" | | hoogste F | 39 | "F" | | onderkant van het bereik | 0 | "F" | | net onder het bereik | -1 | ValueError | | net boven het bereik | 101 | ValueError |
Elke rij wordt één geval. De kolom "Geval" is geen versiering — dit wordt later de test-ID, zodat een fout je vertelt welke regel stuk is gegaan.
4. Bepaal wat je niet test. Niet elke score van 0 tot 100: 95 en 85 testen dezelfde regel als 80, en voegen alleen maar uitvoeringstijd toe zonder extra informatie te leveren. Eén waarde aan elke kant van elke grens is voldoende. Test niet Pythons eigen vergelijkingsoperatoren, en stop niet twee verschillende gedragingen in één test puur omdat ze in een tabel passen — als de rijen verschillende assertions nodig hebben, horen ze in verschillende tests thuis.
De rest van het hoofdstuk zet deze tabel om in code, gereedschap voor gereedschap.
Eén argument, veel waarden
De decorator accepteert de naam van een argument en een lijst met waarden daarvoor. Pytest maakt één test per waarde aan en geeft de waarde door onder die naam:
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 ===============================Één functie, drie tests, elk met een eigen regel en een eigen naam. Het deel tussen vierkante haken is de test-ID, opgebouwd uit de waarde. score is geen fixture en heeft geen standaardwaarde — de naam in de decorator-string hoeft simpelweg alleen maar overeen te komen met een parameter van de functie.
Meerdere argumenten per geval
De meeste gevallen hebben een invoer en een verwacht resultaat nodig. Noem beide, gescheiden door een komma, en geef elk geval mee als een tuple in dezelfde volgorde. Hier is de lus nogmaals, met dezelfde twee foute verwachtingen:
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.01sVergelijk dit met de lus. Elk geval is uitgevoerd. Beide fouten worden gerapporteerd, elk onder een ID — test_grade[79-C] — die het geval benoemt. De telling is eerlijk: 2 failed, 3 passed. (Bij de standaard lange traceback toont pytest ook de argumenten van elk falend geval: score = 79, expected = 'C'.)
De namen kunnen worden geschreven als één string, "score,expected" of "score, expected" — spaties na komma's zijn toegestaan — of als een lijst met strings, ["score", "expected"]. Beide leveren exact dezelfde tests op. De string is de gebruikelijke vorm; de lijst is handig wanneer de namen door code worden gegenereerd.
Hoe de ID's tot stand komen
Voor getallen, strings, booleans en None plaatst pytest de waarde zelf in de ID en verbindt de waarden van een geval met -. Voor al het andere — een lijst, een dict, een object — waagt pytest zich daar niet aan. Het gebruikt dan de argumentnaam plus de positie van het geval:
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 betekent "value en expected van geval nummer 2, geteld vanaf nul". Het is uniek, maar vertelt je niets. Wanneer zo'n geval faalt, moet je in de lijst omlaag tellen om het te vinden. Dat is de reden om gevallen zelf een naam te geven.
De gevallen benoemen: ids=
Geef aan ids= een lijst met strings mee, één per geval, in dezelfde volgorde:
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 ===============================Nu vertelt de uitvoer waarom elk geval bestaat. Het zwakke punt is dat de namen in een tweede lijst staan, los van hun gevallen: voeg tussendoor een geval toe, vergeet de bijbehorende naam, en elke naam daarna verschuift met één positie.
ids= accepteert ook een functie. Pytest roept deze één keer aan voor elke waarde, niet voor elk geval, en verbindt de resultaten met -:
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 ===============================Een functie is handig voor waarden waarvan de automatische ID nutteloos is, zoals objecten. Als de functie None retourneert voor een waarde, valt pytest terug op de automatische ID voor die waarde.
Eén geval tegelijk: pytest.param
pytest.param wikkelt één enkel geval in en laat je er zaken aan koppelen: een id= die direct naast de waarden staat, en marks= die alleen voor dat specifieke geval gelden.
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 ====================Het geval round-up legt een beslissing vast die nog niet genomen is — moet 59.5 naar boven worden afgerond op een B? — zonder de run te laten falen, terwijl de andere gevallen onaangeroerd blijven. (xfail en skip krijgen later een eigen hoofdstuk; weet voor nu dat een mark op één specifiek geval geplaatst kan worden.)
Omdat de ID een echte naam is, kan één geval afzonderlijk worden uitgevoerd: pytest "test_param_obj.py::test_grade_cases[perfect]". Zet er aanhalingstekens omheen — de shell behandelt vierkante haken als speciale tekens.
Twee decorators: elke combinatie
Stapel twee parametrize-decorators en pytest voert de test uit voor elke combinatie van hun waarden — het cartesisch product. Twee valuta's maal drie bedragen is zes 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 ===============================De decorator die het dichtst bij de functie staat, levert het eerste deel van de ID. Stapel alleen wanneer de invoerwaarden werkelijk onafhankelijk van elkaar zijn — elk bedrag moet met elke valuta werken. Als alleen bepaalde paren logisch zijn, vermeld die paren dan als tuples in één decorator. En let op de vermenigvuldiging: drie decorators met elk tien waarden levert al duizend tests op.
Waarden en errors in één tabel
Soms vallen gevallen uiteen in "retourneert dit" en "werpt dat op". Je kunt de verwachting zelf tot parameter maken en die openen met with:
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 is een contextmanager die niets doet: with nullcontext("C") as expected stelt simpelweg expected = "C" in, waardoor de normale gevallen de assert zoals gebruikelijk uitvoeren. Bij de foutgevallen is pytest.raises de contextmanager: grade werpt een exceptie op binnen het block, de assert wordt nooit bereikt, en de test slaagt omdat de juiste fout optrad. Als er geen fout optreedt, krijg je het bekende DID NOT RAISE.
Dit is overzichtelijk voor een korte tabel. Begint de test-body if-statements nodig te hebben om de twee soorten uit elkaar te houden, splits hem dan op in twee aparte tests. Let ook op de ID's: expectation0 is opnieuw de regel voor objecten. In het complete voorbeeld lossen we dat op met pytest.param.
Een klasse parametreren
Plaats de decorator op een testklasse en elke testmethode daarin ontvangt de 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 ===============================Twee methoden maal twee waarden: vier tests. Elke methode in de klasse moet score accepteren, anders faalt de collection.
Een compleet voorbeeld
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 ==============================Drie functies, veertien tests, en elke regel vertelt wat er is gecontroleerd. Drie dingen vallen hier op.
Ten eerste geven de ID's in test_cost de reden van elk geval — part-kg-rounds-up benoemt de regel die wordt bewaakt, wat 1.2-local-70 niet zou doen.
Ten tweede gebruikt test_first_kg_is_flat gestapelde decorators omdat gewicht en zone echt onafhankelijk zijn. Het vergelijkt de functie bovendien met zichzelf in plaats van met een vast getal, waardoor de test blijft werken als de tarieven veranderen.
Ten derde maakt pytest.param(..., id=...) van expectation2 de naam negative. De tabel met waarden en fouten leest nu als een specificatie.
Wanneer het misgaat
function uses no argument 'scores' Een naam in de decorator-string komt met geen enkele parameter van de functie overeen — hier "scores,expected" tegenover def test_grade(score, expected). Dit faalt tijdens de collection, voordat er ook maar één test wordt uitgevoerd:
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 Het omgekeerde: de functie heeft een parameter die de decorator nergens noemt, waardoor pytest op zoek gaat naar een fixture met die naam en er geen vindt. Voeg de naam toe aan de decorator-string.
the number of names (2) ... must be equal to the number of values (1) Eén geval heeft het verkeerde aantal waarden — bijvoorbeeld (40,) in een lijst voor "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,)ID's zoals [score0] en een TypeError waarin een tuple wordt genoemd Bij één argumentnaam wordt elke waarde in de lijst in zijn geheel doorgegeven. Schrijf je daar uit gewoonte tuples, dan ontvangt de functie een tuple:
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 ===============================De ID's zijn de aanwijzing: een gewoon getal had [80] opgeleverd, dus [score0] geeft aan dat de waarde iets anders was. Schrijf voor één argument [80, 95].
Een geval slaagt op zichzelf en faalt samen met andere Parameterwaarden worden één keer aangemaakt, wanneer de module wordt verzameld (collected), en hetzelfde object wordt doorgegeven aan elke test die de parameter ontvangt. Als een test een veranderlijke (mutable) waarde wijzigt — een lijst, een dict — dan ziet de volgende test die wijziging:
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.01sGeef iets onveranderlijks (immutables) mee, zoals de tuple (), en bouw binnen elke test een nieuw object op met cart = list(items). Dan start elke test met zijn eigen lege lijst en slagen ze allebei.
Stap 4 van 6 — Voorspellen
Test je begrip
Wanneer je pytest -v uitvoert, welke vier test-ID's verschijnen er dan, en in welke volgorde?
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) en grade(95) geven allebei "A" terug, en toch falen beide tests. Waarom?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- ADe ID's `score0` en `score1` botsen met de functienaam
- BDe waarden moeten een tuple van tuples zijn, geen lijst
- CBij één naam wordt elke waarde in zijn geheel doorgegeven, dus `score` is de tuple `(80,)`, niet `80`
- Dparametrize kan niet worden gebruikt met slechts één argumentnaam
Twee verwachtingen hier kloppen niet (79 moet B zijn, 40 moet C zijn). Wat rapporteert pytest?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — één regel per geval
- B1 failed — alleen voor het eerste foute geval; het geval met 40 wordt nooit uitgevoerd
- C1 failed — voor het geval met 40, het laatste foute geval
- D3 failed, omdat één fout ervoor zorgt dat elk geval faalt
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf password.py met een functie check_password(text) die een lijst met problemen retourneert: "too short" bij minder dan 8 tekens, "no digit" als er geen cijfer in staat, "no letter" als er geen letter in staat. Een goed wachtwoord geeft [] terug. Alles wat geen str is, werpt een TypeError op.
Schrijf vervolgens test_password.py met:
- Eén geparametreerde test met ten minste zes gevallen, geschreven met
pytest.paramen eenid=die aangeeft waar elk geval over gaat ("seven-chars","digits-only"enzovoort) - Twee gestapelde decorators — drie goede wachtwoorden maal twee achtervoegsels (suffixes),
""en"!"— die controleren dat alle zes[]opleveren - Eén tabel die normale gevallen (
nullcontext) combineert metTypeError-gevallen (pytest.raises) —Noneen het gehele getal12345678zijn goede invoerwaarden voor de tweede soort - Eén geval gemarkeerd met
xfailvoor een regel die je nog niet hebt geschreven, zoals "geen hoofdletter"
Voer pytest -v uit en lees de lijst met ID's alsof iemand anders ze heeft geschreven. Zou je de wachtwoordregels kunnen afleiden uit de ID's alleen? Zo niet, hernoem de gevallen totdat dat wel kan.
Laat vervolgens met opzet één verwachting falen en voer pytest opnieuw uit. Controleer dat er precies één regel faalt, dat de bijbehorende ID je vertelt om welk geval het gaat, en dat elk ander geval gewoon is uitgevoerd.
Oplossing
Begin met het contract, en daarna de tabel. check_password accepteert een str en retourneert een lijst van elk gevonden probleem, in een vaste volgorde; een niet-str werpt een TypeError op in plaats van iets te retourneren. 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 =========================Waarom het op deze manier is geschreven:
- Het randwaardenpaar staat vooraan.
eight-chars-okenseven-charsstaan aan weerszijden van de lengteregel, precies de plek waar een vergissing tussen<en<=aan het licht zou komen. Elke andere regel krijgt één geval dat uitsluitend die specifieke regel overtreedt, zodat een fout exact naar één regel incheck_passwordwijst. emptycontroleert de volgorde van de lijst. Dit is het enige geval dat alle drie de regels overtreedt, waardoor het vastlegt dat problemen in een vaste volgorde worden geretourneerd — iets waar een aanroeper die ze aan een gebruiker toont op zal vertrouwen.- De verwachte waarden zijn nieuwe lijsten in elk geval, en de test leest ze alleen maar. Er wordt niets gemuteerd, dus de valkuil van gedeelde objecten uit "Wanneer het misgaat" kan hier niet optreden.
- Stapelen past bij
test_good_passwordsomdat het achtervoegsel en het wachtwoord onafhankelijk van elkaar zijn: elk goed wachtwoord moet goed blijven met of zonder een!. De ID'sabcdef12-enabcdef12-!zijn zonder extra hulp leesbaar, dus er was geenids=nodig. - De fouttabel gebruikt
pytest.param-ID's omdat objecten vannullcontextenpytest.raisesanders zouden verschijnen alsexpectation0,expectation1.match="int"controleert dat de foutmelding het verkeerde type noemt, en niet alleen dat er toevallig eenTypeErroroptrad. - Het
xfail-geval legt een toekomstige regel vast zonder dat de testrun faalt. Zodra de regel voor hoofdletters wordt toegevoegd, begint dit geval te slagen en zal pytest het rapporteren alsXPASS— het signaal om de mark te verwijderen.
Wanneer je de ID's van boven naar beneden leest, zie je direct de regels: acht tekens, minstens één cijfer, minstens één letter, alleen strings, hoofdletters gepland. Dat is de ultieme test om te bepalen of de namen goed genoeg zijn.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken