24. Caso práctico integrador: mejorar la cobertura de un proyecto Python

24.1 Objetivo del tema

En este tema vamos a integrar lo aprendido durante el curso: medición de cobertura, lectura de reportes, cobertura de ramas, parametrización, configuración y mejora progresiva de pruebas.

Trabajaremos sobre un pequeño proyecto de tienda con reglas de precios, cupones y pagos. El objetivo no es llegar a un número perfecto, sino aplicar una estrategia razonable para mejorar cobertura y calidad.

Objetivo práctico: partir de un reporte con huecos de cobertura y terminar con pruebas más completas, configuradas y mantenibles.

24.2 Estructura del proyecto

Usaremos esta estructura:

cobertura-demo/
|-- pyproject.toml
|-- requirements.txt
|-- src/
|   `-- tienda/
|       |-- __init__.py
|       |-- precios.py
|       |-- cupones.py
|       `-- pagos.py
`-- tests/
    |-- test_precios.py
    `-- test_cupones.py

El módulo pagos.py todavía no tendrá pruebas suficientes. Lo usaremos para practicar la mejora guiada por cobertura.

24.3 Configuración base

En pyproject.toml, deja una configuración consistente:

[tool.coverage.run]
source = ["src"]
branch = true
omit = [
    "*/__init__.py",
]

[tool.coverage.report]
show_missing = true
precision = 2
fail_under = 80

Esta configuración mide src, activa ramas, omite inicializadores vacíos y muestra líneas faltantes.

24.4 Módulo de pagos

Crea src/tienda/pagos.py:

def autorizar_pago(monto, metodo, intentos_fallidos=0):
    if monto <= 0:
        raise ValueError("El monto debe ser mayor que cero")

    if intentos_fallidos >= 3:
        return "bloqueado"

    if metodo == "tarjeta":
        if monto > 100000:
            return "revision"
        return "aprobado"

    if metodo == "transferencia":
        return "pendiente"

    return "rechazado"


def calcular_comision(monto, metodo):
    if monto <= 0:
        raise ValueError("El monto debe ser mayor que cero")

    if metodo == "tarjeta":
        return monto * 0.03

    if metodo == "transferencia":
        return 0

    raise ValueError("Método de pago inválido")

Este módulo tiene validaciones, ramas anidadas, retornos alternativos y excepciones.

24.5 Pruebas iniciales

Crea tests/test_pagos.py con pocas pruebas iniciales:

from tienda.pagos import autorizar_pago, calcular_comision


def test_autorizar_pago_tarjeta_aprobado():
    assert autorizar_pago(1000, "tarjeta") == "aprobado"


def test_calcular_comision_tarjeta():
    assert calcular_comision(1000, "tarjeta") == 30

Estas pruebas cubren el camino feliz de tarjeta, pero dejan varios comportamientos sin verificar.

24.6 Medición inicial

Ejecuta el reporte:

En Windows PowerShell:

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

En Linux o macOS:

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

Una salida posible para pagos.py:

Name                 Stmts   Miss Branch BrPart   Cover   Missing
------------------------------------------------------------------
src\tienda\pagos.py    24     10     12      5   55.56%   3, 6, 10, 14, 16, 21, 27, 29, 31

El reporte muestra líneas faltantes y ramas parciales. Ahora debemos convertir esa información en pruebas útiles.

24.7 Identificar comportamientos faltantes

Al leer pagos.py, encontramos estos comportamientos sin cubrir:

  • Monto inválido.
  • Pago bloqueado por intentos fallidos.
  • Tarjeta con monto alto que requiere revisión.
  • Transferencia pendiente.
  • Método desconocido rechazado.
  • Comisión para transferencia.
  • Método inválido en comisión.

Esta lista es más útil que una lista de números de línea porque está expresada como comportamientos.

24.8 Agregar pruebas de autorización

Agrega pruebas para los caminos pendientes de autorizar_pago:

import pytest

from tienda.pagos import autorizar_pago, calcular_comision


def test_autorizar_pago_rechaza_monto_invalido():
    with pytest.raises(ValueError, match="mayor que cero"):
        autorizar_pago(0, "tarjeta")


