Exceptions testen — pytest.raises
Errors zijn gedrag, dus die hebben ook tests nodig. pytest.raises, DID NOT RAISE, matchen op subklassen, waarom match= een regex is, de opgevangen error inspecteren met excinfo, en waarom alleen de laatste regel in het with-blok hoort.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een kleine functie die een saldo bewaakt. Er zijn twee weigeringen ingebouwd: een bedrag dat niet positief is, en een bedrag dat groter is dan wat er op de rekening staat.
wallet.py:
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amountDie twee raise-regels zijn gedrag, net zo goed als de return dat is. Een aanroeper vertrouwt erop dat withdraw(100.0, -5.0) weigert, en als die weigering op een dag verdwijnt, verplaatst geld zich de verkeerde kant op. Het verdient dus een test. De eerste test die de meeste mensen schrijven ziet er zo uit:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passStel nu dat iemand de controle if amount <= 0 uit withdraw verwijdert. Voer pytest -q uit:
. [100%]
1 passed in 0.01sDe controle is verdwenen en de test slaagt nog steeds. Zonder exception wordt het try-blok simpelweg voltooid, except wordt overgeslagen en de functie eindigt zonder mislukte assert. De test controleerde alleen maar dat als er een ValueError optrad, deze zou worden genegeerd. Er werd nergens geëist dat de fout moest optreden.
Dit hoofdstuk gaat over hoe je dat wél goed formuleert — en over de paar manieren waarop een test van een error kan slagen terwijl dat niet zou moeten.
Aan het einde van dit hoofdstuk kun je
- Met
pytest.raisestesten dat code een exception opwerpt enDID NOT RAISEbegrijpen - Uitleggen waarom
pytest.raises(Exception)bijna altijd te breed is - Het bericht controleren met
match=, wetende dat het een reguliere expressie is - De opgevangen exception inspecteren via
excinfo - Alleen de regel die de exception opwerpt binnen het
with-blok houden, en uitleggen waarom - Een
ExceptionGroupherkennen en controleren wat erin zit
Voorkennis: Discovery en tests uitvoeren.
Voordat je de test schrijft
Een test voor een error is slechts zo goed als de beslissing erachter. Voordat je pytest.raises intypt, leg je drie dingen vast op papier.
1. Wat wordt er precies beloofd? Lees withdraw als een contract, niet als code:
- Invoer: een saldo en een bedrag. Uitvoer: het nieuwe saldo,
balance - amount. - Weigering één: een bedrag dat nul of negatief is werpt
ValueErrorop, en het bericht noemt het bedrag. - Weigering twee: een bedrag groter dan het saldo werpt
InsufficientFundsop, metbalanceenamountals attributen. - Side-effects: geen.
withdrawverandert niets buiten zichzelf, dus "er is niets veranderd na een weigering" geldt hier automatisch — bij het bankvoorbeeld aan het einde is dat niet zo.
Elke weigering is een afzonderlijke belofte, met drie onderdelen die je kunt controleren: welke klasse, welk bericht, en welke toestand er achterblijft.
2. Wat moet er klaarstaan? Niets buiten wat je in het vorige hoofdstuk had. pytest.raises maakt deel uit van pytest, en re zit in de standaardbibliotheek. Je hebt een actieve virtuele omgeving nodig, pytest geïnstalleerd, en wallet.py moet importeerbaar zijn vanuit het testbestand — in dit hoofdstuk staat het in dezelfde map als test_wallet.py. Controleer de eerste twee één keer:
$ python -m pytest --version
pytest 9.1.1Kijk vervolgens naar het daadwerkelijke gedrag van de code voordat je er ook maar één claim over opschrijft. Een kort experiment beantwoordt vragen die je met gissen niet oplost — wordt 0.0 geretourneerd of geweigerd? hoe ziet het bericht eruit?
from wallet import InsufficientFunds, withdraw
print(withdraw(100.0, 30.0))
print(withdraw(100.0, 100.0))
try:
withdraw(100.0, 100.01)
except InsufficientFunds as error:
print(repr(error))70.0
0.0
InsufficientFunds('cannot withdraw 100.01; balance is 100.00')3. Welke gevallen? Errors bevinden zich bij grenswaarden, dus plan daar omheen — het normale geval, de randen van elke weigering aan beide kanten, en het overduidelijk ongeldige:
| Geval | Invoer | Verwacht | |---|---|---| | Gewone opname | withdraw(100.0, 30.0) | retourneert 70.0 | | Volledig saldo (grensgeval, toegestaan) | withdraw(100.0, 100.0) | retourneert 0.0, geen error | | Net te veel (grensgeval, geweigerd) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | Nul (grensgeval, geweigerd) | withdraw(100.0, 0) | ValueError | | Negatief | withdraw(100.0, -5.0) | ValueError, bericht bevat (got -5.0) | | Duidelijk rood staan | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
De twee "toegestane" rijen doen er net zoveel toe als de weigeringen. Een controle geschreven als >= in plaats van > weigert het geval waarin het hele saldo wordt opgenomen, en alleen een test die daar geen error verwacht merkt dat op.
Wat je niet test. Het eigen gedrag van Python: dat het aanroepen van withdraw(100.0) met een ontbrekend argument een TypeError opwerpt is de belofte van Python, niet die van jou. De volledige formulering van elk bericht, tenzij de bewoording zelf het contract is — een fragment dat de waarde bevat is genoeg. En alles wat de functie nooit heeft beloofd, zoals wat er gebeurt als je de string "ten" meegeeft. Dat soort dingen testen leidt alleen maar tot tests die omvallen bij onschuldige wijzigingen en nergens tegen beschermen.
De rest van het hoofdstuk zet deze tabel om in tests, stap voor stap per tool.
pytest.raises — "dit moet een exception opwerpen"
pytest.raises is een contextmanager. Van alles binnen het with-blok wordt verwacht dat het de genoemde exception opwerpt:
import pytest
from wallet import withdraw
def test_negative_amount_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, -5.0)
def test_zero_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 0).. [100%]
2 passed in 0.01sKomt er een ValueError uit het blok, dan vangt pytest.raises die op en gaat de test verder na het blok. Dat is de helft die try/except al bood. De andere helft is wat er gebeurt als er niets uitkomt. Verwijder de controle if amount <= 0 opnieuw en voer hetzelfde bestand uit:
FF [100%]
=================================== FAILURES ===================================
_______________________ test_negative_amount_is_rejected _______________________
def test_negative_amount_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:7: Failed
____________________________ test_zero_is_rejected _____________________________
def test_zero_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:12: Failed
=========================== short test summary info ============================
FAILED test_wallet.py::test_negative_amount_is_rejected - Failed: DID NOT RAI...
FAILED test_wallet.py::test_zero_is_rejected - Failed: DID NOT RAISE ValueError
2 failed in 0.01sDID NOT RAISE ValueError is de mededeling die de try/except-versie nooit kon doen. Dat is alles wat pytest.raises is: de verwachte fout opvangen, en luid falen wanneer die uitblijft.
Welke exceptions tellen als een match
pytest.raises(X) matcht precies zoals except X: dat doet: X zelf of een willekeurige subklasse daarvan. De exceptions van Python vormen een stamboom, en hier zie je een tak daarvan:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
TrueEen KeyError is dus een LookupError. Dat is handig, maar het is ook een valkuil. Hier zijn twee tests die allebei slagen:
import pytest
from wallet import withdraw
def test_missing_price_is_a_lookup_error():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_too_broad():
with pytest.raises(Exception):
withdraw(100.0) # forgot the amount.. [100%]
2 passed in 0.01sDe eerste is prima — de bovenliggende klasse doelbewust opvangen. De tweede is een test die niets bewijst. withdraw(100.0) bereikte de body van de functie nooit: Python wierp een TypeError op voor het ontbrekende argument, en omdat elke gewone exception een subklasse van Exception is, accepteerde pytest.raises(Exception) die vrolijk. Een typefout, een verkeerde import, een AttributeError — ze zouden deze test allemaal groen maken.
De regel: noem de meest specifieke exception die de code belooft op te werpen. Hoe breder de klasse, hoe meer niet-gerelateerde fouten hij verbergt.
De andere kant op is het streng. Een exception die niet de genoemde klasse of een subklasse daarvan is, wordt niet opgevangen; hij ontsnapt aan het blok en laat de test falen als een gewone error. (pytest -q --tb=short kort elke traceback in tot één regel per frame.)
import pytest
from wallet import withdraw
def test_overdraft_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 150.0)F [100%]
=================================== FAILURES ===================================
__________________________ test_overdraft_is_rejected __________________________
test_wallet.py:8: in test_overdraft_is_rejected
withdraw(100.0, 150.0)
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_overdraft_is_rejected - wallet.InsufficientFunds:...
1 failed in 0.01sNiet DID NOT RAISE — er werd iets opgeworpen, alleen niet wat je had genoemd, en pytest laat je zien waar het vandaan kwam.
match= — het bericht controleren
Het type zegt welk soort weigering het was. Het bericht zegt waarom, en bevat vaak de gewraakte waarde. match= controleert dat:
import pytest
from wallet import withdraw
def test_message_says_positive():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100.0, -5.0)
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0).F [100%]
=================================== FAILURES ===================================
______________________________ test_whole_message ______________________________
def test_whole_message():
> with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
E AssertionError: Regex pattern did not match.
E Expected regex: 'amount must be positive (got -5.0)'
E Actual message: 'amount must be positive (got -5.0)'
E Did you mean to `re.escape()` the regex?
test_wallet.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_message - AssertionError: Regex pattern did...
1 failed, 1 passed in 0.01sDe verwachte tekst en het daadwerkelijke bericht zijn identiek, en toch faalde de test. De reden staat op de eerste regel: match= is geen gewone string. Het is een reguliere expressie, en pytest voert re.search(pattern, str(exception)) uit. Twee gevolgen:
re.searchzoekt overal in het bericht."must be positive"matchte middenin. Om het begin of het einde vast te zetten, gebruik je^en$.- Sommige tekens hebben een speciale betekenis. In een regex vormen
( )een groep en matchen ze helemaal geen haakjes;.matcht met elk willekeurig teken;+,*,?,[,$hebben ook allemaal een eigen taak.
Het geval met . is minder opvallend, omdat het faalt in de richting van slagen:
import re
print(re.search("100.00", "balance is 100.00"))
print(re.search("100.00", "balance is 100500"))
print(re.search(re.escape("100.00"), "balance is 100500"))<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None"100.00" matchte vrolijk met 100500. re.escape zet elk speciaal teken om in een letterlijk teken, dus wanneer je "precies deze tekst" bedoelt, haal je de tekst daardoorheen — exact zoals de hint van pytest al suggereerde:
import re
import pytest
from wallet import withdraw
def test_whole_message():
message = "amount must be positive (got -5.0)"
with pytest.raises(ValueError, match=re.escape(message)):
withdraw(100.0, -5.0). [100%]
1 passed in 0.01sHoeveel van het bericht je matcht is een afweging. Een kort, kenmerkend fragment — de waarde, het kernwoord — overleeft onschuldige herformuleringen; de hele zin breekt op het moment dat iemand een typefout herstelt.
excinfo — de exception die je hebt opgevangen
with pytest.raises(...) as excinfo geeft je grip op wat er is opgevangen. Kijk er één keer in met pytest -q -s (-s laat print door):
import pytest
from wallet import InsufficientFunds, withdraw
def test_look_inside():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
print()
print("type :", excinfo.type)
print("value :", repr(excinfo.value))
print("str :", str(excinfo.value))
print("fields:", excinfo.value.balance, excinfo.value.amount)type : <class 'wallet.InsufficientFunds'>
value : InsufficientFunds('cannot withdraw 150.00; balance is 100.00')
str : cannot withdraw 150.00; balance is 100.00
fields: 100.0 150.0
.
1 passed in 0.01sexcinfo.typeis de klasse die daadwerkelijk werd opgeworpen — handig wanneer je een bovenliggende klasse hebt opgevangen en wilt weten welke subklasse er kwam.excinfo.valueis het exception-object zelf, met alle attributen die het bevat.str(excinfo.value)is het bericht — dezelfde string waarinmatch=zoekt.
De print-aanroepen waren alleen om te kijken. In een echte test gebruik je assert:
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_reports_both_numbers():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.balance == 100.0
assert excinfo.value.amount == 150.0
assert str(excinfo.value) == "cannot withdraw 150.00; balance is 100.00". [100%]
1 passed in 0.01sWanneer een exception gegevens als attributen meedraagt, is een assert op die attributen veel robuuster dan het ontleden van het bericht. Let op dat de asserts na het with-blok staan, niet erin. Dat is geen stijlkwestie; het volgende deel laat zien waarom.
Alleen de regel die de exception opwerpt hoort erin
Op het moment dat een exception wordt opgeworpen, verlaat Python het blok. Elke regel na de regel die de fout veroorzaakt wordt overgeslagen — inclusief asserts:
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 999.0 # never runs
def test_text_amount_is_rejected():
with pytest.raises(ValueError):
amount = float("ten") # raises ValueError itself
withdraw(100.0, amount).. [100%]
2 passed in 0.01sBeide tests zijn groen, en beide zijn fout. In de eerste is assert balance == 999.0 een onjuiste bewering die nooit is uitgevoerd. In de tweede wierp float("ten") al een ValueError op tijdens de set-upregel, waardoor withdraw niet eens werd aangeroepen — de test slaagt ongeacht of withdraw ook maar iets controleert.
De regel: set-up vóór het blok, één aanroep die de exception opwerpt erin, controles erna.
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 100.0. [100%]
1 passed in 0.01sNu wordt de assert wél uitgevoerd, en controleert hij iets echts: een geweigerde opname liet balance onaangeroerd, omdat de toekenning nooit heeft plaatsgevonden.
Meer dan één acceptabele exception
pytest.raises accepteert ook een tuple, net zoals except. Elk van de genoemde klassen volstaat, en excinfo.type vertelt je welke er binnenkwam:
import pytest
from wallet import InsufficientFunds, withdraw
def test_bad_withdrawal_is_refused():
with pytest.raises((ValueError, InsufficientFunds)) as excinfo:
withdraw(100.0, 150.0)
print()
print(excinfo.type.__name__)InsufficientFunds
.
1 passed in 0.01sGebruik dit wanneer het contract werkelijk "een van deze" is — bijvoorbeeld code die afhankelijk van het platform terecht een van beide kan opwerpen. Voor je eigen functies is één precieze klasse bijna altijd de betere belofte.
Testen dat iets geen exception opwerpt
Er is geen pytest.does_not_raise() om naar te grijpen, en die heb je ook niet nodig. Roep de code gewoon aan. Als die een exception opwerpt, faalt de test met die exception en de bijbehorende traceback:
from wallet import withdraw
def test_whole_balance_can_be_withdrawn():
assert withdraw(100.0, 100.0) == 0.0. [100%]
1 passed in 0.01sVerander amount > balance in amount >= balance in withdraw — een off-by-one-fout die velen maken — en pytest -q --tb=short meldt:
F [100%]
=================================== FAILURES ===================================
_____________________ test_whole_balance_can_be_withdrawn ______________________
test_wallet.py:5: in test_whole_balance_can_be_withdrawn
assert withdraw(100.0, 100.0) == 0.0
^^^^^^^^^^^^^^^^^^^^^^
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 100.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_balance_can_be_withdrawn - wallet.Insuffici...
1 failed in 0.01sEen onverwachte exception is van zichzelf al een mislukking. Een aanroep inpakken in try/except: pytest.fail() gooit die traceback alleen maar weg.
Meerdere fouten tegelijk: ExceptionGroup
Sinds Python 3.11 kan code meerdere exceptions tegelijk opwerpen als een ExceptionGroup. Voeg een batchversie toe aan wallet.py die elke opname probeert en alle mislukkingen tegelijk meldt:
def withdraw_all(balance: float, amounts: list[float]) -> float:
errors = []
for amount in amounts:
try:
balance = withdraw(balance, amount)
except (ValueError, InsufficientFunds) as error:
errors.append(error)
if errors:
raise ExceptionGroup(f"{len(errors)} withdrawals failed", errors)
return balanceEen groep is een eigen exception-klasse. pytest.raises(ValueError) kijkt er niet in. Om de inhoud te controleren vang je de groep op en gebruik je excinfo.group_contains, of beschrijf je de hele groep met pytest.RaisesGroup:
import pytest
from wallet import InsufficientFunds, withdraw_all
def test_plain_raises_does_not_look_inside():
with pytest.raises(ValueError):
withdraw_all(100.0, [-5.0, 30.0, 500.0])
def test_group_contains():
with pytest.raises(ExceptionGroup) as excinfo:
withdraw_all(100.0, [-5.0, 30.0, 500.0])
assert excinfo.group_contains(ValueError, match="must be positive")
assert excinfo.group_contains(InsufficientFunds)
assert not excinfo.group_contains(KeyError)
def test_raises_group():
with pytest.RaisesGroup(ValueError, InsufficientFunds):
withdraw_all(100.0, [-5.0, 30.0, 500.0])Voer uit met pytest -q --tb=no om alleen de uitkomst te zien:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sDe eerste test faalt: de groep ontsnapte onaangeroerd aan pytest.raises(ValueError). group_contains beantwoordt de vraag "zit er ergens binnenin een van deze?" en accepteert ook match=. pytest.RaisesGroup is strenger: de groep moet exact de opgesomde exceptions bevatten — niet meer, niet minder, in willekeurige volgorde. Beide zijn geïntroduceerd in pytest 8 en zijn aanwezig in pytest 9. Je zult ze niet vaak nodig hebben; je hebt ze nodig op de dag dat een library je een groep overhandigt.
Een compleet voorbeeld
Een kleine bank met drie weigeringen: een onbekende rekening, een niet-positief bedrag en rood staan. De bedragcontrole werkt precies zoals die van withdraw, dus de onderstaande tests richten hun aandacht op de andere twee. AccountNotFound is bewust een subklasse van LookupError, zodat aanroepers deze kunnen opvangen zoals ze een ontbrekende dictionary-key zouden opvangen.
bank.py:
class AccountNotFound(LookupError):
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"no account with id {account_id!r}")
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
class Bank:
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def open(self, account_id: str, deposit: float) -> None:
self._balances[account_id] = deposit
def balance(self, account_id: str) -> float:
try:
return self._balances[account_id]
except KeyError:
raise AccountNotFound(account_id) from None
def transfer(self, source: str, target: str, amount: float) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
# Look both accounts up first, so nothing moves if either is missing.
available = self.balance(source)
self.balance(target)
if amount > available:
raise InsufficientFunds(available, amount)
self._balances[source] -= amount
self._balances[target] += amounttest_bank.py:
import pytest
from bank import AccountNotFound, Bank, InsufficientFunds
def make_bank() -> Bank:
bank = Bank()
bank.open("alice", 100.0)
bank.open("bob", 20.0)
return bank
def test_transfer_moves_money():
bank = make_bank()
bank.transfer("alice", "bob", 30.0) # must not raise
assert bank.balance("alice") == 70.0
assert bank.balance("bob") == 50.0
def test_unknown_account_is_a_lookup_error():
bank = make_bank()
with pytest.raises(LookupError) as excinfo:
bank.balance("carol")
assert excinfo.type is AccountNotFound
assert excinfo.value.account_id == "carol"
def test_unknown_target_moves_nothing():
bank = make_bank()
with pytest.raises(AccountNotFound, match="'carol'"):
bank.transfer("alice", "carol", 30.0)
assert bank.balance("alice") == 100.0
def test_overdraft_reports_both_numbers():
bank = make_bank()
with pytest.raises(InsufficientFunds) as excinfo:
bank.transfer("bob", "alice", 25.0)
assert excinfo.value.balance == 20.0
assert excinfo.value.amount == 25.0
assert bank.balance("bob") == 20.0pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/bank
collecting ... collected 4 items
test_bank.py::test_transfer_moves_money PASSED [ 25%]
test_bank.py::test_unknown_account_is_a_lookup_error PASSED [ 50%]
test_bank.py::test_unknown_target_moves_nothing PASSED [ 75%]
test_bank.py::test_overdraft_reports_both_numbers PASSED [100%]
============================== 4 passed in 0.01s ===============================Drie dingen vallen hier op.
Ten eerste houdt elke test zijn with-blok beperkt tot één aanroep, en controleert de toestand daarna. test_unknown_target_moves_nothing is hier de waardevolste: als transfer het geld van Alice zou afschrijven voordat ontdekt wordt dat Carol niet bestaat, zou er nog steeds AccountNotFound worden opgeworpen — en de assert na het blok zou die half voltooide overboeking ontdekken met assert 70.0 == 100.0.
Ten tweede vangt test_unknown_account_is_a_lookup_error doelbewust de bovenliggende klasse op, omdat dat de belofte is die bank.py doet, en gebruikt vervolgens excinfo.type om vast te stellen welke subklasse er binnenkwam.
Ten derde controleert match="'carol'" of het bericht de rekening noemt waarnaar werd gevraagd — het ene detail dat iemand die de fout leest het hardst nodig heeft.
Wanneer het misgaat
Failed: DID NOT RAISE ValueError Het blok is voltooid zonder een exception op te werpen. Ofwel de code is echt zijn controle kwijtgeraakt (de test deed zijn werk), of de aanroep binnen het blok is niet wat je denkt — verkeerde argumenten die toevallig geldig zijn, of een functie die een foutwaarde retourneert in plaats van een exception op te werpen.
AssertionError: Regex pattern did not match. gevolgd door Did you mean to re.escape() the regex? Het bericht klopt, maar bevat regex-tekens, meestal (, ), ., [ of +. Wikkel de tekst in re.escape(...). Zonder die hint verschilt het bericht daadwerkelijk: vergelijk de regels Expected regex en Actual message teken voor teken.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 Het patroon is niet eens een geldige regex — hier een openingshaakje ( zonder sluithaakje. Zelfde oplossing: re.escape.
De test faalt met een andere exception, zoals wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 Er werd een exception opgeworpen, maar niet de genoemde of een subklasse daarvan, waardoor pytest.raises deze heeft doorgelaten. Bepaal welke de code hoort op te werpen, en herstel de test of de code.
AssertionError: .value can only be used after the context manager exits Je hebt excinfo.value gebruikt binnen het with-blok. Verplaats de regel naar onder het blok.
TypeError: Expected a BaseException type, but got 'str' Je hebt pytest.raises("ValueError") geschreven. Geef de klasse zelf mee, zonder aanhalingstekens.
FAILED ... - ExceptionGroup... vanuit een pytest.raises(ValueError)-blok De code wierp een ExceptionGroup op die een ValueError bevat. Een groep is geen ValueError; gebruik pytest.raises(ExceptionGroup) met excinfo.group_contains(ValueError), of pytest.RaisesGroup(ValueError).
De test slaagt, maar je weet zeker dat dit niet zou moeten Kijk of er regels na de regel die de exception opwerpt binnen het with-blok staan — die worden nooit uitgevoerd — en let op pytest.raises(Exception), dat bijna alles accepteert. Breek de code vervolgens met opzet en controleer of de test rood wordt.
Stap 4 van 6 — Voorspellen
Test je begrip
Wat is de laatste regel die pytest -q afdrukt?
import pytest
def test_missing_key():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_missing_index():
prices = [15.0, 60.0]
with pytest.raises(KeyError):
prices[5]- A2 passed in 0.01s
- B2 failed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 passed, 1 error in 0.01s
Deze test slaagt. Wat is er mis mee?
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_names_the_amount():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.amount == 999.0- A`pytest.raises` laat de test falen omdat de assert `AssertionError` heeft opgeworpen
- BDe assert staat in het blok na de regel die de exception opwerpt, dus hij wordt nooit uitgevoerd
- CNiets — `excinfo.value.amount` is echt 999.0
- D`excinfo` kan niet worden benoemd in een `with`-statement
withdraw(100.0, -5.0) werpt ValueError op met precies dit bericht: amount must be positive (got -5.0). Wat gebeurt er met de test?
import pytest
from wallet import withdraw
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0)- AHij slaagt — de twee teksten zijn identiek
- BHij faalt met `Invalid regex pattern`
- CHij faalt met `DID NOT RAISE ValueError`
- DHij faalt met `Regex pattern did not match`, omdat de haakjes een regex-groep vormen
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf inventory.py met één functie, remove_stock(stock: dict[str, int], item: str, quantity: int) -> int, en twee exception-klassen:
UnknownItem, een subklasse vanKeyError, opgeworpen wanneeritemniet instockstaat, met de artikelnaam als attribuutOutOfStock, opgeworpen wanneerquantitygroter is dan wat er over is, met de attributenavailableenrequested- een gewone
ValueErrormet de waarde in het bericht wanneerquantityniet positief is
De functie retourneert wat er over is, en wijzigt de dictionary alleen wanneer hij slaagt.
Schrijf vervolgens test_inventory.py met minstens zes tests:
- Een geslaagde verwijdering — geen
pytest.raises, roep hem gewoon aan en controleer het resultaat metassert - Een onbekend artikel, opgevangen als
KeyError, waarbijexcinfo.typeaantoont dat hetUnknownItemwas - Een aantal van nul, waarbij
match=controleert of het bericht de waarde noemt — gebruikre.escapeals het bericht haakjes of punten bevat - Te veel afboeken, met asserts op
excinfo.value.availableenexcinfo.value.requested - Hetzelfde geval van te veel afboeken, waarbij je na het blok controleert dat de dictionary ongewijzigd is
- Precies verwijderen wat er over is, wat geen exception mag opwerpen
Maak de code vervolgens drie keer stuk, één voor één, en lees elke foutmelding: verwijder de controle op nul (zoek naar DID NOT RAISE), verander > in >= bij de voorraadcontrole, en verplaats tot slot een van je asserts naar binnen een with-blok, onder de regel die de exception opwerpt, met een onjuiste waarde. De eerste twee horen rood te worden. De derde blijft groen — en dat is de les uit dit hoofdstuk die je moet onthouden.
Oplossing
Begin vanuit het plan, net als in de rest van het hoofdstuk: één normaal geval, het grensgeval dat moet worden toegestaan (alles verwijderen), en elke weigering gecontroleerd op klasse, bericht of attributen, en de toestand die achterblijft.
inventory.py:
class UnknownItem(KeyError):
def __init__(self, item: str):
self.item = item
super().__init__(item)
class OutOfStock(Exception):
def __init__(self, item: str, available: int, requested: int):
self.item = item
self.available = available
self.requested = requested
super().__init__(f"only {available} {item} left, {requested} requested")
def remove_stock(stock: dict[str, int], item: str, quantity: int) -> int:
if quantity <= 0:
raise ValueError(f"quantity must be positive (got {quantity})")
if item not in stock:
raise UnknownItem(item)
available = stock[item]
if quantity > available:
raise OutOfStock(item, available, quantity)
stock[item] = available - quantity
return stock[item]test_inventory.py:
import re
import pytest
from inventory import OutOfStock, UnknownItem, remove_stock
def test_removal_returns_what_is_left():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 3) == 7
assert stock == {"pen": 7}
def test_unknown_item_is_a_key_error():
stock = {"pen": 10}
with pytest.raises(KeyError) as excinfo:
remove_stock(stock, "eraser", 1)
assert excinfo.type is UnknownItem
assert excinfo.value.item == "eraser"
def test_zero_quantity_names_the_value():
stock = {"pen": 10}
with pytest.raises(ValueError, match=re.escape("(got 0)")):
remove_stock(stock, "pen", 0)
def test_overdraw_reports_both_numbers():
stock = {"pen": 10}
with pytest.raises(OutOfStock) as excinfo:
remove_stock(stock, "pen", 11)
assert excinfo.value.available == 10
assert excinfo.value.requested == 11
def test_overdraw_changes_nothing():
stock = {"pen": 10}
with pytest.raises(OutOfStock):
remove_stock(stock, "pen", 11)
assert stock == {"pen": 10}
def test_removing_everything_is_allowed():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 10) == 0
assert stock == {"pen": 0}pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/inventory
collecting ... collected 6 items
test_inventory.py::test_removal_returns_what_is_left PASSED [ 16%]
test_inventory.py::test_unknown_item_is_a_key_error PASSED [ 33%]
test_inventory.py::test_zero_quantity_names_the_value PASSED [ 50%]
test_inventory.py::test_overdraw_reports_both_numbers PASSED [ 66%]
test_inventory.py::test_overdraw_changes_nothing PASSED [ 83%]
test_inventory.py::test_removing_everything_is_allowed PASSED [100%]
============================== 6 passed in 0.01s ===============================Dan de drie manieren om de code stuk te maken. Het verwijderen van de controle quantity <= 0, pytest -q --tb=no:
..F... [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_zero_quantity_names_the_value - Failed: DID NO...
1 failed, 5 passed in 0.01sHerstel deze en verander quantity > available in quantity >= available:
.....F [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_removing_everything_is_allowed - inventory.Out...
1 failed, 5 passed in 0.01sHerstel ook dat en verplaats de laatste assert van test_overdraw_changes_nothing naar binnen het blok, onder de regel die de exception opwerpt, als assert stock == {"pen": 999}:
...... [100%]
6 passed in 0.01sWaarom elke beslissing is genomen:
UnknownItemis een subklasse vanKeyError. Een ontbrekend artikel is echt een ontbrekende key, waardoor bestaande code dieKeyErroropvangt gewoon blijft werken. De test vangtKeyErrorop — de publieke belofte — en legt vervolgens de exacte klasse vast metexcinfo.type.- De controle op het aantal komt als eerste. Deze heeft niets uit
stocknodig, faalt dus direct en het bericht gaat over het argument dat de aanroeper verkeerd heeft opgegeven. re.escape("(got 0)"). Het fragment bevat de waarde, wat precies is wat iemand die de fout leest nodig heeft, enre.escapehoudt de haakjes letterlijk. Het matchen van de hele zin zou stukgaan op de dag dat iemand de bewoording aanpast.- Attributen in plaats van het ontleden van berichten.
availableenrequestedzijn getallen die je rechtstreeks kunt vergelijken; ze uit een string peuteren zou de opmaak testen in plaats van de data. - Twee tests voor te veel afboeken. De ene controleert wat de exception zegt, de andere wat de code heeft achtergelaten. Als er eentje faalt, vertelt de naam je direct welke belofte is geschonden.
- De grenswaardetest ontdekte de
>=-bug. Geen van de error-tests kon dat: die wierpen allemaal nog steeds een exception op. Alleen de test die aan de rand geen error verwachtte merkte het op. - De derde situatie bleef groen. Dat is de valkuil van de regel-na-de-raise van eerder in het hoofdstuk: de onjuiste assert werd nooit uitgevoerd. De enige verdediging daartegen is de gewoonte — één aanroep in het blok, controles erna.
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