23. Estrategias para refactorizar código heredado en pasos pequeños

23.1 Objetivo del tema

El código heredado suele tener reglas importantes, poca documentación, baja cobertura y decisiones de negocio escondidas en bloques largos. En ese contexto, una reescritura grande suele ser más riesgosa que una mejora gradual.

En este tema aplicaremos una estrategia de refactoring en pasos pequeños: observar comportamiento, escribir pruebas de caracterización, crear puntos de apoyo, extraer funciones y recién después mejorar nombres y estructura.

Objetivo práctico: mejorar código heredado sin cambiar su comportamiento observable y sin depender de una reescritura completa.

23.2 Qué entendemos por código heredado

En la práctica, código heredado no significa solamente código viejo. También puede ser código que no entendemos bien, que no tiene pruebas suficientes o que nadie se anima a modificar porque una pequeña corrección rompe otras partes.

La prioridad inicial no es embellecerlo. La prioridad es volverlo observable y modificable.

23.3 Código inicial problemático

Crea el archivo src/facturacion_heredada.py:

def procesar_factura(datos):
    total = 0
    errores = []
    lineas = []

    if "cliente" not in datos or datos["cliente"] == "":
        errores.append("cliente requerido")

    if "items" not in datos or len(datos["items"]) == 0:
        errores.append("items requeridos")

    if errores:
        return {
            "ok": False,
            "errores": errores,
            "total": 0,
            "detalle": "",
        }

    for item in datos["items"]:
        if item["cantidad"] <= 0:
            errores.append("cantidad inválida")
        if item["precio"] <= 0:
            errores.append("precio inválido")
        total = total + item["cantidad"] * item["precio"]
        lineas.append(f"{item['nombre']} x {item['cantidad']}")

    if errores:
        return {
            "ok": False,
            "errores": errores,
            "total": 0,
            "detalle": "",
        }

    if datos.get("tipo_cliente") == "vip":
        total = total * 0.9

    if total > 50000:
        total = total * 0.95

    if datos.get("envio") == "express":
        total = total + 2500
    else:
        total = total + 1000

    detalle = "\n".join(lineas)

    return {
        "ok": True,
        "errores": [],
        "total": total,
        "detalle": detalle,
    }

La función valida, calcula, aplica descuentos, calcula envío y arma texto. No vamos a reescribirla de golpe.

23.4 Regla inicial: no mejorar sin protección

Cuando el código es heredado, muchas reglas no están escritas en documentación sino en el comportamiento actual. Antes de cambiar estructura, debemos capturar ese comportamiento con pruebas.

Estas pruebas no buscan demostrar que el diseño es bueno. Buscan decir: "esto es lo que el sistema hace hoy".

23.5 Primera prueba de caracterización

Crea tests/test_facturacion_heredada.py:

from src.facturacion_heredada import procesar_factura


def test_procesa_factura_normal():
    datos = {
        "cliente": "Ana",
        "tipo_cliente": "regular",
        "envio": "normal",
        "items": [
            {"nombre": "Teclado", "cantidad": 2, "precio": 5000},
            {"nombre": "Mouse", "cantidad": 1, "precio": 3000},
        ],
    }

    resultado = procesar_factura(datos)

    assert resultado == {
        "ok": True,
        "errores": [],
        "total": 14000,
        "detalle": "Teclado x 2\nMouse x 1",
    }
python -m pytest tests/test_facturacion_heredada.py

23.6 Cubrir casos de riesgo

Agrega pruebas para las ramas que parecen tener reglas importantes:

def test_aplica_descuento_vip_y_envio_express():
    datos = {
        "cliente": "Luis",
        "tipo_cliente": "vip",
        "envio": "express",
        "items": [{"nombre": "Monitor", "cantidad": 1, "precio": 20000}],
    }

    resultado = procesar_factura(datos)

    assert resultado["total"] == 20500


def test_aplica_descuento_por_total_alto():
    datos = {
        "cliente": "Carla",
        "envio": "normal",
        "items": [{"nombre": "Notebook", "cantidad": 1, "precio": 60000}],
    }

    resultado = procesar_factura(datos)

    assert resultado["total"] == 58000

