10. Cobertura de validaciones, excepciones y casos borde

10.1 Objetivo del tema

Muchas líneas faltantes en un reporte de cobertura corresponden a validaciones, excepciones y casos borde. Es normal que el camino principal esté probado primero y que los caminos de error queden pendientes.

En este tema vamos a transformar esas líneas faltantes en pruebas claras, usando pytest.raises para excepciones y parametrización para límites.

Objetivo práctico: cubrir validaciones y casos borde sin escribir pruebas artificiales ni aserciones débiles.

10.2 Crear un módulo con validaciones

Crea el archivo src/tienda/cupones.py:

def aplicar_cupon(total, codigo):
    if total <= 0:
        raise ValueError("El total debe ser mayor que cero")

    if not codigo:
        return total

    codigo = codigo.upper()

    if codigo == "DESC10":
        return total * 0.90

    if codigo == "DESC20":
        return total * 0.80

    raise ValueError("Cupón inválido")


def calcular_puntos(total):
    if total < 0:
        raise ValueError("El total no puede ser negativo")

    if total < 1000:
        return 0

    if total < 10000:
        return int(total // 1000)

    return int(total // 500)

El módulo mezcla caminos normales, ausencia de cupón, cupones válidos, cupón inválido y límites para cálculo de puntos.

10.3 Pruebas iniciales insuficientes

Una primera versión de pruebas podría cubrir solo el camino principal:

from tienda.cupones import aplicar_cupon, calcular_puntos


def test_aplicar_cupon_desc10():
    assert aplicar_cupon(1000, "DESC10") == 900


def test_calcular_puntos_compra_media():
    assert calcular_puntos(3000) == 3

Estas pruebas son válidas, pero dejan sin ejecutar varios caminos: total inválido, ausencia de cupón, otro cupón válido, cupón inválido y límites del cálculo de puntos.

10.4 Medir las líneas faltantes

Ejecuta el reporte con líneas faltantes.

En Windows PowerShell:

$env:PYTHONPATH="src"
python -m pytest --cov=src --cov-report=term-missing

En Linux o macOS:

PYTHONPATH=src python -m pytest --cov=src --cov-report=term-missing

Una salida posible para cupones.py sería:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
src\tienda\cupones.py      22      9    59%   3, 6, 14, 16, 21, 24, 27, 29, 31

Los números pueden variar, pero el patrón es importante: el reporte apunta a caminos que todavía no tienen pruebas.

10.5 Cubrir excepciones con pytest.raises

Para probar excepciones, no alcanza con ejecutar la función. Hay que verificar que lance el error esperado.

import pytest

from tienda.cupones import aplicar_cupon, calcular_puntos


def test_aplicar_cupon_rechaza_total_cero():
    with pytest.raises(ValueError):
        aplicar_cupon(0, "DESC10")


def test_aplicar_cupon_rechaza_codigo_invalido():
    with pytest.raises(ValueError):
        aplicar_cupon(1000, "NOEXISTE")


def test_calcular_puntos_rechaza_total_negativo():
    with pytest.raises(ValueError):
        calcular_puntos(-1)

Estas pruebas cubren caminos de error y documentan qué entradas no son aceptadas.

10.6 Verificar el mensaje de error

Cuando el mensaje forma parte del comportamiento esperado, también se puede verificar:

def test_aplicar_cupon_rechaza_codigo_invalido_con_mensaje():
    with pytest.raises(ValueError, match="Cupón inválido"):
        aplicar_cupon(1000, "NOEXISTE")

No conviene verificar mensajes si cambian con frecuencia o si son solo detalles internos. Sí conviene hacerlo cuando ayudan a asegurar una regla de negocio o una respuesta esperada.

10.7 Cubrir ausencia de cupón

El camino if not codigo representa una decisión válida: comprar sin cupón debe devolver el total sin descuento.

def test_aplicar_cupon_sin_codigo_devuelve_total_original():
    assert aplicar_cupon(1000, "") == 1000

Esta prueba no se escribe solo porque falta una línea. Se escribe porque comprar sin cupón es un comportamiento real.

10.8 Probar límites inferiores y superiores

Los casos borde aparecen alrededor de los valores donde cambia la regla. En calcular_puntos, los límites importantes son 0, 1000 y 10000.

def test_calcular_puntos_menor_a_mil():
    assert calcular_puntos(999) == 0


def test_calcular_puntos_en_mil():
    assert calcular_puntos(1000) == 1


def test_calcular_puntos_debajo_de_diez_mil():
    assert calcular_puntos(9999) == 9


def test_calcular_puntos_en_diez_mil():
    assert calcular_puntos(10000) == 20

Estas pruebas revisan los puntos donde suele haber errores de comparación: <, <=, > y >=.

10.9 Parametrizar casos borde

Cuando varias pruebas tienen la misma estructura, conviene parametrizar:

import pytest


@pytest.mark.parametrize(
    "total, esperado",
    [
        (0, 0),
        (999, 0),
        (1000, 1),
        (9999, 9),
        (10000, 20),
    ],
)
def test_calcular_puntos_casos_borde(total, esperado):
    assert calcular_puntos(total) == esperado

La parametrización permite mantener visible la tabla de casos sin repetir el mismo cuerpo de prueba muchas veces.

10.10 Cubrir variantes válidas

Si existen dos cupones válidos, ambos deben estar representados en las pruebas:

@pytest.mark.parametrize(
    "codigo, esperado",
    [
        ("DESC10", 900),
        ("DESC20", 800),
        ("desc10", 900),
    ],
)
def test_aplicar_cupon_codigos_validos(codigo, esperado):
    assert aplicar_cupon(1000, codigo) == esperado

El caso "desc10" también comprueba que la función acepta minúsculas porque convierte el código con upper().

10.11 Medir nuevamente

Después de agregar las pruebas, vuelve a ejecutar:

En Windows PowerShell:

$env:PYTHONPATH="src"
python -m pytest --cov=src --cov-report=term-missing

En Linux o macOS:

PYTHONPATH=src python -m pytest --cov=src --cov-report=term-missing

La cobertura de cupones.py debería subir y la lista de líneas faltantes debería reducirse. Si todavía quedan líneas sin cubrir, revisa si representan un comportamiento necesario o código que conviene simplificar.

10.12 Errores frecuentes

  • Probar solo el camino feliz: las validaciones y errores también son comportamiento del sistema.
  • No verificar la excepción: usa pytest.raises para confirmar que el error esperado ocurre.
  • Olvidar los límites: prueba justo antes, justo en y justo después de los valores donde cambia una regla.
  • Forzar pruebas artificiales: si una línea no representa comportamiento útil, revisa si el código sobra.

10.13 Conclusión

En este tema usamos cobertura para detectar validaciones, excepciones y casos borde sin probar. Luego agregamos pruebas con pytest.raises y parametrización para cubrir reglas importantes.

En el próximo tema vamos a trabajar con clases, métodos y cambios de estado, donde la cobertura requiere mirar no solo entradas y salidas, sino también cómo evoluciona el objeto.