Testare le eccezioni — pytest.raises
Gli errori fanno parte del comportamento, quindi hanno bisogno di test. pytest.raises, DID NOT RAISE, corrispondenza delle sottoclassi, perché match= è una regex, ispezionare l'errore catturato con excinfo e perché solo l'ultima riga va all'interno del blocco with.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una piccola funzione che protegge un saldo. Ha due rifiuti incorporati: un importo non positivo e un importo superiore a quello disponibile.
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 - amountQuelle due righe con raise fanno parte del comportamento, esattamente quanto il return. Chi chiama la funzione fa affidamento sul fatto che withdraw(100.0, -5.0) rifiuti l'operazione e, se quel rifiuto un giorno dovesse sparire, il denaro si muoverebbe nella direzione sbagliata. Quindi merita un test. Il primo test che la maggior parte delle persone scrive assomiglia a questo:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passOra supponiamo che qualcuno elimini il controllo if amount <= 0 da withdraw. Esegui pytest -q:
. [100%]
1 passed in 0.01sIl controllo non c'è più e il test passa comunque. In assenza di eccezioni, il blocco try semplicemente termina, except viene saltato e la funzione finisce senza alcun assert fallito. Il test ha verificato soltanto che, se si fosse verificato un ValueError, sarebbe stato ignorato. Non ha mai stabilito che l'errore deve verificarsi.
Questo capitolo spiega come formulare questa verifica nel modo corretto — e illustra i vari modi in cui il test di un errore può passare quando invece non dovrebbe.
Alla fine di questo capitolo sarai in grado di
- Testare che il codice sollevi un'eccezione con
pytest.raisese interpretareDID NOT RAISE - Spiegare perché
pytest.raises(Exception)è quasi sempre troppo generico - Verificare il messaggio con
match=, sapendo che si tratta di un'espressione regolare - Ispezionare l'eccezione catturata tramite
excinfo - Mantenere solo la riga che solleva l'eccezione all'interno del blocco
withe spiegare il perché - Riconoscere un
ExceptionGroupe verificare cosa c'è al suo interno
Prerequisiti: Discovery ed esecuzione dei test.
Prima di scrivere il test
Il test di un errore vale solo quanto la decisione su cui si basa. Prima di digitare pytest.raises, chiarisci tre aspetti su carta.
1. Cosa viene promesso esattamente? Leggi withdraw come un contratto, non come codice:
- Input: un saldo e un importo. Output: il nuovo saldo,
balance - amount. - Primo rifiuto: un importo pari a zero o negativo solleva
ValueError, e il messaggio include l'importo. - Secondo rifiuto: un importo superiore al saldo solleva
InsufficientFunds, che contienebalanceeamountcome attributi. - Effetti collaterali: nessuno.
withdrawnon modifica nulla all'esterno di sé stessa, quindi "nulla è cambiato dopo un rifiuto" qui è automatico — non lo sarà nell'esempio della banca alla fine.
Ciascun rifiuto è una promessa distinta, con tre parti che puoi verificare: quale classe, quale messaggio e quale stato viene lasciato.
2. Cosa deve essere predisposto? Niente di più rispetto allo scorso capitolo. pytest.raises fa parte di pytest e re è nella libreria standard. Ti servono l'ambiente virtuale attivo, pytest installato e wallet.py importabile dal file di test — in questo capitolo si trova nella stessa cartella di test_wallet.py. Controlla i primi due elementi una volta:
$ python -m pytest --version
pytest 9.1.1Poi osserva il comportamento reale del codice prima di scrivere anche una sola affermazione al riguardo. Una rapida verifica risponde a domande a cui un'ipotesi non può rispondere — viene restituito 0.0, oppure viene rifiutato? che aspetto ha il messaggio?
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. Quali casi? Gli errori si annidano sui valori limite, quindi pianifica attorno a essi — il caso ordinario, i limiti di ciascun rifiuto su entrambi i lati e i casi chiaramente non validi:
| Caso | Input | Atteso | |---|---|---| | Prelievo ordinario | withdraw(100.0, 30.0) | restituisce 70.0 | | Intero saldo (valore limite, consentito) | withdraw(100.0, 100.0) | restituisce 0.0, nessun errore | | Appena oltre (valore limite, rifiutato) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | Zero (valore limite, rifiutato) | withdraw(100.0, 0) | ValueError | | Negativo | withdraw(100.0, -5.0) | ValueError, il messaggio contiene (got -5.0) | | Scoperto netto | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
Le due righe "consentito" contano tanto quanto i rifiuti. Un controllo scritto come >= invece di > rifiuta il caso dell'intero saldo, e solo un test che lì non si aspetta alcun errore se ne accorgerà.
Cosa non testare. Il comportamento nativo di Python: il fatto che chiamare withdraw(100.0) con un argomento mancante sollevi TypeError è una promessa di Python, non tua. Il testo integrale di ogni messaggio, a meno che il testo stesso non sia il contratto — un frammento che riporta il valore è sufficiente. E qualsiasi cosa la funzione non abbia mai promesso, come ciò che accade passando la stringa "ten". Testare questi aspetti aggiunge test che si rompono a fronte di modifiche innocue e non proteggono nulla.
Il resto del capitolo trasforma questa tabella in test, uno strumento alla volta.
pytest.raises — "questo deve sollevare"
pytest.raises è un context manager. Ci si aspetta che tutto ciò che si trova all'interno del blocco with sollevi l'eccezione indicata:
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.01sSe dal blocco esce un ValueError, pytest.raises lo cattura e il test prosegue dopo il blocco. Questa è la metà che try/except aveva già. L'altra metà riguarda ciò che accade quando non esce nulla. Elimina di nuovo il controllo if amount <= 0 ed esegui lo stesso file:
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 è la frase che la versione con try/except non avrebbe mai potuto pronunciare. pytest.raises è tutto qui: cattura l'errore atteso e fallisce rumorosamente quando non arriva.
Quali eccezioni corrispondono
pytest.raises(X) trova corrispondenza esattamente nello stesso modo di except X:: X stesso o qualsiasi sua sottoclasse. Le eccezioni di Python formano un albero genealogico, e puoi osservarne un ramo:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
TrueQuindi un KeyError è un LookupError. Questo è utile, ed è anche la trappola. Ecco due test che passano entrambi:
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.01sIl primo va benissimo — cattura intenzionalmente la classe genitore. Il secondo è un test che non dimostra nulla. withdraw(100.0) non ha mai raggiunto il corpo della funzione: Python ha sollevato un TypeError per l'argomento mancante e, poiché ogni eccezione ordinaria è una sottoclasse di Exception, pytest.raises(Exception) l'ha accettata volentieri. Un refuso, un'importazione errata, un AttributeError — tutti quanti renderebbero questo test verde.
La regola: indica l'eccezione più specifica che il codice promette di sollevare. Più la classe è ampia, più fallimenti non correlati nasconde.
La direzione opposta è rigorosa. Un'eccezione che non è la classe indicata o una sua sottoclasse non viene catturata; fuoriesce dal blocco e fa fallire il test come un errore ordinario. (pytest -q --tb=short riduce ogni traceback a una riga per stack 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.01sNon DID NOT RAISE — qualcosa è stato sollevato, semplicemente non quello che avevi indicato, e pytest ti mostra da dove proviene.
match= — verificare il messaggio
Il tipo indica che tipo di rifiuto si è verificato. Il messaggio spiega il perché, e spesso riporta il valore incriminato. match= lo verifica:
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.01sIl testo atteso e il messaggio effettivo sono identici, eppure il test è fallito. Il motivo si trova nella prima riga: match= non è una semplice stringa. È un'espressione regolare, e pytest esegue re.search(pattern, str(exception)). Ne derivano due conseguenze:
re.searchcerca ovunque all'interno del messaggio."must be positive"ha trovato corrispondenza nel mezzo. Per vincolare l'inizio o la fine, usa^e$.- Alcuni caratteri hanno un significato speciale. In una regex,
( )formano un gruppo e non cercano affatto una parentesi;.corrisponde a qualsiasi carattere; anche+,*,?,[,$hanno tutti un proprio ruolo.
Il caso del . è il più silenzioso, perché fallisce nella direzione in cui il test passa:
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" ha trovato felicemente corrispondenza con 100500. re.escape trasforma ogni carattere speciale in un carattere letterale, quindi quando intendi "questo testo esatto", passa il testo attraverso re.escape — esattamente come ha suggerito il consiglio di pytest:
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.01sQuanto del messaggio verificare è una scelta di buon senso. Un frammento breve e distintivo — il valore, la parola chiave — sopravvive a riformulazioni innocue; la frase intera si rompe non appena qualcuno corregge un refuso.
excinfo — l'eccezione che hai catturato
with pytest.raises(...) as excinfo ti fornisce un riferimento su ciò che è stato catturato. Guardaci dentro una volta con pytest -q -s (-s lascia passare le chiamate a print):
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.typeè la classe che è stata effettivamente sollevata — utile quando hai catturato una classe genitore e vuoi sapere quale classe figlia sia arrivata.excinfo.valueè l'oggetto eccezione stesso, con tutti gli attributi che porta con sé.str(excinfo.value)è il messaggio — la stessa stringa in cui cercamatch=.
Le stampe servivano solo per osservare. In un test reale inserisci degli 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.01sQuando un'eccezione contiene dati sotto forma di attributi, verificare gli attributi con gli assert è più robusto che analizzare il messaggio. Nota che gli assert si trovano dopo il blocco with, non al suo interno. Non è una questione di stile; la sezione successiva spiega il motivo.
Solo la riga che solleva l'eccezione va all'interno
Nel momento in cui viene sollevata un'eccezione, Python esce dal blocco. Ogni riga successiva a quella che solleva l'eccezione viene saltata — compresi gli assert:
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.01sEntrambi i test sono verdi, ed entrambi sono sbagliati. Nel primo, assert balance == 999.0 è una falsa affermazione che non è mai stata eseguita. Nel secondo, float("ten") ha sollevato un ValueError nella riga di preparazione, quindi withdraw non è stata nemmeno chiamata — il test passa sia che withdraw controlli qualcosa sia che non lo faccia.
La regola: preparazione prima del blocco, una sola chiamata che solleva l'eccezione al suo interno, verifiche dopo di esso.
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.01sOra l'assert viene eseguito e verifica qualcosa di reale: un prelievo rifiutato ha lasciato balance intatto, perché l'assegnazione non è mai avvenuta.
Più di un'eccezione accettabile
pytest.raises accetta anche una tupla, ancora una volta proprio come except. Una qualsiasi delle classi elencate andrà bene, e excinfo.type ti dice quale sia arrivata:
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.01sUsa questa possibilità quando il contratto è davvero "una di queste" — per esempio, codice che può legittimamente sollevare l'una o l'altra a seconda della piattaforma. Per le tue funzioni, un'unica classe precisa è quasi sempre la promessa migliore.
Testare che qualcosa non sollevi eccezioni
Non c'è alcun pytest.does_not_raise() a cui ricorrere, e non ne hai bisogno. Chiama il codice. Se solleva un'eccezione, il test fallisce con quell'eccezione e il suo 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.01sModifica amount > balance in amount >= balance all'interno di withdraw — un errore off-by-one che molti commettono — e pytest -q --tb=short segnala:
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.01sUn'eccezione imprevista costituisce già un fallimento. Racchiudere una chiamata in try/except: pytest.fail() non fa che gettare via quel traceback.
Più errori contemporaneamente: ExceptionGroup
A partire da Python 3.11, il codice può sollevare più eccezioni insieme sotto forma di ExceptionGroup. Aggiungi una versione batch a wallet.py che prova ogni prelievo e segnala tutti i fallimenti insieme:
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 balanceUn gruppo costituisce una classe di eccezione a sé stante. pytest.raises(ValueError) non guarda al suo interno. Per verificare il contenuto, cattura il gruppo e interroga excinfo.group_contains, oppure descrivi l'intero gruppo con 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])Esegui con pytest -q --tb=no per vedere solo il risultato finale:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sIl primo test fallisce: il gruppo è fuoriuscito da pytest.raises(ValueError) intatto. group_contains risponde a "ce n'è una di queste da qualche parte all'interno?" e accetta anche match=. pytest.RaisesGroup è più severo: il gruppo deve contenere esattamente le eccezioni elencate — né più né meno, in qualsiasi ordine. Entrambe le funzionalità sono state introdotte in pytest 8 e sono presenti in pytest 9. Non ne avrai bisogno spesso; ne avrai bisogno il giorno in cui una libreria ti restituirà un gruppo.
Un esempio completo
Una piccola banca con tre rifiuti: un conto sconosciuto, un importo non positivo e uno scoperto. Il controllo dell'importo funziona esattamente come quello di withdraw, quindi i test seguenti concentrano i propri sforzi sugli altri due. AccountNotFound eredita volutamente da LookupError, così che chi chiama il codice possa catturarla nello stesso modo in cui catturerebbe una chiave mancante in un dizionario.
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 ===============================Vale la pena notare tre aspetti.
Primo, ogni test limita il proprio blocco with a una sola chiamata e verifica lo stato dopo di esso. test_unknown_target_moves_nothing è quello prezioso: se transfer prelevasse il denaro da Alice prima di scoprire che Carol non esiste, solleverebbe comunque AccountNotFound — e l'assert dopo il blocco intercetterebbe il trasferimento completato a metà con assert 70.0 == 100.0.
Secondo, test_unknown_account_is_a_lookup_error cattura intenzionalmente la classe genitore, perché questa è la promessa fatta da bank.py, e poi usa excinfo.type per identificare con precisione quale classe figlia sia arrivata.
Terzo, match="'carol'" verifica che il messaggio indichi il conto richiesto — l'unico dettaglio di cui chi legge l'errore ha maggiormente bisogno.
Quando qualcosa va storto
Failed: DID NOT RAISE ValueError Il blocco è terminato senza sollevare eccezioni. O il codice ha davvero perso il proprio controllo (il test ha fatto il suo dovere), oppure la chiamata all'interno del blocco non è quella che pensi — argomenti errati che per caso risultano validi, oppure una funzione che restituisce un valore di errore invece di sollevare un'eccezione.
AssertionError: Regex pattern did not match. seguito da Did you mean to re.escape() the regex? Il messaggio è corretto ma contiene caratteri speciali delle regex, solitamente (, ), ., [ o +. Racchiudi il testo in re.escape(...). In assenza del suggerimento, il messaggio è davvero diverso: confronta le righe Expected regex e Actual message carattere per carattere.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 Il pattern non è nemmeno una regex valida — in questo caso, una parentesi aperta ( senza quella di chiusura. Stessa soluzione: re.escape.
Il test fallisce con un'altra eccezione, come wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 È stata sollevata un'eccezione, ma non quella indicata o una sua sottoclasse, quindi pytest.raises l'ha lasciata passare. Decidi quale eccezione il codice dovrebbe sollevare e correggi il test oppure il codice.
AssertionError: .value can only be used after the context manager exits Hai fatto riferimento a excinfo.value all'interno del blocco with. Sposta la riga sotto il blocco.
TypeError: Expected a BaseException type, but got 'str' Hai scritto pytest.raises("ValueError"). Passa la classe stessa, senza virgolette.
FAILED ... - ExceptionGroup... da un blocco pytest.raises(ValueError) Il codice ha sollevato un ExceptionGroup che contiene un ValueError. Un gruppo non è un ValueError; usa pytest.raises(ExceptionGroup) con excinfo.group_contains(ValueError), oppure pytest.RaisesGroup(ValueError).
Il test passa, ma hai la certezza che non dovrebbe Cerca righe successive a quella che solleva l'eccezione all'interno del blocco with — non vengono mai eseguite — e la presenza di pytest.raises(Exception), che accetta quasi tutto. Poi rompi il codice di proposito e verifica che il test diventi rosso.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Qual è l'ultima riga stampata da pytest -q?
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
Questo test passa. Cosa c'è che non va?
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` fa fallire il test perché l'assert ha sollevato `AssertionError`
- BL'assert si trova all'interno del blocco dopo la riga che solleva l'eccezione, quindi non viene mai eseguito
- CNulla — `excinfo.value.amount` è davvero 999.0
- D`excinfo` non può essere indicato in un'istruzione `with`
withdraw(100.0, -5.0) solleva ValueError con esattamente questo messaggio: amount must be positive (got -5.0). Cosa succede al 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)- APassa — i due testi sono identici
- BFallisce con `Invalid regex pattern`
- CFallisce con `DID NOT RAISE ValueError`
- DFallisce con `Regex pattern did not match`, perché le parentesi formano un gruppo regex
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi inventory.py con una funzione, remove_stock(stock: dict[str, int], item: str, quantity: int) -> int, e due classi di eccezione:
UnknownItem, una sottoclasse diKeyError, sollevata quandoitemnon è presente instock, che contiene il nome dell'articolo come attributoOutOfStock, sollevata quandoquantityè superiore a quanto rimasto, con gli attributiavailableerequested- un semplice
ValueErrorcon il valore nel messaggio quandoquantitynon è positivo
La funzione restituisce quanto rimasto e modifica il dizionario solo quando ha successo.
Poi scrivi test_inventory.py con almeno sei test:
- Una rimozione riuscita — nessun
pytest.raises, basta chiamare la funzione e verificare il risultato con un assert - Un articolo sconosciuto, catturato come
KeyError, conexcinfo.typeche mostra che si trattava diUnknownItem - Una quantità pari a zero, con
match=che verifica che il messaggio indichi il valore — usare.escapese il messaggio contiene parentesi o punti - Un prelievo eccessivo, con assert su
excinfo.value.availableeexcinfo.value.requested - Lo stesso prelievo eccessivo, verificando con un assert dopo il blocco che il dizionario sia invariato
- La rimozione esatta di quanto rimasto, che non deve sollevare alcuna eccezione
Poi rompi il codice tre volte, una alla volta, e leggi ciascun fallimento: elimina il controllo sulla quantità pari a zero (cerca DID NOT RAISE), modifica > in >= nel controllo sulle scorte e infine sposta uno dei tuoi assert all'interno di un blocco with, sotto la riga che solleva l'eccezione, e assegnagli un valore falso. I primi due dovrebbero diventare rossi. Il terzo rimarrà verde — ed è questa la lezione che vale la pena ricordare di questo capitolo.
Soluzione
Inizia dal piano, come nel resto del capitolo: un caso ordinario, il valore limite che deve essere consentito (rimuovere tutto) e ciascun rifiuto verificato per classe, messaggio o attributi e stato lasciato al termine.
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 ===============================Ora le tre manomissioni. Eliminando il controllo 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.01sRipristinandolo e modificando 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.01sRipristinando anche questo e spostando l'ultimo assert di test_overdraw_changes_nothing all'interno del blocco, sotto la riga che solleva l'eccezione, come assert stock == {"pen": 999}:
...... [100%]
6 passed in 0.01sPerché queste decisioni:
UnknownItemè una sottoclasse diKeyError. Un articolo mancante è davvero una chiave mancante, quindi il codice esistente che catturaKeyErrorcontinua a funzionare. Il test catturaKeyError— la promessa pubblica — e poi individua l'esatta classe conexcinfo.type.- Il controllo sulla quantità viene prima. Non ha bisogno di nulla da
stock, quindi fallisce rapidamente e il messaggio riguarda l'argomento che chi ha chiamato la funzione ha sbagliato. re.escape("(got 0)"). Il frammento contiene il valore, che è ciò di cui ha bisogno chi legge l'errore, ere.escapemantiene le parentesi letterali. Un controllo sull'intera frase si romperebbe il giorno in cui qualcuno decidesse di riformularla.- Attributi anziché parsing del messaggio.
availableerequestedsono numeri confrontabili direttamente; estrarli da una stringa testerebbe la formattazione, non i dati. - Due test per il prelievo eccessivo. Uno controlla cosa dice l'eccezione, l'altro cosa ha lasciato il codice dietro di sé. Se uno fallisce, il suo nome ti dice quale promessa è stata infranta.
- Il test sul valore limite ha individuato il bug con
>=. Nessuno dei test sugli errori avrebbe potuto: tutti sollevavano comunque un'eccezione. Solo il test che non si aspetta alcun errore sul valore limite se n'è accorto. - La terza manomissione è rimasta verde. È la trappola della riga successiva a quella che solleva l'eccezione vista in precedenza nel capitolo: il falso assert non è mai stato eseguito. L'unica difesa è l'abitudine — una sola chiamata all'interno del blocco, verifiche dopo di esso.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz