Testontwerp en TDD — wat maakt een goede test
Een test plannen voordat je hem schrijft, Arrange-Act-Assert, testen van gedrag in plaats van implementatie, red-green-refactor, property-based testen met Hypothesis, de oorzaken en oplossingen van flaky tests, en een regressietest voor elke bug.
Hoe wil je leren?
Het probleem dat we oplossen
Je kent nu elk hulpmiddel dat deze cursus je wilde leren: assert, raises, parametrize, fixtures, mocks, markers, configuratie, coverage, plugins. En het kan heel goed dat je ze allemaal gebruikt en toch tests overhoudt die meer kwaad dan goed doen.
Hier is een kleine winkelwagen, met twee tests die slagen:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sNu verbetert iemand de winkelwagen. Hetzelfde artikel twee keer toevoegen moet samenvallen in één regel, dus de lijst wordt een dictionary met de naam als sleutel. De winkelwagen voegt nog steeds dingen toe en telt ze nog steeds goed op — niets wat een klant zou kunnen merken is veranderd:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sEen rode test, en geen bug. De test controleerde niet wat de winkelwagen doet; hij controleerde hoe de winkelwagen gebouwd is. Een test als deze faalt zodra je de code verbetert, en blijft stil als je hem kapotmaakt — precies het omgekeerde van zijn taak.
Dit laatste hoofdstuk gaat niet over nog een hulpmiddel. Het gaat over oordeelsvermogen: hoe een goede test eruitziet, hoe je tests de code laat sturen, hoe je de computer testgevallen laat bedenken, en hoe je voorkomt dat tests willekeurig falen.
Aan het einde van dit hoofdstuk kun je
- Een test plannen voordat je hem schrijft: het contract, de gevallen, en wat je weglaat
- Een test structureren als Arrange, Act, Assert, en hem benoemen als een zin
- Een test van gedrag onderscheiden van een test van implementatie, en de tweede herschrijven naar de eerste
- Werken in red-green-refactor cycli, met bij elke rode stap een echte fout
- Property-based tests schrijven met Hypothesis, en een geshrinkt falend voorbeeld lezen
- De vier gebruikelijke oorzaken van flaky tests vinden en oplossen
- Elk bugrapport omzetten in een regressietest
Voorkennis: async tests, hooks en plugins.
Voordat je de test schrijft
De meeste slechte tests zijn niet slecht geschreven. Ze zijn slecht gepland — geschreven voordat iemand besloot wat de code belooft. Dus voordat er testcode komt, vier vragen.
Door dit hoofdstuk bouwen we één kleine functie testgedreven: slugify, die een blogtitel zoals "Café au lait" omzet naar het URL-stukje "cafe-au-lait".
1. Wat is het contract? Zeg het in één of twee zinnen, zoals de aanroeper het ziet: gegeven een titel (een `str`), geef een slug terug: kleine ASCII-letters en cijfers, woorden verbonden door enkele koppeltekens, en geen koppelteken aan een van de uiteinden. Geaccentueerde letters worden hun gewone letter. Geen bijwerkingen — de functie raakt geen bestand, geen klok en geen netwerk. Let op wat het contract niet noemt: reguliere expressies, Unicode-normalisatie, hulpfuncties. Dat zijn de manier waarop het gedaan wordt, en die mogen vrij veranderen.
2. Wat moet er klaarstaan? Een virtuele omgeving met de tools geïnstalleerd, en de code importeerbaar vanuit de tests:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisDan twee bestanden naast elkaar — slugs.py en test_slugs.py — en pytest draaien vanuit die map, zodat from slugs import slugify werkt. Dit onderwerp heeft geen fixtures, geen tijdelijke bestanden en geen omgevingsvariabelen nodig. Dat is ook de moeite waard om op te merken: een pure functie is de makkelijkste functie om te testen, en dat is een goede reden om logica in pure functies te stoppen.
3. Welke gevallen? Eerst het normale pad, dan de randgevallen, dan ongeldige invoer, dan de grenzen:
| Geval | Invoer | Verwacht | |---|---|---| | gewone woorden | "Hello World" | "hello-world" | | leestekens | "Hello, World!" | "hello-world" | | geaccentueerde letters | "Café au lait" | "cafe-au-lait" | | extra spaties aan de uiteinden | " Hello World " | "hello-world" | | cijfers blijven behouden | "Top 10 Tips" | "top-10-tips" | | niets bruikbaars | "!!!" | ? |
Dat vraagteken is de nuttigste cel in de tabel. Bij het schrijven van het plan kwam een beslissing aan het licht die niemand genomen heeft: wat moet er gebeuren als een titel helemaal geen letters bevat? We laten dat bewust open, en zien later in dit hoofdstuk wat een open vraag kost.
4. Wat test je niet? De modules re, unicodedata en str.lower() hebben al hun eigen tests; ze opnieuw testen kost alleen tijd. Privéhulpfuncties (alles wat begint met _) bereik je via slugify, nooit rechtstreeks. En je gaat niet proberen elk Unicode-teken met de hand op te sommen — daar is een property-based test voor, later in dit hoofdstuk.
De tabel wordt de tests. Alles hieronder voert dat plan uit.
Arrange, Act, Assert
Elke goede test heeft dezelfde drie delen, in dezelfde volgorde:
- Arrange — bouw de wereld die de test nodig heeft
- Act — doe het ene ding dat getest wordt
- Assert — controleer wat eruit kwam
Hier is de winkelwagen opnieuw getest — ditmaal via wat hij belooft, niet via hoe hij dingen opslaat:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0Tegen de lijst-gebaseerde winkelwagen, en daarna tegen de dictionary-gebaseerde:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sDezelfde tests, twee implementaties, steeds groen. Dat is de eigenschap die je wilt: een refactor die het gedrag behoudt, moet de tests groen houden. De commentaren in de middelste test zijn er alleen om de vorm te tonen; de meeste mensen laten ze weg en scheiden de drie delen met lege regels, zoals in de derde test.
Waarom is het belangrijk dat de Act één stap is? Omdat je, als de test faalt, wilt weten welk ding kapotging. Als de Act vijf aanroepen is, wijst een fout naar vijf verdachten.
Gedrag testen, geen implementatie
De vuistregel: een test mag alleen gebruiken wat een aanroeper mag gebruiken. Voor de winkelwagen zijn dat Cart(), .add() en .total(). Niet _items, niet _lines. Het voorloopstreepje (underscore) is Python's manier om te zeggen: "dit is van mij, en ik mag het veranderen".
De verkeerde manier koppelt de test aan een beslissing die nooit deel van het contract was — dat artikelen in een lijst worden bewaard. De juiste manier stelt de vraag die een aanroeper zou stellen: als ik deze dingen toevoeg, wat is dan het totaal?
Twee tekenen dat een test gekoppeld is aan de implementatie:
- Hij leest attributen die beginnen met
_, of hij mockt een functie die de code intern aanroept - Een refactor die geen gebruiker zou opmerken, maakt hem rood
Over mocks één opmerking. De mocks uit hoofdstuk elf zijn het juiste hulpmiddel op een grens — het netwerk, de klok, een betaaldienst. Gebruikt binnen je eigen code ("controleer dat _calculate met deze argumenten is aangeroepen"), worden ze implementatietests onder een andere naam.
Namen die als zinnen lezen, en één reden om te falen
Draai de drie winkelwagentests met -v:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]Lees alleen de namen en je hebt een specificatie van de winkelwagen. test_add en test_total vertelden je alleen welke methode geraakt was. Een goede naam zegt de situatie en het verwachte resultaat: een lege winkelwagen geeft een totaal van nul. Het is lang; dat is prima. Je typt hem nooit, maar je leest hem elke keer dat hij faalt.
Het tweede deel van de regel is één reden om te falen. Hier is het tegenovergestelde — één test die alles controleert, uitgevoerd tegen een winkelwagen met een bug (hij negeert quantity):
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=no verbergt de tracebacks en laat alleen de samenvatting zien — het deel dat je eerst leest.) test_cart faalde — wat niets zegt — met 865.0 == 880.0, en dat moet je nu uitzoeken. En de derde assert is nooit uitgevoerd, dus je weet niet of die geslaagd zou zijn. Dezelfde bug tegen de drie gerichte tests:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sNog voordat je ook maar één traceback opent, leest de samenvatting al als een diagnose: de lege winkelwagen is in orde, maar de hoeveelheden gaan mis. "Één reden om te falen" betekent niet één assert-regel — twee asserts die twee kanten van hetzelfde resultaat controleren zijn prima. Het betekent één gedrag per test.
De testpiramide
Tests komen in verschillende maten:
- Unittests controleren één functie of klasse, zonder iets echts eromheen. Elk duurt milliseconden. Je hebt er duizenden.
- Integratietests controleren of stukken goed bij elkaar passen: je code tegen een echte database, een echt bestandssysteem, een echte HTTP-app via een testclient. Trager; je hebt er tientallen tot honderden.
- End-to-end tests laten het hele systeem draaien zoals een gebruiker het zou doen — een browser, een gedeployde API. Traag en broos; je hebt er een handvol, die de paden dekken die geld opleveren.
Geteld is dat een piramide: een brede basis van unittests, een dunne top van end-to-end tests. De reden is kosten. Wanneer een unittest faalt, wijst hij naar een paar regels. Wanneer een end-to-end test faalt, wijst hij naar het hele systeem. Duw elke controle zo ver mogelijk naar beneden in de piramide, en houd de top voor wat alleen de top kan zien.
Red, green, refactor
Test-driven development (TDD) draait in een strakke lus:
- Red — schrijf één kleine test voor gedrag dat nog niet bestaat, en kijk hoe hij faalt
- Green — schrijf de minimale code die hem laat slagen
- Refactor — maak de code netjes, met de tests de hele tijd groen
Waarom kijken hoe hij faalt? Omdat een test die je nog nooit hebt zien falen misschien niets test. De red-stap bewijst dat de test de afwezigheid van de functie kan vangen.
We nemen het plan uit de tabel, één rij per keer.
Red. De eerste rij, voordat slugs.py bestaat:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sDat is een prima rood. De test vraagt om iets dat er niet is.
Green. De minimale code die slaagt — en het is echt de minimale:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sHet voelt als valsspelen. Dat is bewust: de code doet alleen wat een test tot nu toe heeft geëist. Alles meer zou code zijn die geen test controleert.
Red. De tweede rij van de tabel:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sDe ?-regel markeert precies de twee tekens die er niet hadden moeten staan.
Green. Nu is de vervang-één-spatie-truc niet genoeg. Vervang elke reeks van "geen letter of cijfer" door één koppelteken, en knip daarna de koppeltekens van de uiteinden af:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sDe rijen voor extra spaties en cijfers zouden al slagen met deze code. Voeg ze toch toe — ze horen bij het contract — maar weet dat een test die de eerste keer dat hij draait al groen is, niets heeft bewezen over de code die je net schreef. Alleen een rood-dan-groen test bewijst iets.
Red. Geaccentueerde letters:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sé staat niet in a-z, dus die werd samen met de leestekens weggegooid.
Green. Unicode-normalisatie "NFKD" splitst é in e plus een apart accentteken; daarna met "ignore" naar ASCII coderen laat het accentteken vallen en houdt de e over:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactor. Het werkt, maar de body is één dichte regel. Geef elke stap een naam, compileer het patroon één keer, en voeg typehints toe — zonder het gedrag te veranderen:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================Dit is de stap die TDD de moeite waard maakt. Je mocht vrij herstructureren omdat de tests gedrag controleren; hadden ze _to_ascii of de regex rechtstreeks aangeroepen, dan was de refactor ze kapotgegaan.
Property-based testen met Hypothesis
Elke test tot nu toe heeft één verzonnen voorbeeld: "Hello World", "Café au lait". De voorbeelden zijn zo goed als je fantasie, en je fantasie deelt zijn blinde vlekken met de code die je net schreef.
Een property is een bewering die geldt voor elke invoer. Hypothesis genereert de invoer — standaard honderd per test — en probeert hard er één te vinden die de bewering breekt. @given zegt waar de invoer vandaan komt; strategies (altijd geïmporteerd als st) beschrijft hun vorm:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sEén puntje, maar er zijn honderd lijsten met prijzen en honderd kortingen doorheen gegaan. Andere strategieën die je vaak gebruikt: st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...).
Welke properties heeft slugify? Je kunt niet zeggen wat de slug van een willekeurige string is, maar je kunt zeggen hoe hij eruit moet zien, en dat het slugifyen van een slug niets verandert:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text() levert emoji, Chinese tekens, besturingstekens, lege strings — invoer die je nooit in de plantabel zou hebben getypt.
Een round trip vindt een echte bug
De nuttigste property is de round trip: als je iets kunt coderen en weer decoderen, moet het decoderen van de codering het origineel teruggeven. Hier is een run-length encoder — "aaab" wordt "3a1b" — met twee voorbeeldtests die slagen:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sDe tekst "0" codeert naar "10" — "één nul" — en de decoder leest 10 als een aantal zonder teken erna. Elke tekst met een cijfer wordt beschadigd. Beide voorbeeldtests gebruikten alleen letters, dus die konden het nooit zien.
Shrinking
Hypothesis struikelde niet meteen over "0". Het vond een lange, rommelige string en shrinkte die daarna: het probeerde kleinere en eenvoudigere invoer, hield elke invoer die nog steeds faalde, tot niets eenvoudigers meer faalde. Je kunt dat zien gebeuren door elke falende invoer te registreren:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seed vastzet de willekeurige keuzes zodat deze run herhaalbaar is; database=None voorkomt dat Hypothesis een onthouden fout opnieuw afspeelt.) De eerste fout is dertien tekens ruis met een verborgen 9 erin. Je zou er minuten naar staren. Het geshrinkte voorbeeld vertelt het hele verhaal in één teken: een cijfer breekt het.
Hypothesis bewaart falende voorbeelden ook in een map .hypothesis/, dus de volgende run probeert eerst "0". Voeg die map toe aan .gitignore.
De oplossing zet een scheidingsteken tussen aantal en teken, zodat een cijfer nooit voor een deel van het aantal kan worden aangezien. En het voorbeeld dat Hypothesis vond wordt vastgezet met @example, zodat het elke keer draait, op elke machine:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)De twee voorbeeldtests veranderen naar het nieuwe formaat ("3:a1:b"), en de property krijgt één regel erbij:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sProperties vervangen voorbeelden niet. De twee voorbeeldtests documenteren het formaat op een manier die een lezer in één oogopslag begrijpt; de property bewaakt de hoeken waar niemand aan gedacht heeft. Gebruik beide.
Flaky tests
Een flaky test slaagt en faalt op dezelfde code. Dat is erger dan geen test: mensen leren op "opnieuw uitvoeren" te drukken en stoppen rood nog serieus te nemen. Bijna elke flaky test komt uit een van vier oorzaken, en elk heeft een oplossing die de oorzaak wegneemt in plaats van hem te verbergen.
Gedeelde toestand en testvolgorde
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sFaalt in de suite, slaagt alleen. De set op modulniveau blijft bestaan van de ene test naar de volgende, dus de tweede test ziet wat de eerste heeft achtergelaten. Verander de volgorde, of draai tests parallel met pytest-xdist, en het resultaat verandert. De verkeerde oplossing is de tests herordenen. De juiste oplossing haalt de gedeelde toestand weg: maak het register een object, en geef elke test via een fixture een nieuwe:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sAls je de code niet kunt aanpassen, is een autouse fixture die de toestand reset vóór en na elke test de reserveoplossing. Elke test moet op zichzelf slagen, en in elke volgorde.
Tijd
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonDezelfde code, op hetzelfde moment gedraaid, op twee machines met verschillende tijdzones ingesteld:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sDe oplossing zit al in de signatuur van de functie: now kan worden meegegeven. Een test die de klok controleert, test beide kanten van de grens, en geeft op elk uur hetzelfde antwoord:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sDe tijd meegeven is eenvoudiger dan welke mock ook. Als je de signatuur niet kunt veranderen, vervangt monkeypatch uit hoofdstuk tien de klok in plaats daarvan.
Willekeur
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"Zes keer draaien, zonder iets te veranderen:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sTwee oplossingen, voor twee soorten vragen. Controleer wat waar is voor elke uitkomst — de winnaar is een van de deelnemers. Of houd de willekeur in de hand door een gezaaide generator mee te geven:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sDe buitenwereld
De vierde oorzaak is alles wat je niet controleert: een echte netwerkaanroep, een echte server, een sleep(0.1) die "wel genoeg zou moeten zijn". De oplossingen zijn die uit eerdere hoofdstukken — mock op de grens, gebruik tmp_path in plaats van een gedeelde map, wacht op een voorwaarde in plaats van op een vaste tijd. Eén vorm dekt alle vier oorzaken: wat de test ook nodig heeft, de test moet het zelf maken of beheersen.
Een regressietest voor elke bug
Terug naar het vraagteken in de plantabel. Niemand heeft het beantwoord, en het bugrapport komt binnen: een bericht met de titel `"!!!"` werd opgeslagen op `/posts/`, en overschreef de indexpagina.
Schrijf voordat je slugify aanraakt een test die het rapport reproduceert, en kijk hoe hij faalt. Dat bewijst dat de test deze bug vangt:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sRepareer hem daarna — het einde van slugify wordt:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sDe regressietest en de drie voorbeeldtests slagen. Maar draai de hele suite, en de property-tests protesteren:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesis probeerde de lege string, en slugify("") gooit nu een fout. Dat is geen nieuwe bug — het is het contract dat opzettelijk verandert, en de properties die dat opmerken. Goed. Werk ze bij zodat ze het nieuwe contract beschrijven: titels die minstens één letter of cijfer bevatten.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothing schakelt op dezelfde manier over naar @given(titles).
...... [100%]
6 passed in 0.01sWaarom een test voor elke bug? Omdat een bug die één keer is gebeurd bewezen heeft dat hij makkelijk te maken is, en de volgende die die code aanraakt maakt hem waarschijnlijk opnieuw. Het commentaar dat zegt wat er misging is onderdeel van de test: over een jaar is het het enige verslag van waarom dit vreemde geval hier staat.
Een compleet voorbeeld
Een controle op wachtwoordsterkte, gebouwd met alles uit dit hoofdstuk. Eerst het plan:
| Geval | Invoer | Verwacht | |---|---|---| | leeg | "" | weak | | elke soort, maar te kort | "Ab1!xyz" (7) | weak | | lang genoeg, één soort | "abcdefgh" | weak | | lang genoeg, twee soorten | "abcdefg1" | medium | | 12 tekens, drie soorten | "abcdefghij1!" | strong | | 12 tekens, twee soorten | "abcdefghijk1" | medium |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================Drie beslissingen verdienen aandacht.
De tabelrijen zitten op de grenzen — 7 en 8 tekens, 11 en 12, twee soorten en drie. Bugs zitten op grenzen: een < die <= had moeten zijn is onzichtbaar in het midden van een bereik.
De property zegt iets wat geen voorbeeld kan zeggen: extra tekens maken een wachtwoord nooit zwakker. Niemand zou die test met de hand voor elk wachtwoord schrijven, en Hypothesis controleert hem tegen honderden, inclusief Unicode-letters die noch hoofdletter noch kleine letter zijn.
En geen enkele test raakt _kinds aan. Hij wordt bereikt via strength; morgen kan hij weg zijn.
Wanneer het misgaat
AttributeError: 'Cart' object has no attribute '_items' na een refactor De test las het binnenste van het object. Herschrijf hem om via de publieke methoden te controleren — wat een aanroeper kan zien — dan overleeft hij ook de volgende refactor.
Een test slaagt alleen, maar faalt in de volledige run (of andersom) Gedeelde toestand: een lijst, dictionary of cache op modulniveau; een bestand op een vaste plek; een omgevingsvariabele die gezet maar nooit weer verwijderd wordt. Laat elke test zelf maken wat hij nodig heeft (fixtures, tmp_path, monkeypatch) in plaats van de tests te herordenen.
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. Een .filter() (of assume()) gooit bijna alles weg wat Hypothesis genereert — bijvoorbeeld st.integers().filter(lambda n: n % 1000 == 7). Bouw de waarden die je wilt direct: st.integers().map(lambda n: n * 1000 + 7).
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one De test gaf een ander antwoord voor dezelfde invoer. Iets buiten de gegenereerde argumenten veranderde tussen de aanroepen — een teller, een lijst op modulniveau, de klok. Hypothesis speelt elke fout opnieuw af, dus een test met verborgen toestand kan niet shrinken.
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. Elk gegenereerd voorbeeld moet standaard binnen 200 ms klaar zijn, omdat honderd trage voorbeelden een trage suite maken. Maak de test sneller, of, als de traagheid echt is, verhoog de limiet met @settings(deadline=...).
Een nieuwe TDD-test slaagt de eerste keer dat je hem draait Hij bewijst niets over de code die je gaat schrijven. Het gedrag bestaat al (prima — bewaar de test als documentatie), of de test controleert niet wat je denkt. Breek de code even bewust en bevestig dat de test rood wordt.
Stap 4 van 6 — Voorspellen
Test je begrip
Uitgevoerd als heel bestand faalt de tweede test. Wat is de laatste regel als alleen de tweede test op zichzelf draait?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cart gaat intern van een lijst naar een dictionary, en het gedrag blijft hetzelfde. Welke test wordt rood?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
Deze test slaagt van maandag tot vrijdag en faalt op zaterdag en zondag. Wat is de beste oplossing?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- AGeef een vaste datum mee — `is_weekend(date(2026, 10, 7))` — en voeg een aparte test toe voor een zaterdag
- BSchrijf `== False` in plaats van `is False`
- CVoer de test automatisch opnieuw uit wanneer hij faalt
- DSla de test over op zaterdag en zondag
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
Bouw twee functies, testgedreven, in durations.py:
parse_duration(text)zet"1h30m"om in5400(seconden). De eenheden zijnh,m,s, in die volgorde, elk optioneel;"45s","2h"en"1h0m5s"zijn allemaal geldig. Al het andere —"","h","1x","30m1h","1.5h"— gooitValueErrormetnot a durationin de melding.format_duration(seconds)gaat de andere kant op:5400wordt"1h30m",3605wordt"1h5s",0wordt"0s". Een negatief getal gooitValueError.
Werk in deze volgorde:
- Schrijf eerst de plantabel: geval, invoer, verwacht — inclusief de ongeldige invoer en de grenzen (bijvoorbeeld
59en60seconden). - Ga rij voor rij door de red-green-cycli. Draai
pytestna elke wijziging, en kijk naar het rood voordat je het groen maakt. - Voeg de round-trip property toe met Hypothesis: voor elk geheel aantal seconden vanaf
0geldtparse_duration(format_duration(n)) == n. - Een gebruiker meldt dat
"90m"werd geweigerd, terwijl mensen dat de hele tijd typen. Voeg een regressietest toe, met een commentaar dat zegt wat er misging.
Refactor als je klaar bent één keer, en controleer dat de hele suite groen blijft.
Oplossing
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sWaarom het zo is opgebouwd:
- Het plan werd de parametrize-tabellen. Elke rij van de tabel is één rij in
parametrize, dus een geval later toevoegen is één regel, en de testnamen in-v-output laten zien welke rij faalde. - Geldige en ongeldige invoer zijn aparte tests. Het zijn aparte gedragingen met aparte redenen om te falen. De lijst met ongeldige invoer bevat één voorbeeld van elk soort fout: leeg, eenheid zonder getal, onbekende eenheid, verkeerde volgorde, decimaal.
match="not a duration"controleert de melding naast het type, zodat eenValueErrordie per ongeluk ergens anders gegooid wordt —int(""), bijvoorbeeld — de test niet kan laten slagen.if not textstaat er omdat het patroon, met elk deel optioneel, de lege string matcht. Dat is precies het soort geval waar de plantabel je toe dwingt om na te denken voordat de regex het voor je doet.59en60zijn de grens waar seconden overlopen naar minuten; een test in het midden van het bereik zou een off-by-one-fout daar nooit vangen.- De round-trip property is de sterkste test in het bestand. Hij kent geen enkele verwachte waarde, maar controleert tien miljoen mogelijke tijdsduren tegen elkaar — elk verschil tussen de twee functies komt naar boven, geshrinkt tot het kleinste getal dat het breekt.
- De regressietest slaagt al met deze implementatie; hij bestaat om te voorkomen dat een toekomstige "opruimbeurt" die minuten beperkt tot
0-59de bug terugbrengt. Zijn commentaar zegt waarom. - Niets test
PATTERNofUNITSrechtstreeks. Dat is implementatie, en je mag de regex morgen vervangen door een met de hand geschreven parser.
Waar je hierna naartoe kunt
Dit is het einde van de cursus. Je kunt tests schrijven, hun fouten lezen, ze structureren, isoleren, meten, pytest zelf uitbreiden, en — na dit hoofdstuk — bepalen wat het de moeite waard is om te testen. Enkele richtingen vanaf hier:
- Maak het een gewoonte op echte code. Neem een project dat je al hebt en voeg tests toe aan het deel waar je het meest bang voor bent om het te veranderen. Schrijf eerst de plantabel. De angst gaat omlaag terwijl de tests omhoog gaan.
- Ga verder met Hypothesis. De documentatie behandelt stateful testen (
RuleBasedStateMachine), dat hele reeksen bewerkingen tegen een object genereert, en bugs vindt die geen enkele losse invoer kon vinden. - Mutatietesten. Tools zoals
mutmutmaken kleine wijzigingen in je code —<naar<=,+naar-— en controleren of een test faalt. Een mutatie die overleeft is een regel die je tests niet echt controleren, hoe hoog het coveragegetal ook is. - Breid de CI-opzet uit hoofdstuk veertien uit. Draai de suite op elke Python-versie die je ondersteunt, en laat een falende test de merge blokkeren — een suite die niemand verplicht om groen te houden, stopt langzaam met groen zijn.
- Lees goede testsuites. De tests van
pytest,requests,attrsenhypothesiszelf zijn open source. Lezen hoe ervaren mensen tests benoemen, structureren en isoleren leert meer dan welke regellijst ook.
Wat je ook schrijft, begin met de vraag waarmee dit hoofdstuk opende: als iemand deze code morgen verbetert zonder te veranderen wat hij doet, blijven mijn tests dan groen — en als iemand hem kapotmaakt, worden ze dan rood?
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