def test_autorizar_pago_bloqueado_por_intentos():
    assert autorizar_pago(1000, "tarjeta", intentos_fallidos=3) == "bloqueado"


def test_autorizar_pago_tarjeta_monto_alto_requiere_revision():
    assert autorizar_pago(150000, "tarjeta") == "revision"


def test_autorizar_pago_transferencia_pendiente():
    assert autorizar_pago(1000, "transferencia") == "pendiente"


def test_autorizar_pago_metodo_desconocido_rechazado():
    assert autorizar_pago(1000, "efectivo") == "rechazado"

Cada prueba representa una regla específica del módulo.

24.9 Agregar pruebas de comisión

Completa los caminos de calcular_comision:

def test_calcular_comision_rechaza_monto_invalido():
    with pytest.raises(ValueError, match="mayor que cero"):
        calcular_comision(0, "tarjeta")


def test_calcular_comision_transferencia_sin_comision():
    assert calcular_comision(1000, "transferencia") == 0


def test_calcular_comision_rechaza_metodo_invalido():
    with pytest.raises(ValueError, match="inválido"):
        calcular_comision(1000, "efectivo")

Estas pruebas cubren validaciones y caminos alternativos sin depender de detalles internos.

24.10 Parametrizar donde ayuda

Si prefieres compactar pruebas de autorización, puedes parametrizar los resultados normales:

import pytest


@pytest.mark.parametrize(
    "monto, metodo, intentos, esperado",
    [
        (1000, "tarjeta", 0, "aprobado"),
        (150000, "tarjeta", 0, "revision"),
        (1000, "transferencia", 0, "pendiente"),
        (1000, "efectivo", 0, "rechazado"),
        (1000, "tarjeta", 3, "bloqueado"),
    ],
)
def test_autorizar_pago_resultados(monto, metodo, intentos, esperado):
    assert autorizar_pago(monto, metodo, intentos) == esperado

Parametriza cuando los casos comparten intención. Mantén pruebas separadas si eso mejora la claridad.

24.11 Medir nuevamente

Ejecuta otra vez:

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

El reporte de pagos.py debería mejorar notablemente:

Name                 Stmts   Miss Branch BrPart    Cover   Missing
-------------------------------------------------------------------
src\tienda\pagos.py    24      0     12      0   100.00%

Si no llega a ese resultado, revisa qué líneas faltan y si representan comportamientos que todavía no fueron probados.

24.12 Generar reporte HTML final

Para revisar visualmente el resultado:

python -m pytest --cov --cov-report=term-missing --cov-report=html

Abre htmlcov/index.html y revisa que los archivos críticos tengan cobertura suficiente y que no haya ramas parciales importantes.

24.13 Revisar el mínimo de cobertura

Si configuraste fail_under = 80, la ejecución debería pasar cuando el total supere ese mínimo.

Si falla, no bajes el mínimo automáticamente. Primero revisa:

  • Qué módulo tiene baja cobertura.
  • Si faltan pruebas de comportamiento importante.
  • Si se están midiendo archivos que deberían quedar fuera.
  • Si el umbral elegido es razonable para el estado actual.

24.14 Checklist final del curso

  • Medición consistente: usar configuración en pyproject.toml.
  • Reporte útil: mostrar líneas faltantes y activar ramas.
  • Priorización: atender primero módulos críticos.
  • Pruebas de calidad: verificar comportamiento, no solo ejecutar líneas.
  • Mínimo razonable: evitar regresiones sin perseguir números artificiales.
  • Revisión periódica: ajustar exclusiones, umbrales y estrategia según el proyecto.

24.15 Cierre

La cobertura de código es una herramienta de análisis. Ayuda a descubrir zonas sin ejecutar, ramas no probadas y módulos que necesitan más atención.

Usada con criterio, mejora la conversación sobre calidad. Usada como número aislado, puede llevar a pruebas débiles. La meta práctica es mantener pruebas que cubran comportamientos importantes y que sigan siendo útiles cuando el proyecto evoluciona.