Voordat je een test schrijft — wat je echt nodig hebt
Het denkwerk voorafgaand aan testcode: een vage eis omzetten in een precies contract, code testbaar maken, testgevallen met grenswaarden kiezen, het verwachte antwoord weten zonder de code uit te voeren, en bepalen wat je niet test — allemaal met gewone Python en assert.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een functie die het gemiddelde berekent van een lijst met examencijfers, en de manier waarop de meesten van ons zo'n functie controleren — roep hem een paar keer aan en kijk:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0Beide antwoorden kloppen. De functie "werkt", en belandt in het programma. Een week later bereikt een klas die nog geen cijfers heeft dezelfde regel:
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero"Hij deed het toen ik hem probeerde" was waar. Maar het betekende alleen maar: "hij werkt voor de twee invoerwaarden die ik toevallig probeerde". Handmatig controleren heeft twee zwakke punten die hierdoor worden blootgelegd.
Het eerste is dat het alleen dekt waar je op dat moment toevallig aan dacht, en een lege lijst is precies zoiets waar niemand op dat moment aan denkt.
Het tweede is dat het zich niet herhaalt. De twee print-aanroepen werden één keer uitgevoerd, één keer bekeken en weer verwijderd. Als de functie volgende maand verandert, voert niemand ze opnieuw uit.
Geautomatiseerde tests lossen het tweede zwakke punt op, en de rest van deze cursus gaat over het schrijven daarvan met pytest. Maar kijk eens wat beter naar het eerste zwakke punt, want geen enkele tool lost dat voor je op. Stel dat je nu meteen een check voor de lege lijst zou willen schrijven. Wat zou die moeten zeggen? Moet average([]) een 0 teruggeven? None teruggeven? Een error geven? Niemand heeft dat besloten. Je kunt geen test schrijven voor gedrag waarover niemand een besluit heeft genomen, en je kunt een antwoord dat je zelf nog niet weet, niet controleren.
Dat is waar dit hoofdstuk over gaat: het denkwerk dat moet gebeuren vóór de eerste regel testcode. Doe je dat goed, dan schrijven de tests zich bijna vanzelf. Sla je het over, dan eindig je met tests die slagen en toch de bug missen.
Aan het einde van dit hoofdstuk kun je
- In één zin vertellen wat een test is: een uitvoerbare claim over gedrag
- Een vage eis omzetten in een precies contract — invoer, uitvoer, errors, side-effects
- Code herkennen die lastig te testen is, en die herstructureren naar een pure kern met een dunne schil
- Testgevallen plannen met equivalentieklassen en grenswaarden, in de vorm van een tabel
- Verwachte antwoorden onafhankelijk van de code bepalen — het orakel
- Beslissen wat je niet test, en bepalen wanneer je genoeg tests hebt
- Het plan uitschrijven als gewone
assert-checks, ze uitvoeren en vertellen waarom ze nog niet genoeg zijn
Voorkennis: geen — dit is waar de cursus begint. Je hebt Python 3 nodig en je moet weten hoe je een functie schrijft. Je hebt pytest nog niet nodig; alles in dit hoofdstuk werkt met gewone Python.
Voordat je de test schrijft
Elk hoofdstuk van deze cursus heeft een paragraaf met deze naam, en in elk daarvan worden dezelfde zes vragen beantwoord voor het eigen onderwerp. Dit hoofdstuk is waar die vragen vandaan komen, dus hier staan ze op één plek. Bewaar ze; de rest van de cursus gebruikt ze als checklist.
- Wat is er precies beloofd? Het contract: voor deze invoer, deze uitvoer; voor deze invoer, deze error; en deze side-effects, of geen.
- Kan de code worden aangeroepen door een test? Neemt hij zijn invoer aan als argumenten en levert hij een resultaat op — of leest hij vanaf het toetsenbord, print hij en kijkt hij naar de klok?
- Welke testgevallen? Eén van elke soort invoer, beide kanten van elke rand, de ongeldige invoer, het lege en het heel grote.
- Hoe weet ik het juiste antwoord? Met de hand berekend of vanuit de specificatie — nooit door de te testen code uit te voeren.
- Wat moet er klaarstaan? De Python-versie, een omgeving, waar de testbestanden komen te staan — en in latere hoofdstukken: bestanden, instellingen en tools die het onderwerp vereist.
- Wat ga ik niet testen? Python zelf, libraries van anderen, code die te simpel is om fout te zijn, en gedrag dat niemand heeft beloofd.
Hier worden ze toegepast op average van hierboven, dat klein genoeg is om helemaal te doen.
Het contract bevat een leemte, dus de eerste stap is om die te dichten. Stel dat je het navraagt, en het antwoord luidt: "een gemiddelde van nul cijfers slaat nergens op; geef een ValueError". De functie is al testbaar — hij accepteert een lijst en retourneert een getal. Voor dit hoofdstuk is Python 3 het enige dat klaar moet staan. En de testgevallen:
| Geval | Invoer | Verwacht | Waarom | | --- | --- | --- | --- | | Typisch | [80, 90, 70] | 80.0 | het normale gebruik: (80 + 90 + 70) / 3 | | Een enkel cijfer | [100] | 100.0 | kleinste lijst die een gemiddelde heeft | | Leeg | [] | ValueError | het geval dat stukging; nu besloten |
Wat er niet op de lijst staat: of sum juist optelt, of dat / deelt — dat is de taak van Python, en dat is veel grondiger getest dan wat jij of ik ooit zullen schrijven.
De rest van het hoofdstuk behandelt elk van de zes vragen op volgorde, en sluit af door het hele proces toe te passen op één realistische functie.
Wat een test eigenlijk is
Een test is een uitvoerbare claim over gedrag: gegeven deze invoer, verwacht dit resultaat. "Resultaat" komt voor in precies drie vormen:
- een returnwaarde —
average([80, 90, 70])levert80.0op - een error —
average([])geeft eenValueError - een side-effect — na
add_score(scores, 70)heeft de lijstscoresaan het einde een70
Python heeft al een statement om zo'n claim op te schrijven: assert. Het doet niets wanneer de voorwaarde waar is, en veroorzaakt een AssertionError als deze onwaar is. Hier is één claim van elke soort, waarbij average nu het vastgelegde contract volgt:
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedLet op wat elke claim nodig heeft: een invoer die jij kiest, een resultaat dat je al kent, en een manier om de code aan te roepen en te zien wat hij heeft gedaan. De zes vragen zijn er om te zorgen dat je alle drie paraat hebt voordat je begint. Zie ook hoe omslachtig de error-controle is — vijf regels try/except/else om te zeggen "dit hoort een fout te geven". pytest maakt daar in hoofdstuk vier één regel van.
1. Het contract: van een vage eis naar een precieze belofte
Eisen worden meestal aangeleverd als één zin: "Leden krijgen 10% korting op grote bestellingen." Het klinkt compleet. Geef het aan twee zorgvuldige ontwikkelaars en kijk wat er gebeurt:
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123Geen van beiden heeft een fout gemaakt. De zin vermeldde niet of precies 1000 telt als "groot", of hoe er moet worden afgerond. Ieder vulde de leemte anders in, en een bestelling van 1000 krijgt van de één een korting van 0 en van de ander 100. Geen enkele test kan zeggen wie van hen gelijk heeft, want wat "goed" is, werd nooit gedefinieerd.
Een contract is de eis die precies genoeg is gemaakt om te kunnen testen. Om van de zin naar het contract te komen, stel je vragen — telkens weer dezelfde:
- Invoer. Welke types? Welk bereik? Is er een grens, en telt die grens zelf ook mee? Wat gebeurt er bij nul, negatief, leeg of
None? - Uitvoer. Wat wordt er precies geretourneerd — de korting, of het nieuwe totaalbedrag? Welk type? Waarop afgerond?
- Errors. Welke invoer wordt geweigerd, en hoe — welke exception?
- Side-effects. Verandert er iets — een meegegeven lijst, een bestand, een database — of wordt er iets geprint? Of helemaal niets?
Toegepast op de korting leveren die vragen antwoorden op, en die antwoorden horen thuis waar de code staat — in de docstring:
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5Elke regel in de docstring is nu iets wat een test kan controleren, en elke check heeft een duidelijke verwachte waarde. Dat is de lakmoesproef voor een goed contract: zou een buitenstaander voor elke invoer de verwachte waarde kunnen opschrijven, zonder de code te lezen? Zo niet, dan is er nog een vraag die gesteld moet worden.
Wie beantwoordt die vragen? Degene van wie de eis afkomstig is — een product owner, een docent, een klant of jijzelf. Is er niemand om het aan te vragen, neem dan zelf een besluit, leg dat besluit vast in het contract en ga verder. Een expliciet besluit kan worden herzien en gewijzigd; een stilzwijgend besluit blijft gewoon in de code zitten tot iemand erdoor wordt verrast.
2. Testbare code: een pure kern en een dunne schil
Een test roept een functie aan met gekozen invoerwaarden en vergelijkt wat er terugkomt. Sommige functies maken dat onmogelijk. Hier is er eentje, voor het happy hour van een café — 20% korting op drankjes tussen 17:00 en 19:00:
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")Het werkt als je het uitvoert en een prijs intypt. Probeer het nu eens te controleren vanuit een ander bestand, check_drinks.py:
from drinks import drink_price
result = drink_price()
print("returned:", result)Een geautomatiseerde check draait zonder dat er iemand achter het toetsenbord zit. < /dev/null geeft het programma precies dat — invoer waar niets in zit:
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a lineVoer een prijs in, en het draait — maar kijk eens wat er terugkomt:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: NoneDrie afzonderlijke problemen, die elk heel vaak voorkomen:
- Het leest zijn invoer met
input()in plaats van die als argument aan te nemen, waardoor een test de invoer niet kan kiezen. - Het print zijn resultaat in plaats van het te retourneren, waardoor een test
Noneterugkrijgt en niets heeft om te vergelijken. - Het leest de klok zelf af. Die run was net voor zeven uur 's ochtends, dus het antwoord was
10.00; hetzelfde commando om half zes 's avonds levert8.00op. Een check hierop zou slagen of falen afhankelijk van het tijdstip van uitvoering — een flaky test, en dat is erger dan helemaal geen test, omdat mensen leren die te negeren.
De oplossing is geen testtrucje. Het is een verandering van structuur: scheid de beslissing van de invoer en uitvoer. De beslissing wordt een pure functie — alles wat die nodig heeft komt binnen als argumenten, het antwoord gaat naar buiten als returnwaarde, en verder raakt hij niets aan. Het toetsenbord, het scherm en de klok verhuizen naar een dunne schil eromheen:
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()Nu kunnen de checks elk gewenst uur kiezen, op elk moment van de dag:
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedHet programma gedraagt zich voor degene achter het toetsenbord precies zoals voorheen. Wat veranderd is, is dat het deel dat het testen waard is — de regel — nu geïsoleerd getest kan worden, en de schil is zo dun dat er bijna niets in zit wat mis kan gaan.
De alarmsignalen van lastig te testen code, zodat je ze herkent voordat je een test schrijft:
| Signaal | Waarom het een test hindert | De gebruikelijke oplossing | | --- | --- | --- | | input() in de logica | de test kan de invoer niet kiezen | neem die als argument aan | | print() als enig resultaat | er komt niets terug om te vergelijken | retourneer de waarde; print in de schil | | datetime.now(), random in de functie | het antwoord verandert tussen runs | geef de tijd of de willekeurige waarde mee | | leest of wijzigt een globale variabele | tests beïnvloeden elkaar | geef hem mee, retourneer de nieuwe waarde | | opent een vast bestand of vaste URL in de functie | de test heeft dat bestand of netwerk nodig | geef de data of het pad mee |
Je kunt code niet altijd herstructureren — soms is de code van iemand anders, of is de I/O het gedrag. pytest heeft tools voor die situaties: capsys vangt geprinte uitvoer op (hoofdstuk negen), tmp_path biedt een tijdelijke map (hoofdstuk negen), en monkeypatch en mocks vervangen de klok, het netwerk en de omgeving (hoofdstuk tien en elf). Maar herstructureren komt eerst. Code die makkelijk te testen is, is meestal ook gewoon makkelijker te begrijpen.
3. De testgevallen kiezen
Je kunt niet elke invoer testen; drink_price alleen al accepteert elke prijs op elk uur. Het doel is om een kleine set te kiezen waarin elk testgeval een fout kan ontdekken die de andere zouden missen. Twee concepten doen het meeste van dat werk.
Equivalentieklassen. Groepeer de invoerwaarden die door het contract op dezelfde manier worden behandeld. Elk willekeurig lid van zo'n groep vertegenwoordigt de rest: als drink_price(10, 12) klopt, klopt drink_price(10, 11) vrijwel zeker ook, omdat dezelfde regel code beide afhandelt. Stel dat het contract is aangescherpt en zegt dat een uur buiten 0–23, of een negatieve prijs, een ValueError veroorzaakt. Dan vallen de uren uiteen in vijf klassen:
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalidGrenswaarden. Fouten verspreiden zich niet gelijkmatig over een klasse. Ze hopen zich op bij de randen, want dat is waar keuzes worden gemaakt tussen < en <=, en waar "tot 19:00" wordt omgezet in code. Test daarom voor elke rand de waarde aan weerszijden ervan: de laatste van de ene klasse en de eerste van de volgende. Voor een grens L betekent dat kijken naar L - 1, L en L + 1; voor aantallen en lengtes ook naar 0 en 1.
Hier zie je waarom "één typische waarde per klasse" niet genoeg is. Hieronder is de happy-hour-test geschreven met een veelgemaakte vergissing, 17 < hour in plaats van 17 <= hour:
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorEén geval uit elke klasse: ze slagen allemaal, en klanten die om vijf uur binnenkomen betalen de volle prijs. Het grensgeval ontdekt het meteen.
Klassen en grenzen dekken de vorm van de invoer af. Loop daarna deze vragen na, die allemaal al echte bugs aan het licht hebben gebracht:
- Ongeldige invoer — wat het contract aangeeft te weigeren: een negatieve prijs, uur
24. - Leeg en nul —
[],"",0, een prijs van0. Gedraagt "niets" zich netjes? None— alleen als het contract er melding van maakt. Zo niet, dan is het geen testgeval (zie vraag zes).- Grote waarden — een heel lange lijst, een heel groot getal. Alles met een limiet: test op de limiet.
- Afronding — waar een berekening meer decimalen oplevert dan het resultaat bewaart.
Samen vormt dat een testplan: één rij per testgeval, opgesteld vóór enige testcode. De laatste kolom is het belangrijkst — een rij die je niet kunt onderbouwen, is een rij die je niet nodig hebt.
| Geval | Invoer (price, hour) | Verwacht | Waarom | | --- | --- | --- | --- | | Laagste geldige uur | (10, 0) | 10 | rand van het geldige bereik | | Laatste uur met normale prijs | (10, 16) | 10 | onder de 17:00-grens | | Eerste uur met korting | (10, 17) | 8.0 | op de 17:00-grens | | Laatste uur met korting | (10, 18) | 8.0 | onder de 19:00-grens | | Eerste uur weer met normale prijs | (10, 19) | 10 | op de 19:00-grens — "niet inbegrepen" | | Hoogste geldige uur | (10, 23) | 10 | rand van het geldige bereik | | Gratis drankje | (0, 17) | 0 | prijs van nul | | Afronding | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, afgerond op 2 decimalen | | Uur te laag | (10, -1) | ValueError | net buiten het bereik | | Uur te hoog | (10, 24) | ValueError | net buiten het bereik | | Negatieve prijs | (-1, 12) | ValueError | ongeldige invoer |
En het plan omgezet in checks voor de aangescherpte functie:
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passedElf rijen, en elk ervan staat er om een reden die je hardop kunt uitspreken.
4. Het orakel: het antwoord weten zonder de code
Elke check vergelijkt het antwoord van de code met een verwacht antwoord. De bron van het verwachte antwoord noemen we het orakel, en dat kent één regel: het mag niet de te testen code zelf zijn.
Dat klinkt te vanzelfsprekend om te benoemen, totdat je ziet hoe makkelijk die regel wordt overtreden. Hier heeft member_discount een bug — 1% in plaats van 10% — en twee checks:
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorDe eerste check vergelijkt de functie met zichzelf. Hij slaagt voor de juiste functie, voor deze kapotte versie, en voor elke andere versie die iemand zou kunnen schrijven — hij kan niet falen, dus hij vertelt je niets.
Niemand schrijft het zó openlijk op. Dezelfde fout verschijnt meestal in een van twee gedaanten:
- De uitvoer kopiëren. Je voert de functie uit, hij print
20.0, en je plakt20.0in de check. Als de code al fout was, bewaakt de check nu de bug. - De formule herhalen. Je schrijft de verwachte waarde als
2000 * 0.01— dezelfde berekening die de code doet. Een kopie van de logica van de code deelt de fouten van de code. ("Wanneer het misgaat", hieronder, laat zien hoe dit in de praktijk uitpakt.)
Goede orakels, grofweg van meest naar minst voorkomend:
- Met de hand uitrekenen vanuit het contract. Kies invoerwaarden die dit makkelijk maken: liever 2000 dan 1873.41, zodat "10% daarvan" iets is wat je uit je hoofd kunt doen.
- Voorbeelden in de specificatie. Als de eis zegt "een bestelling van 1500 krijgt 150 korting", dan is die rij een test.
- Bekende feiten. Water kookt bij 100 °C en 212 °F; een lege lijst heeft lengte 0.
- Een andere, vertrouwde methode. Een eenvoudigere, tragere berekening, of een functie uit de standard library die dezelfde taak op een andere manier uitvoert:
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.mean is door anderen geschreven, op een andere manier, dus het feit dat het ermee overeenkomt, zegt iets. (Het vergelijken van decimale resultaten met == kan gevaarlijk zijn — 0.1 + 0.2 == 0.3 is False in Python. De waarden hier zijn veilig; hoofdstuk vijf laat zien hoe je floats op de juiste manier vergelijkt.)
Als je het verwachte antwoord met geen van deze middelen kunt bepalen, is dat geen testprobleem. Het betekent dat je nog niet weet wat de code zou moeten doen — ga terug naar het contract.
5. Wat er klaar moet staan
Voor dit hoofdstuk alleen Python 3. Controleer welke versie je hebt:
$ python3 --version
Python 3.12.3Deze cursus gebruikt Python 3.12, en vrijwel alles werkt met versie 3.10 of hoger. Op Windows is het commando meestal py --version.
Vanaf het volgende hoofdstuk moeten er nog drie dingen klaarstaan voordat een test kan draaien, en hoofdstuk één richt elk daarvan in:
- Een virtuele omgeving voor het project, zodat de testtool geïnstalleerd is in dezelfde Python die jouw code uitvoert.
- pytest daarin geïnstalleerd.
- Een plek voor de tests. Testbestanden staan naast de code, of in een map
tests/, met namen die beginnen mettest_. Hoofdstuk één begint met de eerste optie; hoofdstuk drie stapt over naar de tweede en legt uit waarom.
Latere hoofdstukken voegen daar hun eigen vereisten aan toe — een configuratiebestand, een plugin, voorbeelddata op schijf — en elk hoofdstuk vermeldt die in de eigen paragraaf "Voordat je de test schrijft".
6. Wat je niet moet testen, en wanneer je stopt
Een test die geen fout in jouw code kan ontdekken, kost tijd om te schrijven, tijd om uit te voeren en tijd om te onderhouden, en levert niets op. Laat deze achterwege:
- Python zelf. Niet of
sortedsorteert ofroundafrondt. Test of jouw functie de juiste dingen in de juiste volgorde sorteert. - Libraries van anderen. Niet of
requestseen request verstuurt ofpandaseen CSV leest. Test wat jouw code met het resultaat doet — en als je zeker wilt zijn van het gedrag van een library, is een enkele kleine check die je aanname documenteert voldoende. - Code die te simpel is om fout te zijn. Een functie die een constante retourneert, of een klasse die alleen zijn argumenten opslaat. Als je je de fout niet kunt voorstellen, kan de test hem ook niet ontdekken.
- Gedrag dat niemand heeft beloofd. Als het contract niets zegt over het meegeven van een string aan
drink_price, zou een test alleen maar vastleggen wat de functie toevallig vandaag doet. Voeg het óf toe aan het contract — en test het dan — óf laat het weg. - Het hoe, in plaats van het wat. Niet welke helperfunctie intern werd aangeroepen, of in welke volgorde de stappen werden uitgevoerd. Test wat erin gaat en wat eruit komt; dan kan de binnenkant naar hartenlust worden herschreven, terwijl de tests bewijzen dat het nog steeds werkt.
En hoeveel is genoeg? Er is geen vast getal, maar er is wel een regel. Je hebt genoeg wanneer:
- elke equivalentieklasse minstens één testgeval heeft,
- elke grens aan weerszijden een testgeval heeft,
- elke error die het contract belooft een testgeval heeft,
- en elke bug die je ooit hebt opgelost een eigen testgeval heeft, zodat hij niet geruisloos kan terugkeren.
Stop dan. Vraag je bij elk nieuw testgeval dat je in de verleiding komt toe te voegen af: welke fout zou dit ontdekken die de andere niet zouden ontdekken? Kun je er geen noemen, dan is het een duplicaat. drink_price(10, 11) naast drink_price(10, 12) ontdekt niets nieuws.
Een compleet voorbeeld
Nu het hele proces, van begin tot eind, toegepast op één realistische functie.
De eis, zoals die binnenkwam: "Verzending is 60 voor lokale pakketten en 120 voor nationale pakketten. Zwaardere pakketten kosten meer — 20 per extra kilo lokaal, 30 nationaal. We nemen niets aan van meer dan 30 kg."
De vragen, en de antwoorden die ze opleverden:
| Vraag | Antwoord | | --- | --- | | Wat dekt de basisprijs? | De eerste kilo — precies 1 kg valt nog steeds onder de basisprijs. | | Wordt 1.2 kg berekend als 1 kg of als 2? | Elk aangebroken kilogram telt: 1.2 kg wordt berekend als 2 kg. | | Wordt 30 kg zelf geaccepteerd? | Ja. 30 is toegestaan; alles daarboven wordt geweigerd. | | Hoe zit het met 0 kg of een negatief gewicht? | Geweigerd — dat is een invoerfout. | | Hoe worden zones geschreven? | Exact "local" of "national". Al het andere wordt geweigerd, inclusief "Local". | | Wat komt er terug? | De kosten als een geheel getal. Er wordt niets geprint of opgeslagen. |
Het contract en de testbare functie, shipping.py. Hij neemt alles aan als argumenten en retourneert een getal; er is geen schil om af te splitsen, want er zit helemaal geen invoer of uitvoer in:
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceil rondt naar boven af op het volgende gehele getal — math.ceil(1.2) is 2 — wat precies "elk aangebroken kilogram telt" is.
De tabel met testgevallen. De gewichtsklassen zijn: ongeldig (0 en lager), het basiskilogram (boven 0, tot en met 1), extra kilo's (boven 1, tot en met 30), ongeldig (boven 30). De zones zijn "local", "national", en al het andere. Elke verwachte waarde is met de hand uit het contract afgeleid, niet uit de code:
| Geval | Invoer | Verwacht | Waarom | | --- | --- | --- | --- | | Licht pakket | (0.5, "local") | 60 | binnen het basiskilogram | | Precies 1 kg | (1, "local") | 60 | bovengrens van de basis: 1 kg is inbegrepen | | Net boven 1 kg | (1.2, "local") | 80 | aangebroken kilogram telt: 60 + 1 × 20 | | Hele kilo's, lokaal | (3, "local") | 100 | 60 + 2 × 20 | | Hele kilo's, nationaal | (3, "national") | 180 | 120 + 2 × 30 — de andere zone | | Op de limiet | (30, "national") | 990 | 120 + 29 × 30; 30 is geaccepteerd | | Nul | (0, "local") | ValueError | ondergrens: 0 wordt geweigerd | | Negatief | (-2, "local") | ValueError | ongeldige invoer | | Boven de limiet | (30.5, "local") | ValueError | net boven de 30 | | Verkeerde zone | (2, "Local") | ValueError | niet exact "local" |
Wat bewust niet in de tabel staat: een gewicht van None of "2". Het contract stelt dat het gewicht een getal is, dus die vallen daarbuiten — Python geeft zelf een TypeError wanneer ze vergeleken worden met 0, en meer dan dat beloven we niet. math.ceil staat er ook niet in: dat is van Python, niet van ons. En geen (2, "local") naast (3, "local"): dezelfde klasse, dezelfde regel code, niets nieuws om te ontdekken.
De checks, check_shipping.py — de tabel, rij voor rij:
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedMaak het nu eens expres stuk. Een paar maanden later ruimt iemand shipping.py op, verwijdert import math en schrijft int(weight_kg) in plaats van math.ceil(weight_kg). Het leest prima, en het draait zonder foutmelding. Voer de checks opnieuw uit:
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorHet plan deed zijn werk: de verandering werd ontdekt zodra de checks draaiden. int kapt de decimalen af in plaats van naar boven af te ronden, waardoor 0.5 kg veranderde in int(0.5) - 1, oftewel -1 extra kilo.
Maar lees het verslag eens alsof je dat nog niet wist, en zie hoe weinig het vertelt:
- Wat was de daadwerkelijke waarde? Het verslag toont de regel, niet het getal. Je moet zelf
shipping_cost(0.5, "local")uitvoeren om erachter te komen dat het40was. - Wat is er nog meer stuk? Het script stopte bij de eerste mislukking. Het testgeval van 1.2 kg klopt ook niet — het levert nu
60op in plaats van80— maar die check is nooit uitgevoerd. - Welke beloften hielden stand? Niets vertelt je dat de error-gevallen nog steeds slagen.
- En voor de error-checks was een helperfunctie met
try/exceptnodig om überhaupt te kunnen zeggen "dit hoort een fout te geven".
Het denkwerk was het moeilijke deel, en dat is klaar: het contract, de tabel, het orakel. Wat ontbreekt is een betere test-runner — eentje die zelf de checks vindt, ze stuk voor stuk uitvoert ook na een mislukking, de werkelijke waarde naast de verwachte toont, en errors in één regel controleert. Dat is pytest, en dat installeren is het onderwerp van het volgende hoofdstuk.
Wanneer het misgaat
Dit zijn planningsfouten in plaats van Python-fouten, dus de symptomen zijn subtieler: meestal een check die slaagt terwijl dat niet zou moeten.
De verwachte waarde herhaalt de eigen formule van de code De check is geschreven door dezelfde berekening uit te voeren als de functie. Hier bevat de functie de int-bug van hierboven, en de check dus ook:
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorDit is ook hoe "het testen van de implementatie" eruitziet: de check spiegelt hoe de code werkt in plaats van vast te leggen wat hij belooft. Schrijf de verwachte waarde op als een gewoon getal vanuit het contract.
De verwachte waarde is gekopieerd uit de uitvoer Je hebt de functie uitgevoerd, zag 20.0, en hebt dat in de check geplakt. Hij slaagt — en als 20.0 fout was, beschermt de check nu de bug. Reken elke verwachte waarde uit vóórdat je de code uitvoert; als jouw getal en dat van de code niet overeenkomen, zoek dan uit welke van de twee fout is voordat je iets opschrijft.
Het plan bevat geen grensgevallen Elke check slaagt, en de bug zit precies op de grens: vijf uur, exact 1 kg, leeftijd 12. Een tabel met alleen "typische" rijen is hiervan meestal de oorzaak. Voor elke <, <=, "tot", "vanaf" en "minstens" in het contract hoort er aan weerszijden een testgeval te zijn.
De functie kan niet worden aangeroepen door een check EOFError: EOF when reading a line betekent dat de functie wacht op het toetsenbord; returned: None betekent dat hij zijn antwoord heeft geprint in plaats van geretourneerd; een check die 's ochtends slaagt en 's avonds faalt, betekent dat hij de klok afleest. Alle drie zijn het ontwerpproblemen, opgelost door de herstructurering naar een pure kern en een dunne schil uit paragraaf twee.
SyntaxWarning: assertion is always true, perhaps remove parentheses? Haakjes om een assert en zijn bericht maken er één tuple van, en een niet-lege tuple is altijd waar:
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999 is overduidelijk fout, en toch slaagde de check. Schrijf assert drink_price(10, 17) == 999, "happy hour price" — zonder haakjes — en hij faalt zoals het hoort. Python waarschuwt je hier; lees de waarschuwing in plaats van ervoorbij te scrollen.
Een check die decimale resultaten met == vergelijkt, faalt bij een correct antwoord 0.1 + 0.2 == 0.3 is False in Python, omdat 0.1 + 0.2 gelijk is aan 0.30000000000000004. De les voor de planning is om in het contract vast te leggen hoe resultaten worden afgerond, zoals member_discount en drink_price doen. De pytest-tool voor het vergelijken van floats volgt in hoofdstuk vijf.
Stap 4 van 6 — Voorspellen
Test je begrip
De eis: "bestellingen van 500 of meer worden gratis verzonden." Wat gebeurt er als dit script wordt uitgevoerd?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- A`checks passed` wordt geprint, daarna een `AssertionError` bij de laatste assert
- BEen `AssertionError` bij de eerste assert, en er wordt niets geprint
- CEr gebeurt niets — asserts worden genegeerd in een script
- D`checks passed` en daarna `all done` worden geprint
Welke functie kan zo, zoals hij is, betrouwbaar worden getest met een eenregelige assert?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
Deze check is bedoeld om de regel "1.2 kg wordt berekend als 2 kg" te bewaken. Wat is er mis mee?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- ADe check moet `is` gebruiken in plaats van `==`
- BDe functie moet worden aangeroepen binnen `try`/`except`
- CDe verwachte waarde komt uit de te testen code, waardoor de check nooit kan falen
- D`1.2` is geen grenswaarde, dus het is niet de moeite waard om te testen
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
Een bioscoop stuurt je deze eis:
"Kinderen betalen minder, gepensioneerden krijgen korting, en baby's mogen gratis naar binnen. Volwassenen betalen de volle prijs, 300."
Doe alles uit dit hoofdstuk, op volgorde, voordat je ook maar één check schrijft:
- Stel de vragen. Schrijf minstens vijf dingen op die de zin openlaat — wat "kinderen" betekent, waar "gepensioneerde" begint, of de grenzen zelf zijn inbegrepen, wat de prijzen zijn, wat er gebeurt bij een onmogelijke leeftijd. Bepaal vervolgens de antwoorden, zoals de bioscoop dat zou doen: onder de 3 is gratis; 3 tot en met 12 betaalt 150; 13 tot en met 64 betaalt 300; 65 en ouder betaalt 200; leeftijden lopen van 0 tot en met 120, en alles daarbuiten geeft een
ValueError. - Schrijf het contract als de docstring van een functie
ticket_price(age)incinema.py. - Maak hem testbaar: argumenten erin, een returnwaarde eruit, geen
input()en geenprint(). - Stel de tabel met testgevallen op: testgeval, invoer, verwacht, waarom. Bepaal eerst de equivalentieklassen en zet vervolgens een testgeval aan weerszijden van elke grens. Leid elke verwachte waarde af uit het contract.
- Bepaal wat je niet gaat testen, en vertel waarom.
- Schrijf
check_cinema.pymet één losseassertper rij, en voer het uit.
Maak de functie daarna opzettelijk stuk: verander de kinderregel van "12 en jonger" naar "onder de 12" — <= naar <. Voorspel voordat je het uitvoert welke check faalt. Voer het uit en vergelijk.
Oplossing
1. De vragen, en de vastgestelde antwoorden:
| Vraag | Antwoord | | --- | --- | | Tot welke leeftijd is een baby gratis? | Leeftijd 0, 1 en 2. Vanaf 3 kost een kaartje geld. | | Wat is een "kind"? | Leeftijd 3 tot en met 12, beide inbegrepen. Prijs 150. | | Is een 13-jarige een kind? | Nee — 13 betaalt de volwassenprijs, 300. | | Vanaf wanneer geldt het seniorentarief? Telt 65 zelf mee? | 65 en ouder. Prijs 200. | | Wat is een onmogelijke leeftijd? | Onder de 0 of boven de 120: ValueError. | | Wat komt er terug? | De prijs als een geheel getal; er wordt niets geprint. |
2 en 3. Het contract en de functie, cinema.py. De grenzen zijn benoemde constanten, waardoor de code leest als de tabel:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. De tabel met testgevallen. De klassen zijn: ongeldig (onder 0), gratis (0–2), kind (3–12), volwassene (13–64), senior (65–120), ongeldig (boven 120). Dat levert vier typische testgevallen op, acht grensgevallen en twee ongeldige:
| Geval | Invoer | Verwacht | Waarom | | --- | --- | --- | --- | | Typische baby | 1 | 0 | klasse gratis | | Typisch kind | 8 | 150 | klasse kind | | Typische volwassene | 30 | 300 | klasse volwassene | | Typische senior | 80 | 200 | klasse senior | | Pasgeborene | 0 | 0 | laagste geldige leeftijd | | Laatste gratis leeftijd | 2 | 0 | onder de grens gratis/kind | | Eerste kinderleeftijd | 3 | 150 | op de grens gratis/kind | | Laatste kinderleeftijd | 12 | 150 | "12 en jonger" — de grens zelf | | Eerste volwassenleeftijd | 13 | 300 | net voorbij de kindergrens | | Laatste volwassenleeftijd | 64 | 300 | onder de seniorengrens | | Eerste seniorleeftijd | 65 | 200 | "65 en ouder" — de grens zelf | | Oudste geldige leeftijd | 120 | 200 | bovengrens van het geldige bereik | | Onder het bereik | -1 | ValueError | net daarbuiten, aan de lage kant | | Boven het bereik | 121 | ValueError | net daarbuiten, aan de hoge kant |
5. Niet getest: een string of None voor de leeftijd — het contract specificeert een geheel getal en belooft niets anders. Een decimale leeftijd zoals 12.5 — idem. De prijzen van andere bioscopen, of Pythons <= — dat is niet aan ons om te testen. En een tweede typisch testgeval in dezelfde klasse, zoals 9 naast 8 — dat zou niets nieuws ontdekken.
6. De checks, check_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedDe opzettelijke bug: if age <= CHILD_UP_TO: wordt if age < CHILD_UP_TO:. Alleen leeftijd 12 verandert van klasse — die valt door naar de volwassenprijs — dus alleen de check voor 12 zou moeten falen:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorRegel 23 is assert ticket_price(12) == 150, precies zoals voorspeld.
Waarom het op deze manier is gedaan:
- De vragen kwamen eerst. "Kinderen" en "gepensioneerden" zijn geen getallen; elk antwoord in de eerste tabel veranderde een woord in een grens die je kunt testen. Zonder die antwoorden zouden
12en65gissingen zijn geweest. - De functie is puur. Leeftijd erin, prijs eruit, verder niets — dus elke check is één regel, en hij levert op elk moment van de dag op elke computer hetzelfde antwoord op.
- Benoemde constanten voor de grenzen.
CHILD_UP_TO = 12zegt op één plek wat het contract zegt, en de checks noemen dezelfde getallen. - De grensrijen deden het werk. De vier typische leeftijden — 1, 8, 30, 80 — slagen allemaal nog steeds met de bug erin. Alleen de rij voor
12, de grens zelf, ontdekte hem. Dat is het hele betoog voor grenswaarden in één run. - Elke verwachte waarde kwam uit het contract. Geen enkele waarde werd gekopieerd door
ticket_priceuit te voeren, dus een verkeerd antwoord van de code kan de checks niet stilletjes binnensluipen. - Het stopte bij de eerste mislukking, en gaf alleen de regel aan. Je wist welke check faalde omdat je het had voorspeld — maar het vertelde je niet dat de werkelijke prijs voor leeftijd 12 uitkwam op 300. Dat is het gat dat het volgende hoofdstuk dicht.
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