La segunda prueba documenta una regla existente: primero se aplica el descuento por total alto y después se suma el envío normal.

23.7 Cubrir errores antes de extraer

También necesitamos caracterizar validaciones:

def test_falla_si_cliente_esta_vacio():
    datos = {"cliente": "", "items": [{"nombre": "Teclado", "cantidad": 1, "precio": 1000}]}

    resultado = procesar_factura(datos)

    assert resultado == {
        "ok": False,
        "errores": ["cliente requerido"],
        "total": 0,
        "detalle": "",
    }


def test_falla_si_item_tiene_precio_invalido():
    datos = {
        "cliente": "Ana",
        "items": [{"nombre": "Teclado", "cantidad": 1, "precio": 0}],
    }

    resultado = procesar_factura(datos)

    assert resultado["ok"] is False
    assert resultado["errores"] == ["precio inválido"]
python -m pytest tests/test_facturacion_heredada.py

23.8 Primer paso pequeño: extraer respuesta de error

El retorno de error aparece repetido. Extraemos una función sin cambiar lógica:

def respuesta_error(errores):
    return {
        "ok": False,
        "errores": errores,
        "total": 0,
        "detalle": "",
    }

Luego reemplazamos los dos bloques repetidos por return respuesta_error(errores) y ejecutamos pruebas.

python -m pytest tests/test_facturacion_heredada.py

23.9 Segundo paso: extraer validación general

La validación de cliente e items puede tener nombre propio:

def validar_datos_factura(datos):
    errores = []

    if "cliente" not in datos or datos["cliente"] == "":
        errores.append("cliente requerido")

    if "items" not in datos or len(datos["items"]) == 0:
        errores.append("items requeridos")

    return errores

Este cambio no modifica las reglas. Solo mueve el bloque a una función con intención clara.

23.10 Tercer paso: extraer validación de items

Ahora separamos la validación de cada item:

def validar_items(items):
    errores = []

    for item in items:
        if item["cantidad"] <= 0:
            errores.append("cantidad inválida")
        if item["precio"] <= 0:
            errores.append("precio inválido")

    return errores

Después de extraer, la prueba de precio inválido debe seguir pasando igual.

23.11 Cuarto paso: extraer total bruto y detalle

El recorrido de items calcula total y arma líneas de detalle. Podemos extraerlo sin cambiar el formato:

def calcular_total_bruto(items):
    total = 0

    for item in items:
        total += item["cantidad"] * item["precio"]

    return total


def construir_detalle(items):
    lineas = []

    for item in items:
        lineas.append(f"{item['nombre']} x {item['cantidad']}")

    return "\n".join(lineas)

Son funciones simples, pero ya hacen visible qué parte calcula y qué parte formatea.

23.12 Quinto paso: extraer descuentos

Las reglas de descuento tienen orden. Para no cambiar comportamiento, respetamos exactamente la secuencia existente:

def aplicar_descuentos(total, tipo_cliente):
    if tipo_cliente == "vip":
        total = total * 0.9

    if total > 50000:
        total = total * 0.95

    return total

Este punto es delicado: cambiar el orden puede cambiar resultados. Por eso las pruebas de caracterización eran necesarias.

23.13 Sexto paso: extraer envío

La regla de envío también puede quedar aislada:

def sumar_envio(total, tipo_envio):
    if tipo_envio == "express":
        return total + 2500

    return total + 1000

En código heredado, extraer funciones con nombres claros suele ser más seguro que introducir clases demasiado pronto.

23.14 Código refactorizado completo

def respuesta_error(errores):
    return {
        "ok": False,
        "errores": errores,
        "total": 0,
        "detalle": "",
    }


def validar_datos_factura(datos):
    errores = []

    if "cliente" not in datos or datos["cliente"] == "":
        errores.append("cliente requerido")

    if "items" not in datos or len(datos["items"]) == 0:
        errores.append("items requeridos")

    return errores


def validar_items(items):
    errores = []

    for item in items:
        if item["cantidad"] <= 0:
            errores.append("cantidad inválida")
        if item["precio"] <= 0:
            errores.append("precio inválido")

    return errores


def calcular_total_bruto(items):
    total = 0

    for item in items:
        total += item["cantidad"] * item["precio"]

    return total


