Testen met pytest
Van `assert` naar pytest, een foutrapport lezen, `pytest.raises`, `parametrize`, `tmp_path` en fixtures — en welke vorm van code makkelijk te testen is.
Hoe wil je leren?
Het probleem dat we oplossen
Tot nu toe hebben we code op precies één manier "getest": draaien, de output lezen en beslissen of het er goed uitzag.
Dat werkt één keer. De problemen beginnen de tweede keer.
Het programma uit hoofdstuk 25 had TAX_RATE op 0.15. Stel dat het 0.18 wordt. Het programma draait, elk getal wordt geprint, er gaat niets stuk — en je weet niet of er iets veranderd is, tenzij je de vorige output toevallig nog weet.
En wat uiteindelijk onthouden moet worden, dat onthoud je niet.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert is een eenvoudige zin: "dit hoort waar te zijn". Als het waar is, gebeurt er niets; als het niet waar is, stopt het programma met een AssertionError.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorDat is de basis. Dit hoofdstuk bouwt er een gereedschap op — pytest — dat honderden van zulke beweringen draait en, als er één faalt, zegt waarom.
Aan het einde van dit hoofdstuk kun je
- Een testbestand schrijven en
pytestdraaien - Een foutrapport lezen en het probleem daaruit halen
- Fouten testen met
pytest.raises - Eén test over veel gevallen draaien met
@pytest.mark.parametrize - Code die met bestanden werkt testen met
tmp_path - Zeggen welke code makkelijk te testen is, en waarom
Wat je eerst nodig hebt: Dataclasses en type hints.
Een eerste test
Twee regels, en dat is alles: de bestandsnaam begint met test_, en de naam van de testfunctie ook. (pytest zoekt ook naar bestanden die eindigen op _test.py, maar in deze cursus beginnen we de namen met 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.75Draai dan pytest -q:
.. [100%]
2 passed in 0.00sTwee puntjes, twee geslaagde tests. pytest vindt de bestanden, vindt de functies en voert ze uit — er hoeft nergens iets geregistreerd te worden. De tijd aan het eind (0.00s) kan op jouw computer iets anders zijn.
Het foutrapport is wat telt
Zet een bewust fout getal in de tweede test van test_pricing.py — 51.0 in plaats van 51.75 — en draai pytest -q opnieuw:
.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.01sDe .F op de eerste regel betekent dat de eerste test slaagde en de tweede faalde. Een gewone assert gaf ons alleen AssertionError — één woord, zonder informatie. Hier krijgen we:
- Welke test (
test_three_items) en welke regel (test_pricing.py:9) - De precieze
assertdie brak, gemarkeerd met> assert 51.75 == 51.0— wat er echt uitkwam, tegenover wat verwacht werdwhere 51.75 = line_total(15.0, 3)— waar dat getal vandaan kwam- De telling op de laatste regel:
1 failed, 1 passed
Dit heet assertion introspection, en het is de belangrijkste reden om pytest te gebruiken. Veel testtools hebben daarvoor een aparte methode nodig, zoals assertEqual(a, b); hier is een gewone == genoeg.
Fouten zijn ook gedrag
Hoofdstuk 24 zei dat een functie moet raisen bij slechte invoer. Dat gedrag is ook een belofte, en dus kan het getest worden:
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.01sHet pytest.raises-blok zegt "deze fout hoort hier te gebeuren". Als dat niet gebeurt, faalt de test. Bijvoorbeeld: als een test test_one_is_rejected een ValueError verwacht van line_total(15.0, 1) — hoewel een hoeveelheid van 1 geldig is:
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.01sHet deel match= controleert dat de melding die tekst bevat. Het is de moeite waard om te schrijven, want de regel uit hoofdstuk 24 — zet de foute waarde in de melding — wordt dan een eigen, getoetste belofte.
Eén test, veel gevallen
Vier functies schrijven voor vier gevallen is saai en nodigt uit tot copy-paste-fouten.
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) == expectedMet 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 aparte tests uit één functie. Elke naam bevat zijn waarden, zodat je bij een fout meteen ziet welke het is. Als je het als lus zou schrijven, zou de eerste fout de rest stoppen en niet zeggen welke waarden schuldig waren.
Zet 51.0 in plaats van 51.75 in het tweede geval en draai pytest -q; het begin van het rapport toont de waarden ook:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0Als er een bestand nodig is — tmp_path
Een test van code die bestanden leest, zoals die van hoofdstuk 23, heeft een bestand nodig. Een bestand in de repository bewaren is een slechte gewoonte: de tests raken met elkaar verstrengeld, en als één test het bestand verandert, breekt een andere.
Stel reader.py heeft deze functie — die geeft de niet-lege regels van een tekstbestand terug:
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 kan elke test een eigen lege map geven. 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 is een pathlib.Path — de Path uit hoofdstuk 23, samengevoegd met / zoals daar. De naam van de parameter vertelt pytest wat het moet leveren. Elke test krijgt een nieuwe map, dus twee tests kunnen dezelfde bestandsnaam gebruiken zonder elkaar in de weg te zitten.
Herhaalde voorbereiding — fixture
Als meerdere tests dezelfde invoer nodig hebben, kan die één keer geschreven worden en een naam krijgen. Hier is order_total een andere functie in pricing.py die een hele bestelling optelt — het volledige bestand staat in het complete voorbeeld hieronder:
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.01sNet als bij tmp_path koppelt pytest op de naam van de parameter. En de functie draait opnieuw voor elke test, dus ook als één test de lijst verandert, krijgt de volgende nog een nieuwe. Het probleem van gedeelde data uit hoofdstuk 21 is zo door het ontwerp weggenomen.
De floating-point-valkuil
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.01sHet oude probleem uit hoofdstuk 5, en tests zijn vaak de plek waar het voor het eerst problemen veroorzaakt.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx zegt "dichtbij genoeg is goed genoeg". Gebruik het in elke test met floats — tenzij het getal al vastgelegd is met round(), zoals line_total doet.
Wat je moet testen
Eén regel is meer waard dan alle andere: een functie die waarden neemt en een waarde teruggeeft, is makkelijk te testen.
Herinner je de pure functies uit hoofdstuk 21? Dit hoofdstuk laat zien waarom ze de moeite waard zijn. Om line_total te testen was geen bestand, geen invoer en geen voorbereiding nodig — alleen aanroepen en het resultaat bekijken.
Een functie die iets print, om invoer vraagt of een bestand schrijft, heeft daarentegen extra voorbereiding nodig voordat hij getest kan worden. Daarom nam report in hoofdstuk 23 geen bestand: het nam een lijst en gaf een lijst terug.
Dat tests lastig te schrijven zijn, ligt meestal niet aan de tests maar aan de vorm van de code.
Wat je test, in drie soorten: wat normaal gebeurt, de randgevallen (nul, leeg, één), en wat juist moet mislukken.
Een compleet voorbeeld
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 dingen om op te letten.
Elf tests uit zes testfuncties. parametrize maakte het verschil, en elk geval telt apart mee.
De namen beschrijven gedrag, niet code. test_quantity_below_one_is_rejected vertelt je wat het programma belooft; test_line_total_2 vertelt je niets. Die naam is het eerste wat een foutrapport laat zien, dus het is de moeite waard er een zin van te maken.
Het geval 0.01 is niet willekeurig gekozen. Het is de kleinste mogelijke prijs — een randgeval, en precies waar het gedrag van round() het meest twijfelachtig is. Bugs zitten in de randgevallen, niet in het midden.
De laatste test bevat een indirecte belofte. order_total doet zelf geen validatie — het vertrouwt op line_total. Als iemand er ooit een try/except in order_total zet om slechte regels stilletjes over te slaan, faalt deze test en vraagt: "heb je dat echt zo bedoeld?"
Wanneer het breekt
no tests ran De bestandsnaam begint niet met test_ (of eindigt niet op _test.py), of de functienaam begint niet met test_. Beide zijn nodig.
ModuleNotFoundError: No module named 'pricing' Draai pytest vanuit de map waarin de bestanden staan.
Een test slaagt en controleert niets De assert ontbreekt. Alleen een functie aanroepen slaagt zolang die niets gooit.
fixture 'order' not found De @pytest.fixture ontbreekt, of de naam komt niet overeen met die van de parameter.
Een vergelijking van floats faalt, terwijl de getallen goed lijken Gebruik pytest.approx, of round() in de functie zelf.
DID NOT RAISE De code binnen het pytest.raises-blok gooide geen fout. Ofwel de code is fout, ofwel de verwachting van de test is fout.
Een test slaagt alleen en faalt samen met de andere De tests delen iets — vaak een lijst of dictionary op moduleniveau. De gedeelde veranderlijke data uit hoofdstuk 21 en 26. Gebruik een fixture.
Stap 4 van 6 — Voorspellen
Test je begrip
Wat is de laatste regel van het rapport als je pytest -q draait?
# 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) is niet 5. Wat zegt pytest -q?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- AEen `AssertionError`
- B1 failed — zoals het hoort
- Cno tests ran
- D1 passed — de test slaagt
De eerste test zet een item in het mandje. Wat ziet de tweede?
# 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 — de tweede test krijgt een nieuwe, lege lijst
- D1 failed, 1 passed — de tweede ziet één item
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
Neem library.py uit hoofdstuk 27 — Book en Shelf — en schrijf test_library.py ernaast.
Minstens deze:
- Meerdere gevallen van
total_pages()viaparametrize, inclusief een lege plank pytest.raisesmetmatch=voor het afwijzen vanpages=0longest()dieNoneteruggeeft op een lege plankby_authordie precies de boeken van die auteur teruggeeft, en een lege lijst voor een auteur die er niet is- Eén test die
tmp_pathgebruikt, de plank naar JSON schrijft en weer inleest
Dan vijf experimenten:
- Haal de
assertuit een test en roep alleen de functie aan. Slaagt de test? - Hernoem het testbestand naar
check_library.pyen draaipytest. Wat gebeurt er? (Als hetlibrary_test.pyheet, wordt het wel gevonden — waarom?) - Haal de
pages < 1-controle vanBookweg. Welke tests falen, en zegt het rapport waarom? - Zet een bewuste fout in
total_pages()— bijvoorbeeldsum(...) + 1. Toont het foutrapport zowel het werkelijke als het verwachte getal? - Schrijf een test die
0.1 + 0.2 == 0.3beweert. Repareer hem dan metpytest.approx.
Het derde experiment leert het meest. Een goede testset vertelt je, als hij breekt, welke belofte gebroken is — en dat is wat een programma veilig houdt om zes maanden later te veranderen.
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