24. Caso práctico integrador: mejorar un proyecto Python sin cambiar su comportamiento

24.1 Objetivo del tema

Este tema integra las técnicas del curso en un caso práctico completo. Partiremos de un módulo Python que funciona, pero mezcla entrada, reglas, persistencia simulada, salida, condicionales y errores silenciosos.

El objetivo será mejorar su diseño sin cambiar su comportamiento observable. Para lograrlo usaremos pruebas de caracterización, extracciones pequeñas, nombres claros, separación de responsabilidades e inyección de dependencias.

Objetivo práctico: completar una refactorización realista de principio a fin, manteniendo una red de seguridad durante todo el proceso.

24.2 Escenario del proyecto

El proyecto procesa inscripciones a cursos. Recibe filas de datos, valida campos, calcula el importe final, guarda inscripciones válidas y devuelve un resumen para mostrar al usuario.

El código actual está todo en una función. Esto dificulta probar reglas aisladas y modificar descuentos, validaciones o almacenamiento.

24.3 Código inicial

Crea el archivo src/inscripciones.py:

INSCRIPCIONES = []


def procesar_inscripciones(filas):
    aceptadas = 0
    rechazadas = 0
    total = 0
    mensajes = []

    for fila in filas:
        try:
            nombre = fila["nombre"].strip()
            email = fila["email"].strip()
            curso = fila["curso"].strip()
            precio = float(fila["precio"])
            becado = fila.get("becado", "no") == "si"
        except Exception:
            rechazadas += 1
            mensajes.append("fila inválida")
            continue

        if nombre == "" or "@" not in email or curso == "":
            rechazadas += 1
            mensajes.append(f"inscripción rechazada: {email}")
            continue

        if becado:
            importe = precio * 0.5
        elif precio >= 10000:
            importe = precio * 0.9
        else:
            importe = precio

        inscripcion = {
            "nombre": nombre,
            "email": email,
            "curso": curso,
            "importe": importe,
        }

        INSCRIPCIONES.append(inscripcion)
        aceptadas += 1
        total += importe
        mensajes.append(f"inscripción aceptada: {email}")

    return {
        "aceptadas": aceptadas,
        "rechazadas": rechazadas,
        "total": total,
        "mensajes": mensajes,
    }

El módulo tiene estado global, captura cualquier excepción y mezcla demasiadas decisiones en un solo lugar.

24.4 Identificar riesgos antes de tocar código

Antes de refactorizar, identificamos qué puede romperse:

  • El formato exacto del resumen devuelto.
  • La cantidad de aceptadas y rechazadas.
  • El cálculo de becas y descuentos.
  • Los mensajes generados para cada fila.
  • El guardado de inscripciones aceptadas.

Estas zonas deben quedar protegidas por pruebas antes de cambiar la estructura.

24.5 Prueba de caracterización principal

Crea tests/test_inscripciones.py:

from src.inscripciones import INSCRIPCIONES, procesar_inscripciones


def test_procesa_inscripciones_mixtas():
    INSCRIPCIONES.clear()
    filas = [
        {
            "nombre": "Ana",
            "email": "ana@example.com",
            "curso": "Python",
            "precio": "12000",
            "becado": "no",
        },
        {
            "nombre": "Luis",
            "email": "luis@example.com",
            "curso": "Testing",
            "precio": "8000",
            "becado": "si",
        },
        {
            "nombre": "",
            "email": "sin-nombre@example.com",
            "curso": "Python",
            "precio": "5000",
        },
    ]

    resumen = procesar_inscripciones(filas)

    assert resumen == {
        "aceptadas": 2,
        "rechazadas": 1,
        "total": 14800.0,
        "mensajes": [
            "inscripción aceptada: ana@example.com",
            "inscripción aceptada: luis@example.com",
            "inscripción rechazada: sin-nombre@example.com",
        ],
    }
    assert INSCRIPCIONES == [
        {"nombre": "Ana", "email": "ana@example.com", "curso": "Python", "importe": 10800.0},
        {"nombre": "Luis", "email": "luis@example.com", "curso": "Testing", "importe": 4000.0},
    ]
python -m pytest tests/test_inscripciones.py

24.6 Prueba para filas inválidas

El except Exception actual convierte datos incompletos en una fila rechazada. Lo caracterizamos antes de mejorarlo:

def test_rechaza_fila_con_datos_incompletos():
    INSCRIPCIONES.clear()
    filas = [{"nombre": "Ana", "email": "ana@example.com"}]

    resumen = procesar_inscripciones(filas)

    assert resumen == {
        "aceptadas": 0,
        "rechazadas": 1,
        "total": 0,
        "mensajes": ["fila inválida"],
    }
    assert INSCRIPCIONES == []
python -m pytest tests/test_inscripciones.py

24.7 Plan de refactoring

