Probar excepciones: pytest.raises
Los errores son comportamiento, así que también necesitan tests. pytest.raises, DID NOT RAISE, coincidencia de subclases, por qué match= es una regex, cómo inspeccionar el error capturado con excinfo y por qué solo la última línea debe ir dentro del bloque with.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes una función pequeña que protege un saldo. Tiene dos rechazos incorporados: una cantidad que no es positiva y una cantidad mayor que lo que hay 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 - amountEsas dos líneas raise son comportamiento, exactamente igual que lo es el return. Quien llama a la función confía en que withdraw(100.0, -5.0) rechace la operación, y si ese rechazo desaparece algún día, el dinero se moverá en la dirección equivocada. Por eso merece un test. El primer test que escribe la mayoría de la gente se parece a esto:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passAhora supongamos que alguien elimina la comprobación if amount <= 0 de withdraw. Ejecuta pytest -q:
. [100%]
1 passed in 0.01sLa comprobación ha desaparecido y el test sigue pasando. Sin ninguna excepción, el bloque try simplemente termina, except se omite y la función finaliza sin ningún assert fallido. El test solo comprobaba que, si ocurría un ValueError, se ignoraría. Nunca dijo que el error debía ocurrir.
Este capítulo trata sobre cómo expresar eso adecuadamente, y sobre las pocas maneras en que el test de un error puede pasar cuando no debería.
Al terminar este capítulo podrás
- Probar que el código lanza excepciones con
pytest.raisesy leerDID NOT RAISE - Explicar por qué
pytest.raises(Exception)casi siempre es demasiado amplio - Comprobar el mensaje con
match=, sabiendo que es una expresión regular - Inspeccionar la excepción capturada mediante
excinfo - Mantener solo la línea que lanza la excepción dentro del bloque
with, y explicar por qué - Reconocer un
ExceptionGroupy comprobar qué hay en su interior
Prerrequisitos: Descubrimiento y ejecución de tests.
Antes de escribir el test
El test de un error es tan bueno como la decisión que hay detrás de él. Antes de escribir pytest.raises, define tres cosas sobre el papel.
1. ¿Qué se promete exactamente? Lee withdraw como un contrato, no como código:
- Entrada: un saldo y una cantidad. Salida: el nuevo saldo,
balance - amount. - Primer rechazo: una cantidad que sea cero o negativa lanza
ValueError, y el mensaje indica la cantidad. - Segundo rechazo: una cantidad mayor que el saldo lanza
InsufficientFunds, que incluyebalanceyamountcomo atributos. - Efectos secundarios: ninguno.
withdrawno modifica nada fuera de sí misma, por lo que «nada cambió tras un rechazo» es automático aquí; no lo será en el ejemplo del banco al final.
Cada rechazo es una promesa independiente, con tres partes que puedes comprobar: qué clase, qué mensaje y qué estado se deja atrás.
2. ¿Qué debe estar listo? Nada más allá del capítulo anterior. pytest.raises forma parte de pytest, y re está en la biblioteca estándar. Necesitas el entorno virtual activo, pytest instalado y que wallet.py se pueda importar desde el archivo de test; en este capítulo se encuentra en la misma carpeta que test_wallet.py. Comprueba los dos primeros una vez:
$ python -m pytest --version
pytest 9.1.1Luego observa el comportamiento real del código antes de escribir una sola afirmación sobre él. Una pequeña prueba responde a preguntas que una suposición no puede responder: ¿se devuelve 0.0 o se rechaza?, ¿cómo es el mensaje?
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. ¿Qué casos? Los errores viven en los límites, así que planifica en torno a ellos: el caso habitual, los bordes de cada rechazo a ambos lados y lo claramente inválido:
| Caso | Entrada | Esperado | |---|---|---| | Retirada habitual | withdraw(100.0, 30.0) | devuelve 70.0 | | Saldo completo (límite, permitido) | withdraw(100.0, 100.0) | devuelve 0.0, sin error | | Justo por encima (límite, rechazado) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | Cero (límite, rechazado) | withdraw(100.0, 0) | ValueError | | Negativo | withdraw(100.0, -5.0) | ValueError, el mensaje contiene (got -5.0) | | Descubierto claro | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
Las dos filas «permitidas» importan tanto como los rechazos. Una comprobación escrita como >= en lugar de > rechaza el caso del saldo completo, y solo un test que espere que no haya ningún error allí lo detectará.
Qué no probar. El comportamiento del propio Python: que llamar a withdraw(100.0) con un argumento faltante lance TypeError es una promesa de Python, no tuya. La redacción completa de cada mensaje, a menos que la redacción en sí sea el contrato: un fragmento que contenga el valor es suficiente. Y cualquier cosa que la función nunca prometió, como lo que sucede cuando pasas la cadena "ten". Probar esas cosas añade tests que se rompen con cambios inofensivos y no protegen nada.
El resto del capítulo convierte esta tabla en tests, herramienta por herramienta.
pytest.raises — «esto debe lanzar una excepción»
pytest.raises es un gestor de contexto. Se espera que todo lo que esté dentro del bloque with lance la excepción nombrada:
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 sale un ValueError del bloque, pytest.raises lo captura y el test continúa después del bloque. Esa es la mitad que try/except ya tenía. La otra mitad es lo que ocurre cuando no sale nada. Elimina de nuevo la comprobación if amount <= 0 y ejecuta el mismo archivo:
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 es la frase que la versión con try/except nunca podría decir. Eso es todo lo que hace pytest.raises: capturar el error esperado y fallar de forma clara cuando no llega.
Qué excepciones cuentan como coincidencia
pytest.raises(X) coincide exactamente igual que except X:: X misma o cualquier subclase de ella. Las excepciones de Python forman un árbol genealógico, y puedes ver una rama de él:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
TrueAsí que un KeyError es un LookupError. Eso es útil, y también es la trampa. Aquí tienes dos tests que pasan:
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.01sEl primero está bien: capturar la clase padre a propósito. El segundo es un test que no demuestra nada. withdraw(100.0) nunca llegó al cuerpo de la función: Python lanzó un TypeError por el argumento faltante y, como cualquier excepción común es una subclase de Exception, pytest.raises(Exception) lo aceptó encantado. Una errata, una importación incorrecta, un AttributeError: cualquiera de ellos haría que este test se pusiera en verde.
La regla: nombra la excepción más específica que el código prometa lanzar. Cuanto más amplia sea la clase, más fallos no relacionados ocultará.
La otra dirección es estricta. Una excepción que no sea la clase nombrada ni una subclase no se captura; escapa del bloque y hace fallar el test como un error común. (pytest -q --tb=short recorta cada traceback a una línea por marco de llamada).
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.01sNo es DID NOT RAISE: sí se lanzó algo, solo que no lo que nombraste, y pytest te muestra de dónde provino.
match= — comprobar el mensaje
El tipo indica qué clase de rechazo fue. El mensaje indica por qué y, a menudo, incluye el valor infractor. match= lo comprueba:
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.01sEl texto esperado y el mensaje real son idénticos y, sin embargo, falló. La razón está en la primera línea: match= no es una cadena de texto simple. Es una expresión regular, y pytest ejecuta re.search(pattern, str(exception)). Dos consecuencias:
re.searchbusca en cualquier parte del mensaje."must be positive"coincidió en medio. Para fijar el inicio o el final, usa^y$.- Algunos caracteres tienen un significado especial. En una expresión regular,
( )forman un grupo y no coinciden con ningún paréntesis literal;.coincide con cualquier carácter;+,*,?,[,$también tienen funciones asignadas.
El caso de . es el más silencioso, porque falla en la dirección de dar un aprobado falso:
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" coincidió sin problemas con 100500. re.escape convierte cada carácter especial en un carácter literal, por lo que cuando quieras decir «este texto exacto», pasa el texto a través de él, tal como sugería la indicación de 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.01sCuánto del mensaje hacer coincidir es una cuestión de criterio. Un fragmento corto y característico —el valor, la palabra clave— sobrevive a cambios de redacción inofensivos; la frase entera se rompe en el momento en que alguien corrige una errata.
excinfo — la excepción capturada
with pytest.raises(...) as excinfo te da acceso a lo que se capturó. Mira en su interior una vez con pytest -q -s (-s deja pasar las salidas de 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.typees la clase que realmente se lanzó; útil cuando capturaste una clase padre y quieres saber qué clase hija llegó.excinfo.valuees el propio objeto excepción, con cada atributo que contenga.str(excinfo.value)es el mensaje: la misma cadena que buscamatch=.
Los print eran solo para mirar. En un test real haces un 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.01sCuando una excepción lleva datos como atributos, hacer asserts sobre los atributos es más robusto que analizar el mensaje. Observa que los asserts se colocan después del bloque with, no dentro de él. No es una cuestión de estilo; la siguiente sección explica por qué.
Solo la línea que lanza la excepción va dentro
En el momento en que se lanza una excepción, Python sale del bloque. Cada línea posterior a la que lanzó la excepción se omite, incluidos los 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.01sAmbos tests están en verde y ambos son incorrectos. En el primero, assert balance == 999.0 es una afirmación falsa que nunca se ejecutó. En el segundo, float("ten") lanzó un ValueError en la línea de preparación, por lo que withdraw ni siquiera llegó a llamarse; el test pasa tanto si withdraw comprueba algo como si no.
La regla: preparación antes del bloque, una sola llamada que lanza la excepción dentro de él y comprobaciones después de él.
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.01sAhora el assert sí se ejecuta y comprueba algo real: una retirada rechazada dejó balance intacto, porque la asignación nunca llegó a producirse.
Más de una excepción aceptable
pytest.raises también acepta una tupla, de nuevo exactamente igual que except. Cualquiera de las clases listadas servirá, y excinfo.type te dice cuál llegó:
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 esto cuando el contrato sea realmente «una de estas»; por ejemplo, código que legítimamente puede lanzar cualquiera de las dos según la plataforma. Para tus propias funciones, una clase precisa es casi siempre una promesa mucho mejor.
Probar que algo no lanza excepciones
No existe un pytest.does_not_raise() al que recurrir, y no lo necesitas. Llama al código. Si lanza una excepción, el test falla con esa excepción y su 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.01sCambia amount > balance por amount >= balance en withdraw —un error por desfase de uno que mucha gente comete— y pytest -q --tb=short informa:
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.01sUna excepción inesperada ya es un fallo por sí misma. Envolver una llamada en try/except: pytest.fail() solo sirve para perder ese traceback.
Varios errores a la vez: ExceptionGroup
Desde Python 3.11, el código puede lanzar varias excepciones juntas como un ExceptionGroup. Añade una versión por lotes a wallet.py que intente cada retirada e informe de todos los fallos juntos:
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 grupo es una clase de excepción propia. pytest.raises(ValueError) no mira en su interior. Para comprobar el contenido, captura el grupo y consulta excinfo.group_contains, o describe el grupo completo 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])Ejecuta con pytest -q --tb=no para ver solo el resultado:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sEl primer test falla: el grupo escapó de pytest.raises(ValueError) intacto. group_contains responde a «¿hay alguna de estas en cualquier parte de su interior?» y también acepta match=. pytest.RaisesGroup es más estricto: el grupo debe contener exactamente las excepciones listadas, ni más ni menos, en cualquier orden. Ambos llegaron en pytest 8 y siguen presentes en pytest 9. No los necesitarás a menudo; los necesitarás el día en que una biblioteca te devuelva un grupo.
Un ejemplo completo
Un pequeño banco con tres rechazos: una cuenta desconocida, una cantidad no positiva y un descubierto. La comprobación de la cantidad funciona exactamente igual que la de withdraw, por lo que los siguientes tests centran su esfuerzo en los otros dos. AccountNotFound hereda deliberadamente de LookupError, de modo que quien llame pueda capturarla de la misma forma en que capturaría una clave faltante de un diccionario.
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 fijarse en tres cosas.
En primer lugar, cada test limita su bloque with a una sola llamada y comprueba el estado después de él. test_unknown_target_moves_nothing es el más valioso: si transfer le quitara el dinero a Alice antes de descubrir que Carol no existe, seguiría lanzando AccountNotFound, y el assert posterior al bloque detectaría la transferencia a medio hacer con assert 70.0 == 100.0.
En segundo lugar, test_unknown_account_is_a_lookup_error captura la clase padre a propósito, porque esa es la promesa que hace bank.py, y luego usa excinfo.type para determinar con precisión qué clase hija llegó.
En tercer lugar, match="'carol'" comprueba que el mensaje mencione la cuenta solicitada: el detalle que más necesita la persona que lee el error.
Cuando algo falla
Failed: DID NOT RAISE ValueError El bloque terminó sin lanzar ninguna excepción. O bien el código realmente perdió su comprobación (el test cumplió su función), o bien la llamada dentro del bloque no es la que crees: argumentos incorrectos que casualmente son válidos, o una función que devuelve un valor de error en lugar de lanzar una excepción.
AssertionError: Regex pattern did not match. seguido de Did you mean to re.escape() the regex? El mensaje es el correcto, pero contiene caracteres especiales de regex, habitualmente (, ), ., [ o +. Envuelve el texto en re.escape(...). Sin esa sugerencia, el mensaje realmente es diferente: compara las líneas Expected regex y Actual message carácter por carácter.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 El patrón ni siquiera es una expresión regular válida: en este caso, un ( de apertura sin cierre. La misma solución: re.escape.
El test falla con alguna otra excepción, como wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 Se lanzó una excepción, pero no la nombrada ni una subclase de ella, por lo que pytest.raises la dejó pasar. Decide cuál se supone que debe lanzar el código y corrige el test o el código.
AssertionError: .value can only be used after the context manager exits Accediste a excinfo.value dentro del bloque with. Mueve esa línea debajo del bloque.
TypeError: Expected a BaseException type, but got 'str' Escribiste pytest.raises("ValueError"). Pasa la clase en sí, sin comillas.
FAILED ... - ExceptionGroup... desde un bloque pytest.raises(ValueError) El código lanzó un ExceptionGroup que contiene un ValueError. Un grupo no es un ValueError; usa pytest.raises(ExceptionGroup) con excinfo.group_contains(ValueError) o pytest.RaisesGroup(ValueError).
El test pasa, pero tienes la certeza de que no debería Busca líneas posteriores a la que lanza la excepción dentro del bloque with (nunca se ejecutan) y busca si hay un pytest.raises(Exception), que acepta casi cualquier cosa. Después, rompe el código a propósito y confirma que el test se pone en rojo.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
¿Cuál es la última línea que imprime 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
Este test pasa. ¿Qué tiene de malo?
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` hace fallar el test porque el assert lanzó `AssertionError`
- BEl assert está dentro del bloque después de la línea que lanza la excepción, por lo que nunca se ejecuta
- CNada — `excinfo.value.amount` realmente es 999.0
- D`excinfo` no se puede nombrar en una sentencia `with`
withdraw(100.0, -5.0) lanza ValueError exactamente con este mensaje: amount must be positive (got -5.0). ¿Qué le ocurre 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)- APasa — los dos textos son idénticos
- BFalla con `Invalid regex pattern`
- CFalla con `DID NOT RAISE ValueError`
- DFalla con `Regex pattern did not match`, porque los paréntesis forman un grupo de regex
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe inventory.py con una función, remove_stock(stock: dict[str, int], item: str, quantity: int) -> int, y dos clases de excepción:
UnknownItem, una subclase deKeyError, que se lanza cuandoitemno está enstock, con el nombre del artículo como atributoOutOfStock, que se lanza cuandoquantityes mayor que lo que queda disponible, con los atributosavailableyrequested- un
ValueErrorcomún con el valor en el mensaje cuandoquantityno es positiva
La función devuelve lo que queda y modifica el diccionario únicamente cuando tiene éxito.
Luego escribe test_inventory.py con al menos seis tests:
- Una retirada con éxito: sin
pytest.raises, solo llámala y comprueba el resultado con un assert - Un artículo desconocido, capturado como
KeyError, conexcinfo.typemostrando que fueUnknownItem - Una cantidad cero, con
match=comprobando que el mensaje mencione el valor; usare.escapesi el mensaje tiene paréntesis o puntos - Una retirada excesiva, haciendo asserts sobre
excinfo.value.availableyexcinfo.value.requested - La misma retirada excesiva, haciendo un assert después del bloque de que el diccionario no ha cambiado
- Retirar exactamente lo que queda, lo cual no debe lanzar ninguna excepción
Luego rompe el código tres veces, una por una, y lee cada fallo: elimina la comprobación de cantidad cero (busca DID NOT RAISE), cambia > por >= en la comprobación de stock y, por último, mueve uno de tus asserts dentro de un bloque with, debajo de la línea que lanza la excepción, y ponle un valor falso. Los dos primeros deberían ponerse en rojo. El tercero seguirá en verde —y esa es la lección que vale la pena recordar de este capítulo—.
Solución
Empieza a partir del plan, como en el resto del capítulo: un caso habitual, el límite que debe permitirse (retirar todo) y cada rechazo comprobado por clase, mensaje o atributos y estado que deja atrás.
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 ===============================Ahora las tres roturas. Al eliminar la comprobación 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.01sAl restaurarla y cambiar quantity > available por 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.01sAl restaurar eso y mover el último assert de test_overdraw_changes_nothing dentro del bloque, debajo de la línea que lanza la excepción, como assert stock == {"pen": 999}:
...... [100%]
6 passed in 0.01sPor qué se tomó cada decisión:
UnknownItemhereda deKeyError. Un artículo inexistente es realmente una clave inexistente, por lo que el código existente que captureKeyErrorsigue funcionando. El test capturaKeyError—la promesa pública— y luego fija la clase exacta conexcinfo.type.- La comprobación de cantidad va primero. No necesita nada de
stock, por lo que falla rápido y el mensaje trata sobre el argumento en el que quien llama se equivocó. re.escape("(got 0)"). El fragmento incluye el valor, que es lo que necesita quien lee el error, yre.escapemantiene los paréntesis literales. Hacer coincidir la frase completa se rompería el día en que alguien cambie su redacción.- Atributos en vez de analizar el mensaje.
availableyrequestedson números que puedes comparar directamente; extraerlos de una cadena probaría el formato, no los datos. - Dos tests para la retirada excesiva. Uno comprueba lo que dice la excepción y el otro lo que el código dejó atrás. Si uno falla, su nombre te dice qué promesa se rompió.
- El test de límite detectó el bug de
>=. Ninguno de los tests de error pudo hacerlo: todos siguieron lanzando excepciones. Solo el test que espera que no haya ningún error en el límite se dio cuenta. - La tercera rotura se mantuvo en verde. Esa es la trampa de la línea posterior al lanzamiento que vimos antes en el capítulo: el assert falso nunca llegó a ejecutarse. La única defensa es la costumbre: una sola llamada dentro del bloque y las comprobaciones después de él.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz