No todo comportamiento correcto consiste en devolver un valor o cambiar un estado. A veces, el comportamiento correcto es rechazar una operación inválida.
Por ejemplo, dividir por cero, retirar más dinero del saldo disponible, crear un usuario sin nombre o aceptar una edad negativa pueden ser situaciones que la unidad debe impedir.
En este tema veremos cómo probar excepciones y errores esperados. La idea central es simple: una prueba también puede pasar cuando el código falla de la manera correcta y controlada.
Conviene diferenciar dos situaciones:
| Tipo | Significado | Ejemplo |
|---|---|---|
| Error inesperado | La unidad falla por un defecto no previsto. | Una variable no inicializada produce una excepción accidental. |
| Error esperado | La unidad rechaza una condición inválida de forma intencional. | Una función lanza ValueError al dividir por cero. |
Las pruebas sobre excepciones verifican el segundo caso: queremos confirmar que la unidad responde de forma controlada ante una entrada inválida.
Supongamos una función que divide dos números, pero rechaza el divisor cero.
def dividir(a, b):
if b == 0:
raise ValueError("No se puede dividir por cero")
return a / b
def test_dividir_por_cero_lanza_error():
try:
dividir(10, 0)
assert False
except ValueError:
assert True
La prueba pasa si se lanza ValueError. Si la función no lanza el error, la línea assert False hace fallar la prueba.
En Python, frameworks como pytest ofrecen una forma más clara de verificar excepciones.
import pytest
def test_dividir_por_cero_lanza_error():
with pytest.raises(ValueError):
dividir(10, 0)
Esta prueba expresa directamente la intención: esperamos que dentro del bloque se lance ValueError. Si no ocurre, la prueba falla.
En otros lenguajes existen mecanismos equivalentes, como assertThrows en JUnit o aserciones similares en frameworks de JavaScript y C#.
Una prueba de excepción puede verificar varias cosas:
No siempre necesitamos verificar todo. La prueba debe concentrarse en el comportamiento relevante.
El tipo de excepción es importante porque comunica qué clase de problema ocurrió. No es lo mismo un error de valor, un error de permisos o un error de estado.
def validar_edad(edad):
if edad < 0:
raise ValueError("La edad no puede ser negativa")
return True
def test_edad_negativa_lanza_value_error():
with pytest.raises(ValueError):
validar_edad(-1)
La prueba espera específicamente ValueError. Si la función lanzara otro tipo de excepción, la prueba debería fallar.
A veces el mensaje de error forma parte del comportamiento esperado, especialmente si será mostrado al usuario o usado para diagnosticar problemas.
def test_edad_negativa_informa_mensaje_claro():
with pytest.raises(ValueError) as error:
validar_edad(-1)
assert str(error.value) == "La edad no puede ser negativa"
Esta prueba no solo verifica que se lance un error, sino también que el mensaje sea el esperado. Conviene hacerlo cuando el mensaje realmente importa.
También debemos comprobar el caso válido. Si solo probamos entradas inválidas, no sabemos si la unidad acepta correctamente las entradas permitidas.
def test_edad_cero_es_valida():
resultado = validar_edad(0)
assert resultado == True
Esta prueba complementa la anterior. Una verifica el rechazo de edad negativa; la otra verifica que una edad válida no sea rechazada por error.
Veamos una clase que impide retirar más dinero del disponible.
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
def retirar(self, importe):
if importe > self.saldo:
raise ValueError("Saldo insuficiente")
self.saldo -= importe
def test_retirar_mas_del_saldo_lanza_error():
cuenta = Cuenta(100)
with pytest.raises(ValueError):
cuenta.retirar(150)
El comportamiento correcto no es devolver un saldo negativo. El comportamiento correcto es rechazar la operación.
Cuando una operación falla, suele ser importante comprobar que el objeto no quedó en un estado incorrecto.
def test_retirar_mas_del_saldo_no_modifica_saldo():
cuenta = Cuenta(100)
with pytest.raises(ValueError):
cuenta.retirar(150)
assert cuenta.saldo == 100
Esta prueba verifica dos aspectos relacionados: se rechaza la operación y el saldo se conserva. Es una combinación razonable porque ambas aserciones pertenecen al mismo comportamiento.
Crear entidades con datos incompletos puede ser inválido. Por ejemplo, un usuario debe tener nombre.
class Usuario:
def __init__(self, nombre):
if nombre.strip() == "":
raise ValueError("El nombre es obligatorio")
self.nombre = nombre
def test_usuario_sin_nombre_lanza_error():
with pytest.raises(ValueError):
Usuario("")
La prueba confirma que la clase no permite crear un usuario inválido.
Una prueba que acepta cualquier excepción puede ocultar defectos. Por ejemplo:
def test_usuario_sin_nombre_lanza_error_poco_preciso():
with pytest.raises(Exception):
Usuario("")
Esta prueba pasaría con ValueError, pero también con un error accidental como AttributeError. Es mejor esperar el tipo específico:
def test_usuario_sin_nombre_lanza_value_error():
with pytest.raises(ValueError):
Usuario("")
Una prueba mal escrita puede esconder errores reales si captura excepciones de forma demasiado amplia.
def test_malo():
try:
Usuario("")
except Exception:
assert True
Esta prueba pasa ante cualquier excepción, incluso si el código falla por un motivo inesperado. Una prueba útil debe ser específica sobre el error esperado.
Las excepciones suelen aparecer en límites inválidos. Por ejemplo, una cantidad debe ser mayor que cero.
def validar_cantidad(cantidad):
if cantidad <= 0:
raise ValueError("La cantidad debe ser mayor que cero")
return True
def test_cantidad_cero_lanza_error():
with pytest.raises(ValueError):
validar_cantidad(0)
def test_cantidad_negativa_lanza_error():
with pytest.raises(ValueError):
validar_cantidad(-1)
Los casos cero y negativo no son duplicados sin sentido: cada uno representa una situación inválida importante.
Después de probar límites inválidos, conviene probar el primer valor válido.
def test_cantidad_uno_es_valida():
assert validar_cantidad(1) == True
Esto ayuda a confirmar que la validación no quedó demasiado estricta. Si el mínimo permitido es 1, la función debe aceptar 1.
Una excepción esperada forma parte del contrato de una unidad. El contrato dice no solo qué devuelve una función en casos válidos, sino también qué ocurre cuando recibe datos inválidos.
Por ejemplo, una función puede establecer este contrato:
ValueError.Las pruebas deben cubrir ambas partes del contrato.
No todos los diseños usan excepciones. Algunas unidades devuelven un valor que representa error, como None, False o un objeto de resultado.
Ejemplo con retorno:
def buscar_usuario(usuarios, nombre):
for usuario in usuarios:
if usuario["nombre"] == nombre:
return usuario
return None
def test_buscar_usuario_inexistente_devuelve_none():
usuarios = [{"nombre": "Ana"}]
assert buscar_usuario(usuarios, "Luis") == None
Esto no es una prueba de excepción, pero sí una prueba de error esperado mediante valor de retorno. La elección depende del diseño de la unidad y del lenguaje.
| Situación | Comportamiento esperado | Prueba recomendable |
|---|---|---|
| Dividir por cero | Lanzar ValueError |
Verificar excepción específica. |
| Edad negativa | Rechazar valor inválido | Verificar tipo y, si importa, mensaje. |
| Retiro mayor al saldo | Lanzar error y conservar saldo | Verificar excepción y estado final. |
| Usuario sin nombre | Impedir creación | Verificar que no se acepte el dato vacío. |
| Búsqueda sin resultado | Devolver None |
Verificar valor de retorno esperado. |
Al probar excepciones y errores esperados, revisa:
Las excepciones y errores esperados son parte del comportamiento de una unidad. Una prueba unitaria no solo debe confirmar que el código funciona con datos válidos; también debe verificar que rechaza correctamente los datos inválidos.
La clave es ser específico: qué condición inválida se prepara, qué error se espera y qué estado debe conservarse después de la falla.
En el próximo tema veremos pruebas positivas, negativas y casos borde, una forma práctica de organizar los distintos tipos de situaciones que debemos cubrir.