El plan será incremental:

  • Extraer parseo de fila.
  • Reemplazar except Exception por errores esperados.
  • Extraer validación.
  • Extraer cálculo de importe.
  • Separar guardado mediante repositorio inyectado.
  • Mantener la función pública para no romper llamadas existentes.

24.8 Extraer parseo de fila

Primero damos nombre a la conversión de datos externos:

def parsear_fila(fila):
    return {
        "nombre": fila["nombre"].strip(),
        "email": fila["email"].strip(),
        "curso": fila["curso"].strip(),
        "precio": float(fila["precio"]),
        "becado": fila.get("becado", "no") == "si",
    }

Después de reemplazar el bloque original por esta función, ejecutamos las pruebas.

python -m pytest tests/test_inscripciones.py

24.9 Reemplazar excepción amplia

Ahora podemos capturar errores concretos del parseo:

def parsear_fila_segura(fila):
    try:
        return parsear_fila(fila)
    except (KeyError, TypeError, ValueError):
        return None

Esta versión todavía conserva el comportamiento observable de rechazar la fila como inválida, pero deja de ocultar cualquier error inesperado del programa.

24.10 Extraer validación

La regla que decide si una inscripción es válida queda más clara como función:

def inscripcion_es_valida(inscripcion):
    return (
        inscripcion["nombre"] != ""
        and "@" in inscripcion["email"]
        and inscripcion["curso"] != ""
    )

Esta función es pura y puede probarse sin estado global.

24.11 Extraer cálculo de importe

El cálculo de beca y descuento también puede aislarse:

def calcular_importe(precio, becado):
    if becado:
        return precio * 0.5

    if precio >= 10000:
        return precio * 0.9

    return precio

El orden es importante: la beca tiene prioridad sobre el descuento por precio alto.

24.12 Probar reglas extraídas

Agrega pruebas enfocadas para las reglas centrales:

from src.inscripciones import calcular_importe, inscripcion_es_valida


def test_calcula_importe_becado():
    assert calcular_importe(12000, True) == 6000


def test_calcula_importe_con_descuento_por_precio_alto():
    assert calcular_importe(12000, False) == 10800


def test_valida_inscripcion_con_datos_correctos():
    inscripcion = {
        "nombre": "Ana",
        "email": "ana@example.com",
        "curso": "Python",
    }

    assert inscripcion_es_valida(inscripcion) is True
python -m pytest tests/test_inscripciones.py

24.13 Separar persistencia

El estado global puede quedar detrás de un repositorio simple:

class RepositorioInscripciones:
    def __init__(self, almacenamiento):
        self.almacenamiento = almacenamiento

    def guardar(self, inscripcion):
        self.almacenamiento.append(inscripcion)

Esto permite que el caso de uso reciba un repositorio falso durante las pruebas y conserve el almacenamiento global en la función pública.

24.14 Crear el caso de uso refactorizado

Ahora coordinamos parseo, validación, cálculo y guardado:

def procesar_inscripciones_con_repositorio(filas, repositorio):
    aceptadas = 0
    rechazadas = 0
    total = 0
    mensajes = []

    for fila in filas:
        inscripcion = parsear_fila_segura(fila)

        if inscripcion is None:
            rechazadas += 1
            mensajes.append("fila inválida")
            continue

        if not inscripcion_es_valida(inscripcion):
            rechazadas += 1
            mensajes.append(f"inscripción rechazada: {inscripcion['email']}")
            continue

        importe = calcular_importe(inscripcion["precio"], inscripcion["becado"])
        inscripcion_guardada = {
            "nombre": inscripcion["nombre"],
            "email": inscripcion["email"],
            "curso": inscripcion["curso"],
            "importe": importe,
        }

        repositorio.guardar(inscripcion_guardada)
        aceptadas += 1
        total += importe
        mensajes.append(f"inscripción aceptada: {inscripcion['email']}")

    return {
        "aceptadas": aceptadas,
        "rechazadas": rechazadas,
        "total": total,
        "mensajes": mensajes,
    }

24.15 Mantener compatibilidad pública

La función original puede seguir existiendo y delegar en el nuevo caso de uso:

def procesar_inscripciones(filas):
    repositorio = RepositorioInscripciones(INSCRIPCIONES)
    return procesar_inscripciones_con_repositorio(filas, repositorio)

Las pruebas de caracterización siguen llamando al punto de entrada original, por lo que confirman que no rompimos la interfaz pública.

24.16 Código refactorizado completo

INSCRIPCIONES = []


class RepositorioInscripciones:
    def __init__(self, almacenamiento):
        self.almacenamiento = almacenamiento

    def guardar(self, inscripcion):
        self.almacenamiento.append(inscripcion)


def parsear_fila(fila):
    return {
        "nombre": fila["nombre"].strip(),
        "email": fila["email"].strip(),
        "curso": fila["curso"].strip(),
        "precio": float(fila["precio"]),
        "becado": fila.get("becado", "no") == "si",
    }


def parsear_fila_segura(fila):
    try:
        return parsear_fila(fila)
    except (KeyError, TypeError, ValueError):
        return None


