13. Casos borde, valores inválidos y excepciones guiadas por pruebas

13.1 Objetivo del tema

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.

Objetivo práctico: guiar validaciones y excepciones mediante pruebas automatizadas con pytest.

13.2 Punto de partida

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.

13.3 Qué es un caso borde

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 <=.

13.4 Probar límites de rangos

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.

13.5 Qué hacer cuando una prueba nueva ya pasa

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á.

13.6 Valores mínimos y máximos válidos

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.

13.7 Definir comportamiento para puntaje negativo

Un puntaje negativo no es válido. En lugar de devolver una calificación, decidiremos lanzar una excepción.

Si el puntaje es menor que 0, debe lanzarse ValueError.

Primero escribimos la prueba.

13.8 Prueba con pytest.raises

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".

13.9 Implementar validación de mínimo

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

13.10 Definir comportamiento para mayor que 100

Un puntaje mayor que 100 tampoco es válido.

Si el puntaje es mayor que 100, debe lanzarse ValueError.

Escribimos una prueba antes de modificar producción.

13.11 Prueba para puntaje mayor que 100

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".

13.12 Implementar validación de máximo

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.

13.13 Refactor: extraer validación

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.

13.14 Validar tipos de datos

También podemos decidir qué hacer si llega un valor que no es numérico, por ejemplo una cadena.

Si el puntaje no es numérico, debe lanzarse TypeError.

Esta regla también debe nacer con una prueba.

13.15 Prueba para tipo inválido

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.

13.16 Implementar validación de tipo

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

13.17 Verificar mensajes de excepción

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.

13.18 Parametrizar valores inválidos

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.

13.19 Errores frecuentes

  • No probar límites exactos: los errores aparecen con frecuencia en valores como 70, 80 y 90.
  • Agregar validaciones sin prueba: puede cambiar comportamiento sin documentación ejecutable.
  • Usar excepciones genéricas: conviene elegir ValueError o TypeError según el problema.
  • Probar mensajes innecesarios: vuelve frágiles las pruebas si el texto no es parte del contrato.
  • Validar después de calcular: los valores inválidos deberían rechazarse antes de aplicar reglas de negocio.

13.20 Ejercicio propuesto

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.

13.21 Lista de verificación

Antes de continuar, verifica lo siguiente:

  • Probaste límites entre rangos válidos.
  • Probaste el mínimo y máximo permitidos.
  • Escribiste pruebas antes de agregar validaciones.
  • Usaste pytest.raises para excepciones esperadas.
  • Diferenciaste ValueError de TypeError.
  • Ejecutaste python -m pytest después de cada cambio.

13.22 Conclusión

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.