def construir_detalle(items):
    lineas = []

    for item in items:
        lineas.append(f"{item['nombre']} x {item['cantidad']}")

    return "\n".join(lineas)


def aplicar_descuentos(total, tipo_cliente):
    if tipo_cliente == "vip":
        total = total * 0.9

    if total > 50000:
        total = total * 0.95

    return total


def sumar_envio(total, tipo_envio):
    if tipo_envio == "express":
        return total + 2500

    return total + 1000


def procesar_factura(datos):
    errores = validar_datos_factura(datos)

    if errores:
        return respuesta_error(errores)

    errores = validar_items(datos["items"])

    if errores:
        return respuesta_error(errores)

    total = calcular_total_bruto(datos["items"])
    total = aplicar_descuentos(total, datos.get("tipo_cliente"))
    total = sumar_envio(total, datos.get("envio"))
    detalle = construir_detalle(datos["items"])

    return {
        "ok": True,
        "errores": [],
        "total": total,
        "detalle": detalle,
    }

23.15 Probar funciones extraídas

Después de proteger el comportamiento general, podemos agregar pruebas específicas para las piezas nuevas:

from src.facturacion_heredada import aplicar_descuentos, sumar_envio


def test_aplica_descuento_vip():
    assert aplicar_descuentos(20000, "vip") == 18000


def test_suma_envio_express():
    assert sumar_envio(10000, "express") == 12500

Estas pruebas no reemplazan las de caracterización. Las complementan.

23.16 Crear un punto de apoyo antes de cambiar llamadas

Si otros módulos llaman a procesar_factura, conviene mantener esa función pública estable mientras internamente delega en piezas más pequeñas. Esto permite mejorar el diseño sin obligar a cambiar todo el sistema al mismo tiempo.

En código heredado, preservar interfaces conocidas reduce el alcance del riesgo.

23.17 Trabajar con cambios reversibles

Cada paso debería ser pequeño y fácil de deshacer:

  • Extraer una función.
  • Ejecutar pruebas.
  • Renombrar una variable.
  • Ejecutar pruebas.
  • Eliminar duplicación obvia.
  • Ejecutar pruebas.

Si algo falla, el cambio reciente es pequeño y el diagnóstico es directo.

23.18 Señales para detenerse

No siempre conviene seguir refactorizando. Detente cuando:

  • El código ya permite hacer el cambio funcional necesario.
  • Las pruebas cubren el riesgo principal.
  • Las extracciones agregan nombres útiles, no ruido.
  • El próximo cambio requeriría entender una regla de negocio que todavía no está clara.

Refactorizar código heredado es una práctica de reducción de riesgo, no una búsqueda de perfección.

23.19 Errores comunes

  • Reescribir todo porque el código se ve feo.
  • Eliminar casos raros sin confirmar si son reglas reales.
  • Agregar abstracciones grandes antes de tener pruebas.
  • Cambiar nombres, formato y comportamiento en el mismo paso.
  • Confiar solo en pruebas nuevas y olvidar comportamiento histórico.

23.20 Ejercicio propuesto

Toma una función larga de un proyecto Python existente y aplica esta secuencia:

  • Identifica entradas, salidas y efectos secundarios.
  • Escribe dos pruebas de caracterización sobre casos normales.
  • Escribe una prueba sobre un caso de error o borde.
  • Extrae una función sin cambiar comportamiento.
  • Ejecuta python -m pytest.
  • Repite el ciclo hasta que la función principal sea más legible.

23.21 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Por qué el código heredado necesita protección antes de cambios de diseño.
  • Qué es una prueba de caracterización.
  • Por qué conviene preservar interfaces públicas al principio.
  • Cómo detectar reglas de negocio escondidas en bloques largos.
  • Cuándo detener una refactorización gradual.

23.22 Conclusión

En este tema refactorizamos código heredado mediante pasos pequeños. Primero caracterizamos comportamiento, luego extraímos funciones y finalmente dejamos una función pública más simple que conserva los mismos resultados.

En el próximo tema integraremos lo aprendido en un caso práctico completo de mejora de un proyecto Python sin cambiar su comportamiento.