def inscripcion_es_valida(inscripcion):
    return (
        inscripcion["nombre"] != ""
        and "@" in inscripcion["email"]
        and inscripcion["curso"] != ""
    )


def calcular_importe(precio, becado):
    if becado:
        return precio * 0.5

    if precio >= 10000:
        return precio * 0.9

    return precio


def procesar_inscripciones_con_repositorio(filas, repositorio):
    aceptadas = 0
    rechazadas = 0
    total = 0
    mensajes = []

    for fila in filas:
        inscripcion = parsear_fila_segura(fila)

        if inscripcion is None:
            rechazadas += 1
            mensajes.append("fila inválida")
            continue

        if not inscripcion_es_valida(inscripcion):
            rechazadas += 1
            mensajes.append(f"inscripción rechazada: {inscripcion['email']}")
            continue

        importe = calcular_importe(inscripcion["precio"], inscripcion["becado"])
        inscripcion_guardada = {
            "nombre": inscripcion["nombre"],
            "email": inscripcion["email"],
            "curso": inscripcion["curso"],
            "importe": importe,
        }

        repositorio.guardar(inscripcion_guardada)
        aceptadas += 1
        total += importe
        mensajes.append(f"inscripción aceptada: {inscripcion['email']}")

    return {
        "aceptadas": aceptadas,
        "rechazadas": rechazadas,
        "total": total,
        "mensajes": mensajes,
    }


def procesar_inscripciones(filas):
    repositorio = RepositorioInscripciones(INSCRIPCIONES)
    return procesar_inscripciones_con_repositorio(filas, repositorio)

24.17 Probar el caso de uso sin estado global

Ahora podemos probar el flujo usando un repositorio falso:

from src.inscripciones import procesar_inscripciones_con_repositorio


class RepositorioFalso:
    def __init__(self):
        self.inscripciones = []

    def guardar(self, inscripcion):
        self.inscripciones.append(inscripcion)


def test_procesa_con_repositorio_inyectado():
    repositorio = RepositorioFalso()
    filas = [{
        "nombre": "Ana",
        "email": "ana@example.com",
        "curso": "Python",
        "precio": "1000",
    }]

    resumen = procesar_inscripciones_con_repositorio(filas, repositorio)

    assert resumen["aceptadas"] == 1
    assert repositorio.inscripciones == [{
        "nombre": "Ana",
        "email": "ana@example.com",
        "curso": "Python",
        "importe": 1000.0,
    }]

24.18 Verificación con herramientas

Al terminar, ejecuta una verificación completa:

python -m pytest tests/test_inscripciones.py
python -m coverage run -m pytest tests/test_inscripciones.py
python -m coverage report -m
python -m ruff check src tests
python -m black src tests

Si agregaste type hints al módulo, también puedes ejecutar:

python -m mypy src/inscripciones.py

24.19 Comparar antes y después

Después del refactoring, el comportamiento público se mantiene, pero el diseño cambió:

  • El parseo de entrada tiene una función propia.
  • La validación puede probarse de forma aislada.
  • El cálculo de importe quedó separado.
  • El guardado ya no está escondido dentro del algoritmo principal.
  • El punto de entrada original sigue disponible.

24.20 Decisiones que no tomamos

No convertimos todo en clases, no agregamos una base de datos real y no cambiamos el formato del resumen. Esas decisiones podrían tener sentido en otro contexto, pero no eran necesarias para este objetivo.

Un buen refactoring mejora el diseño suficiente para el próximo cambio, sin convertir una práctica acotada en una reescritura completa.

24.21 Ejercicio integrador

Aplica la misma estrategia sobre otro módulo pequeño:

  • Elige una función de entre 40 y 100 líneas.
  • Escribe pruebas de caracterización para dos casos normales y un caso inválido.
  • Extrae una función de parseo o normalización.
  • Extrae una regla de negocio pura.
  • Inyecta una dependencia que hoy esté escondida.
  • Ejecuta python -m pytest después de cada paso.

24.22 Lista de verificación final

Antes de cerrar el curso, verifica que puedes explicar estos puntos:

  • Cómo proteger comportamiento antes de refactorizar.
  • Por qué los cambios pequeños reducen riesgo.
  • Cómo separar entrada, reglas, persistencia y salida.
  • Cuándo conviene preservar una interfaz pública.
  • Cómo usar herramientas para confirmar que el código sigue funcionando.
  • Cuándo detener una refactorización.

24.23 Conclusión del curso

En este curso trabajamos refactoring como una disciplina práctica: cambiar estructura sin cambiar comportamiento. Vimos cómo avanzar con pruebas, nombres claros, extracciones, simplificación de condicionales, separación de responsabilidades, inyección de dependencias, manejo explícito de errores y herramientas de verificación.

La idea central es simple: el código mejora cuando cada cambio es pequeño, verificable y orientado a hacer más claro el próximo mantenimiento.