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.
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.
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.
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".
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
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.
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
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
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.
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.
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.
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.
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.
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,
}
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.
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.
Cada paso debería ser pequeño y fácil de deshacer:
Si algo falla, el cambio reciente es pequeño y el diagnóstico es directo.
No siempre conviene seguir refactorizando. Detente cuando:
Refactorizar código heredado es una práctica de reducción de riesgo, no una búsqueda de perfección.
Toma una función larga de un proyecto Python existente y aplica esta secuencia:
python -m pytest.Antes de continuar, verifica que puedes explicar estos puntos:
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.