Tester les exceptions — pytest.raises
Les erreurs font partie du comportement, elles ont donc aussi besoin de tests. pytest.raises, DID NOT RAISE, correspondance des sous-classes, pourquoi match= est une regex, inspection de l'erreur interceptée avec excinfo, et pourquoi seule la dernière ligne a sa place dans le bloc with.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une petite fonction qui protège un solde. Elle intègre deux refus : un montant non strictement positif, et un montant supérieur au solde disponible.
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 - amountCes deux lignes raise constituent un comportement, tout autant que le return. Un appelant compte sur le refus de withdraw(100.0, -5.0), et si ce refus disparaît un jour, l'argent circule dans le mauvais sens. Cela mérite donc un test. Le premier test que la plupart des gens écrivent ressemble à ceci :
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passSupposons maintenant que quelqu'un supprime la vérification if amount <= 0 de withdraw. Exécutez pytest -q :
. [100%]
1 passed in 0.01sLa vérification a disparu et le test réussit toujours. En l'absence d'exception, le bloc try se termine simplement, except est ignoré et la fonction s'achève sans aucun assert en échec. Le test a seulement vérifié que si une ValueError survenait, elle serait ignorée. Il n'a jamais dit que l'erreur devait se produire.
Ce chapitre a pour but d'exprimer cela correctement — et d'examiner les quelques pièges par lesquels un test d'erreur peut réussir alors qu'il ne le devrait pas.
À la fin de ce chapitre, vous saurez
- Tester que du code lève une exception avec
pytest.raiseset lireDID NOT RAISE - Expliquer pourquoi
pytest.raises(Exception)est presque toujours trop large - Vérifier le message avec
match=, en sachant qu'il s'agit d'une expression régulière - Inspecter l'exception interceptée grâce à
excinfo - Ne conserver que la ligne qui lève l'exception dans le bloc
with, et expliquer pourquoi - Reconnaître un
ExceptionGroupet vérifier ce qu'il contient
Prérequis : Découverte et exécution des tests.
Avant d'écrire le test
Un test d'erreur ne vaut que par la décision qui le sous-tend. Avant de taper pytest.raises, définissez trois éléments sur le papier.
1. Qu'est-ce qui est exactement promis ? Lisez withdraw comme un contrat, et non comme du code :
- Entrée : un solde et un montant. Sortie : le nouveau solde,
balance - amount. - Premier refus : un montant nul ou négatif lève
ValueError, et le message mentionne le montant. - Deuxième refus : un montant supérieur au solde lève
InsufficientFunds, qui contientbalanceetamountcomme attributs. - Effets de bord : aucun.
withdrawne modifie rien en dehors d'elle-même, donc « rien n'a été modifié après un refus » est automatique ici — ce ne sera pas le cas dans l'exemple de la banque à la fin.
Chaque refus constitue une promesse distincte, avec trois éléments que vous pouvez vérifier : quelle classe, quel message, et quel état est laissé après coup.
2. Que faut-il mettre en place ? Rien de plus que dans le chapitre précédent. pytest.raises fait partie de pytest, et re appartient à la bibliothèque standard. Vous devez avoir l'environnement virtuel actif, pytest installé, et wallet.py importable depuis le fichier de test — dans ce chapitre, il se trouve dans le même dossier que test_wallet.py. Vérifiez les deux premiers points une fois :
$ python -m pytest --version
pytest 9.1.1Observez ensuite le comportement réel du code avant d'écrire la moindre affirmation à son sujet. Un rapide sondage répond à des questions auxquelles une simple supposition ne saurait répondre — 0.0 est-il renvoyé ou refusé ? à quoi ressemble le message ?
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. Quels cas de test ? Les erreurs se nichent aux limites, préparez-vous donc en conséquence — le cas ordinaire, les limites de chaque refus des deux côtés, et le cas clairement invalide :
| Cas | Entrée | Valeur attendue | |---|---|---| | Retrait ordinaire | withdraw(100.0, 30.0) | renvoie 70.0 | | Tout le solde (limite, autorisé) | withdraw(100.0, 100.0) | renvoie 0.0, aucune erreur | | Juste au-dessus (limite, refusé) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | Zéro (limite, refusé) | withdraw(100.0, 0) | ValueError | | Négatif | withdraw(100.0, -5.0) | ValueError, le message contient (got -5.0) | | Découvert net | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
Les deux lignes « autorisé » comptent tout autant que les refus. Une vérification écrite avec >= au lieu de > refuserait le cas où tout le solde est retiré, et seul un test qui n'attend aucune erreur à cet endroit le remarquera.
Ce qu'il ne faut pas tester. Le propre comportement de Python : appeler withdraw(100.0) avec un argument manquant lève une TypeError, c'est la promesse de Python, pas la vôtre. La formulation intégrale de chaque message, à moins que la formulation exacte soit elle-même le contrat — un fragment contenant la valeur suffit. Et tout ce que la fonction n'a jamais promis, comme ce qui se produit si vous lui passez la chaîne "ten". Tester ces aspects produit des tests qui cassent lors de modifications anodines et ne protègent rien.
Le reste de ce chapitre transforme ce tableau en tests, un outil à la fois.
pytest.raises — « ceci doit lever une exception »
pytest.raises est un gestionnaire de contexte. Tout ce qui se trouve à l'intérieur du bloc with est censé lever l'exception indiquée :
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.01sSi une ValueError sort du bloc, pytest.raises l'intercepte et le test se poursuit après le bloc. C'est la moitié que try/except possédait déjà. L'autre moitié correspond à ce qui se produit lorsque rien ne sort du bloc. Supprimez de nouveau la vérification if amount <= 0 et relancez le même fichier :
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 est la phrase que la version avec try/except ne pouvait jamais prononcer. Voilà tout ce qu'est pytest.raises : intercepter l'erreur attendue, et échouer bruyamment lorsqu'elle ne se produit pas.
Quelles exceptions comptent comme une correspondance
pytest.raises(X) effectue la correspondance exactement comme le fait except X: : X lui-même ou toute sous-classe de celui-ci. Les exceptions de Python forment un arbre généalogique, et vous pouvez en observer une branche :
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
TrueUne KeyError est donc une LookupError. C'est utile, et c'est aussi un piège. Voici deux tests qui réussissent tous les deux :
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.01sLe premier est tout à fait correct — il intercepte délibérément la classe parente. Le second est un test qui ne prouve rien. withdraw(100.0) n'a jamais atteint le corps de la fonction : Python a levé une TypeError pour l'argument manquant, et comme chaque exception ordinaire est une sous-classe de Exception, pytest.raises(Exception) l'a acceptée volontiers. Une faute de frappe, un import incorrect, une AttributeError — tous ces problèmes feraient passer ce test au vert.
La règle : nommez l'exception la plus spécifique que le code s'engage à lever. Plus la classe est large, plus elle masque des échecs sans rapport.
Dans l'autre sens, la règle est stricte. Une exception qui n'est pas la classe nommée ni une sous-classe de celle-ci n'est pas interceptée ; elle s'échappe du bloc et fait échouer le test comme une erreur ordinaire. (pytest -q --tb=short réduit chaque traceback à une seule ligne par 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.01sPas de DID NOT RAISE — quelque chose a bien été levé, simplement pas ce que vous aviez nommé, et pytest vous montre d'où cela provient.
match= — vérifier le message
Le type indique quelle sorte de refus a eu lieu. Le message explique pourquoi, et contient souvent la valeur incriminée. match= s'occupe de le vérifier :
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.01sLe texte attendu et le message réel sont identiques, et pourtant le test a échoué. La raison se trouve à la première ligne : match= n'est pas une simple chaîne de texte. Il s'agit d'une expression régulière, et pytest exécute re.search(pattern, str(exception)). Deux conséquences :
re.searchcherche n'importe où dans le message."must be positive"a fonctionné au beau milieu de la phrase. Pour cibler le début ou la fin, utilisez^et$.- Certains caractères ont une signification. Dans une regex,
( )forment un groupe et ne correspondent à aucune parenthèse littérale ;.correspond à n'importe quel caractère ;+,*,?,[,$jouent tous un rôle particulier également.
Le cas du . est le plus insidieux, car l'erreur se produit dans le sens où le test réussit par accident :
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" a trouvé sans problème une correspondance dans 100500. re.escape transforme chaque caractère spécial en caractère littéral. Ainsi, lorsque vous voulez dire « exactement ce texte », passez le texte par cette fonction — exactement comme l'indice de pytest le suggérait :
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.01sLa part du message à vérifier relève de votre appréciation. Un fragment court et caractéristique — la valeur, le mot-clé — résiste aux reformulations anodines ; la phrase entière se brise dès que quelqu'un corrige une faute de frappe.
excinfo — l'exception interceptée
with pytest.raises(...) as excinfo vous fournit une référence vers ce qui a été intercepté. Observez son contenu avec pytest -q -s (-s laisse passer les 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.typeest la classe qui a réellement été levée — pratique lorsque vous avez intercepté une classe parente et voulez savoir quelle classe fille est arrivée.excinfo.valueest l'objet exception lui-même, avec tous les attributs qu'il transporte.str(excinfo.value)est le message — la même chaîne dans laquellematch=effectue sa recherche.
Les print ne servaient qu'à observer. Dans un vrai test, vous écrivez des assertions :
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.01sLorsqu'une exception transporte des données sous forme d'attributs, écrire un assert sur les attributs est bien plus robuste que d'analyser le texte du message. Notez que les asserts se situent après le bloc with, et non à l'intérieur. Ce n'est pas une question de style ; la section suivante explique pourquoi.
Seule la ligne qui lève l'exception doit être à l'intérieur
Dès qu'une exception est levée, Python quitte le bloc. Chaque ligne située après celle qui a levé l'exception est ignorée — y compris les 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.01sLes deux tests sont au vert, et tous deux sont trompeurs. Dans le premier, assert balance == 999.0 est une fausse affirmation qui ne s'est jamais exécutée. Dans le second, float("ten") a levé une ValueError dès la ligne de préparation, de sorte que withdraw n'a même jamais été appelée — le test réussit, que withdraw vérifie quoi que ce soit ou non.
La règle : la préparation avant le bloc, un seul appel qui lève l'exception à l'intérieur, les vérifications après.
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.01sCette fois, l'assert s'exécute, et il vérifie une vraie propriété : un retrait refusé a laissé balance intact, car l'affectation n'a jamais eu lieu.
Plusieurs exceptions acceptables
pytest.raises accepte également un tuple, là encore tout comme except. N'importe laquelle des classes indiquées convient, et excinfo.type vous révèle laquelle est survenue :
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.01sUtilisez cette syntaxe lorsque le contrat est véritablement « l'une de celles-ci » — par exemple, un code qui peut légitimement lever l'une ou l'autre selon la plateforme. Pour vos propres fonctions, une classe précise constitue presque toujours une bien meilleure promesse.
Tester que le code ne lève pas d'exception
Il n'existe aucun pytest.does_not_raise() à disposition, et vous n'en avez pas besoin. Appelez simplement le code. S'il lève une exception, le test échoue avec cette exception et sa 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.01sRemplacez amount > balance par amount >= balance dans withdraw — une erreur d'un élément (off-by-one) fréquente — et pytest -q --tb=short signale :
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.01sUne exception inattendue constitue déjà un échec en soi. Envelopper un appel dans un try/except: pytest.fail() ne fait que jeter cette traceback à la poubelle.
Plusieurs erreurs à la fois : ExceptionGroup
Depuis Python 3.11, le code peut lever plusieurs exceptions simultanément sous la forme d'un ExceptionGroup. Ajoutez à wallet.py une version par lots qui tente chaque retrait et signale tous les échecs ensemble :
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 groupe d'exceptions constitue sa propre classe d'exception. pytest.raises(ValueError) ne regarde pas à l'intérieur. Pour en vérifier le contenu, interceptez le groupe et utilisez excinfo.group_contains, ou décrivez l'intégralité du groupe avec 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])Exécutez avec pytest -q --tb=no pour voir uniquement le résultat :
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sLe premier test échoue : le groupe a échappé intact à pytest.raises(ValueError). group_contains répond à la question « y en a-t-il une de ce type quelque part à l'intérieur ? » et accepte également match=. pytest.RaisesGroup est plus strict : le groupe doit contenir exactement les exceptions énumérées — ni plus, ni moins, dans n'importe quel ordre. Ces deux fonctionnalités sont apparues dans pytest 8 et sont présentes dans pytest 9. Vous n'en aurez pas besoin souvent ; vous en aurez besoin le jour où une bibliothèque vous renverra un groupe d'exceptions.
Un exemple complet
Une petite banque avec trois refus : un compte inconnu, un montant non strictement positif, et un découvert. La vérification du montant fonctionne exactement comme celle de withdraw, les tests ci-dessous concentrent donc leurs efforts sur les deux autres cas. AccountNotFound hérite délibérément de LookupError, de sorte que les appelants peuvent l'intercepter de la même façon qu'ils intercepteraient une clé de dictionnaire manquante.
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 ===============================Trois éléments méritent d'être soulignés.
Premièrement, chaque test limite son bloc with à un seul appel, et vérifie l'état après celui-ci. Le test test_unknown_target_moves_nothing est particulièrement précieux : si transfer prélevait l'argent d'Alice avant de découvrir que Carol n'existe pas, la fonction lèverait toujours AccountNotFound — et l'assert situé après le bloc repérerait ce transfert à moitié effectué avec assert 70.0 == 100.0.
Deuxièmement, test_unknown_account_is_a_lookup_error intercepte délibérément la classe parente, car c'est la promesse faite par bank.py, puis utilise excinfo.type pour identifier précisément quelle classe fille est survenue.
Troisièmement, match="'carol'" vérifie que le message mentionne bien le compte demandé — le détail dont une personne qui lit l'erreur a le plus besoin.
En cas de problème
Failed: DID NOT RAISE ValueError Le bloc s'est terminé sans lever d'exception. Soit le code a réellement perdu sa vérification (le test a joué son rôle), soit l'appel à l'intérieur du bloc n'est pas celui que vous croyez — des arguments erronés qui s'avèrent valides, ou une fonction qui renvoie une valeur d'erreur au lieu de lever une exception.
AssertionError: Regex pattern did not match. suivi de Did you mean to re.escape() the regex? Le message est correct mais contient des caractères spéciaux de regex, généralement (, ), ., [ ou +. Enveloppez le texte dans re.escape(...). Sans cet indice, le message est réellement différent : comparez les lignes Expected regex et Actual message caractère par caractère.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 Le motif n'est même pas une regex valide — ici, une parenthèse ouvrante ( sans parenthèse fermante. Même correction : re.escape.
Le test échoue avec une autre exception, comme wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 Une exception a été levée, mais pas celle qui a été nommée ni une sous-classe de celle-ci, donc pytest.raises l'a laissée passer. Déterminez quelle exception le code est censé lever, puis corrigez le test ou le code.
AssertionError: .value can only be used after the context manager exits Vous avez accédé à excinfo.value à l'intérieur du bloc with. Déplacez cette ligne sous le bloc.
TypeError: Expected a BaseException type, but got 'str' Vous avez écrit pytest.raises("ValueError"). Passez la classe elle-même, sans guillemets.
FAILED ... - ExceptionGroup... issu d'un bloc pytest.raises(ValueError) Le code a levé un ExceptionGroup qui contient une ValueError. Un groupe n'est pas une ValueError ; utilisez pytest.raises(ExceptionGroup) avec excinfo.group_contains(ValueError), ou pytest.RaisesGroup(ValueError).
Le test réussit, mais vous êtes certain qu'il ne devrait pas Cherchez des lignes placées après celle qui lève l'exception dans le bloc with — elles ne s'exécutent jamais — ainsi que la présence de pytest.raises(Exception), qui accepte presque tout. Cassez ensuite délibérément le code pour vous assurer que le test passe au rouge.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Quelle est la dernière ligne affichée par 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
Ce test réussit. Quel est le problème ?
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` fait échouer le test car l'assert a levé `AssertionError`
- BL'assert se trouve à l'intérieur du bloc après la ligne qui lève l'exception, il n'est donc jamais exécuté
- CRien — `excinfo.value.amount` vaut réellement 999.0
- D`excinfo` ne peut pas être nommé dans une instruction `with`
withdraw(100.0, -5.0) lève une ValueError avec exactement ce message : amount must be positive (got -5.0). Qu'arrive-t-il au 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)- AIl réussit — les deux textes sont identiques
- BIl échoue avec `Invalid regex pattern`
- CIl échoue avec `DID NOT RAISE ValueError`
- DIl échoue avec `Regex pattern did not match`, car les parenthèses forment un groupe regex
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez inventory.py avec une fonction, remove_stock(stock: dict[str, int], item: str, quantity: int) -> int, et deux classes d'exceptions :
UnknownItem, une sous-classe deKeyError, levée lorsqueitemne figure pas dansstock, transportant le nom de l'article comme attributOutOfStock, levée lorsquequantityest supérieure à ce qu'il reste, transportant les attributsavailableetrequested- une simple
ValueErrorcontenant la valeur dans son message lorsquequantityn'est pas positive
La fonction renvoie ce qu'il reste, et ne modifie le dictionnaire que lorsqu'elle réussit.
Écrivez ensuite test_inventory.py avec au moins six tests :
- Un retrait réussi — aucun
pytest.raises, appelez-la simplement et vérifiez le résultat avec un assert - Un article inconnu, intercepté sous forme de
KeyError, avecexcinfo.typemontrant qu'il s'agissait deUnknownItem - Une quantité nulle, avec
match=vérifiant que le message mentionne la valeur — utilisezre.escapesi le message comporte des parenthèses ou des points - Un retrait excessif, avec des assertions sur
excinfo.value.availableetexcinfo.value.requested - Le même retrait excessif, en vérifiant après le bloc que le dictionnaire est inchangé
- Retirer exactement ce qu'il reste, ce qui ne doit pas lever d'exception
Cassez ensuite le code à trois reprises, une fois après l'autre, et lisez chaque échec : supprimez la vérification de quantité nulle (recherchez DID NOT RAISE), changez > en >= dans la vérification du stock, et enfin déplacez l'un de vos asserts à l'intérieur d'un bloc with, sous la ligne qui lève l'exception, en lui donnant une valeur fausse. Les deux premiers doivent passer au rouge. Le troisième restera vert — et c'est la leçon essentielle à retenir de ce chapitre.
Solution
Partez du plan, comme dans le reste du chapitre : un cas ordinaire, le cas limite qui doit être autorisé (retirer l'intégralité), et chaque refus vérifié pour sa classe, son message ou ses attributs, et l'état laissé après coup.
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 ===============================À présent, les trois anomalies provoquées. En supprimant la vérification 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.01sEn la restaurant et en remplaçant quantity > available par 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.01sEn restaurant cela et en déplaçant le dernier assert de test_overdraw_changes_nothing à l'intérieur du bloc, sous la ligne qui lève l'exception, sous la forme assert stock == {"pen": 999} :
...... [100%]
6 passed in 0.01sLes raisons de chaque décision :
UnknownItemhérite deKeyError. Un article manquant est réellement une clé manquante, donc le code existant qui intercepteKeyErrorcontinue de fonctionner. Le test intercepteKeyError— la promesse publique — puis verrouille la classe exacte avecexcinfo.type.- La vérification de quantité vient en premier. Elle n'a besoin d'aucun élément de
stock, elle échoue donc rapidement et le message porte sur l'argument que l'appelant a mal renseigné. re.escape("(got 0)"). Le fragment contient la valeur, ce dont une personne lisant l'erreur a besoin, etre.escapepréserve les parenthèses littérales. Vérifier la phrase entière casserait le jour où quelqu'un en modifierait la formulation.- Des attributs plutôt que l'analyse du message.
availableetrequestedsont des nombres que vous pouvez comparer directement ; les extraire d'une chaîne testerait le formatage et non les données. - Deux tests pour le retrait excessif. L'un vérifie ce que dit l'exception, l'autre ce que le code a laissé derrière lui. Si l'un échoue, son nom indique précisément quelle promesse a été rompue.
- Le test aux limites a intercepté le bug du
>=. Aucun des tests d'erreur n'aurait pu le faire : ils levaient tous toujours une exception. Seul le test qui n'attend aucune erreur à la limite s'en est aperçu. - La troisième anomalie est restée verte. C'est le piège de la ligne située après l'exception vu plus tôt dans ce chapitre : l'assert faux ne s'est jamais exécuté. La seule parade réside dans l'habitude — un seul appel dans le bloc, les vérifications après.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz