En este tema usaremos TDD para decidir cómo debe comportarse una función frente a casos borde y valores inválidos. No agregaremos validaciones por intuición: primero escribiremos pruebas que expresen qué comportamiento esperamos.
Continuaremos con la función obtener_calificacion del tema anterior y agregaremos reglas para límites, puntajes fuera de rango y tipos de datos incorrectos.
Partimos de esta implementación:
Archivo existente: src/academico/calificaciones.py
def obtener_calificacion(puntaje):
if puntaje >= 90:
return "A"
if puntaje >= 80:
return "B"
if puntaje >= 70:
return "C"
return "D"
Funciona para varios puntajes válidos, pero todavía no define qué ocurre con valores inválidos.
Un caso borde está en el límite entre dos comportamientos. En esta función, los límites más importantes son 90, 80, 70, 0 y 100.
Los errores suelen aparecer justo en esos puntos, especialmente cuando usamos comparaciones como >, >=, < o <=.
Primero escribimos pruebas para los límites entre calificaciones.
Archivo a modificar: tests/test_calificaciones.py
import pytest
from academico.calificaciones import obtener_calificacion
@pytest.mark.parametrize(
"puntaje, esperado",
[
(90, "A"),
(89, "B"),
(80, "B"),
(79, "C"),
(70, "C"),
(69, "D"),
],
)
def test_obtener_calificacion_respeta_limites_de_rango(puntaje, esperado):
assert obtener_calificacion(puntaje) == esperado
Ejecutamos:
python -m pytest
Estas pruebas deberían pasar con la implementación actual.
Si una prueba de borde ya pasa, igual puede ser valiosa porque documenta explícitamente la regla. En este caso, los límites quedan protegidos contra futuros refactors incorrectos.
Por ejemplo, si alguien cambia puntaje >= 90 por puntaje > 90, el caso 90 fallará.
El requisito dice que el puntaje válido está entre 0 y 100. Agregamos pruebas para esos extremos.
Archivo a modificar: tests/test_calificaciones.py
def test_obtener_calificacion_acepta_puntaje_minimo():
assert obtener_calificacion(0) == "D"
def test_obtener_calificacion_acepta_puntaje_maximo():
assert obtener_calificacion(100) == "A"
Ejecutamos python -m pytest. Estos casos deberían pasar.
Un puntaje negativo no es válido. En lugar de devolver una calificación, decidiremos lanzar una excepción.
Primero escribimos la prueba.
Para probar excepciones con pytest, usamos pytest.raises.
Archivo a modificar: tests/test_calificaciones.py
import pytest
from academico.calificaciones import obtener_calificacion
def test_obtener_calificacion_rechaza_puntaje_negativo():
with pytest.raises(ValueError):
obtener_calificacion(-1)
Ejecutamos python -m pytest. La prueba debe fallar porque la función actual devuelve "D".
Agregamos el cambio mínimo para cumplir la prueba.
Archivo a modificar: src/academico/calificaciones.py
def obtener_calificacion(puntaje):
if puntaje < 0:
raise ValueError("El puntaje no puede ser negativo")
if puntaje >= 90:
return "A"
if puntaje >= 80:
return "B"
if puntaje >= 70:
return "C"
return "D"
Ejecutamos:
python -m pytest
Un puntaje mayor que 100 tampoco es válido.
Escribimos una prueba antes de modificar producción.
Archivo a modificar: tests/test_calificaciones.py
def test_obtener_calificacion_rechaza_puntaje_mayor_a_cien():
with pytest.raises(ValueError):
obtener_calificacion(101)
Ejecutamos python -m pytest. La prueba debe fallar porque la función actual devuelve "A".
Agregamos la segunda validación.
Archivo a modificar: src/academico/calificaciones.py
def obtener_calificacion(puntaje):
if puntaje < 0 or puntaje > 100:
raise ValueError("El puntaje debe estar entre 0 y 100")
if puntaje >= 90:
return "A"
if puntaje >= 80:
return "B"
if puntaje >= 70:
return "C"
return "D"
Ejecutamos python -m pytest. Todos los casos anteriores deben seguir pasando.
Con la suite en verde, podemos extraer una función auxiliar para mejorar lectura.
Archivo a modificar: src/academico/calificaciones.py
def validar_puntaje(puntaje):
if puntaje < 0 or puntaje > 100:
raise ValueError("El puntaje debe estar entre 0 y 100")
def obtener_calificacion(puntaje):
validar_puntaje(puntaje)
if puntaje >= 90:
return "A"
if puntaje >= 80:
return "B"
if puntaje >= 70:
return "C"
return "D"
Ejecutamos nuevamente python -m pytest.
También podemos decidir qué hacer si llega un valor que no es numérico, por ejemplo una cadena.
Esta regla también debe nacer con una prueba.
Archivo a modificar: tests/test_calificaciones.py
def test_obtener_calificacion_rechaza_puntaje_no_numerico():
with pytest.raises(TypeError):
obtener_calificacion("noventa")
Ejecutamos python -m pytest. El error actual podría ser un TypeError producido por la comparación, pero conviene controlar el mensaje y el lugar donde ocurre.
Agregamos una validación explícita.
Archivo a modificar: src/academico/calificaciones.py
def validar_puntaje(puntaje):
if not isinstance(puntaje, (int, float)):
raise TypeError("El puntaje debe ser numérico")
if puntaje < 0 or puntaje > 100:
raise ValueError("El puntaje debe estar entre 0 y 100")
def obtener_calificacion(puntaje):
validar_puntaje(puntaje)
if puntaje >= 90:
return "A"
if puntaje >= 80:
return "B"
if puntaje >= 70:
return "C"
return "D"
Ejecutamos la suite completa.
python -m pytest
Si el mensaje forma parte del contrato esperado, podemos probarlo con match:
def test_obtener_calificacion_informa_rango_valido():
with pytest.raises(ValueError, match="entre 0 y 100"):
obtener_calificacion(101)
No siempre hace falta verificar mensajes. Hazlo cuando el mensaje sea importante para quien use la función.
Podemos agrupar valores fuera de rango:
Archivo a modificar: tests/test_calificaciones.py
@pytest.mark.parametrize("puntaje", [-1, 101])
def test_obtener_calificacion_rechaza_puntajes_fuera_de_rango(puntaje):
with pytest.raises(ValueError):
obtener_calificacion(puntaje)
La parametrización evita duplicar pruebas que verifican la misma regla.
70, 80 y 90.ValueError o TypeError según el problema.Agrega pruebas para estos casos:
obtener_calificacion(100) debe devolver "A".obtener_calificacion(0) debe devolver "D".obtener_calificacion(None) debe lanzar TypeError.obtener_calificacion(100.5) debe lanzar ValueError.Escribe primero las pruebas, ejecuta python -m pytest y modifica el código solo cuando una prueba lo exija.
Antes de continuar, verifica lo siguiente:
pytest.raises para excepciones esperadas.ValueError de TypeError.python -m pytest después de cada cambio.En este tema usamos TDD para definir casos borde, valores inválidos y excepciones. Las validaciones no aparecieron por suposición: cada una nació de una prueba que describía el comportamiento esperado.
En el próximo tema profundizaremos en parametrización para expresar múltiples ejemplos del mismo comportamiento con pruebas más compactas y claras.