Refactorizar con seguridad no depende solo de conocer técnicas. También depende de trabajar con un ciclo disciplinado: partir de pruebas verdes, hacer un cambio pequeño, ejecutar pruebas otra vez y confirmar el avance si todo sigue funcionando.
En este tema practicaremos ese ciclo sobre una función Python con varias responsabilidades. El objetivo no será llegar a una versión perfecta de una sola vez, sino aprender a avanzar sin perder el control del comportamiento.
Durante el curso usaremos este ciclo:
Si una prueba falla, no seguimos acumulando cambios. Primero entendemos qué pasó y volvemos a un estado confiable.
Crea el archivo src/liquidacion.py con el siguiente código:
def liquidar_empleado(empleado):
bruto = empleado["horas"] * empleado["valor_hora"]
if empleado["categoria"] == "senior":
bruto = bruto + 50000
elif empleado["categoria"] == "semi":
bruto = bruto + 25000
if empleado["antiguedad"] >= 5:
bruto = bruto + bruto * 0.10
descuentos = bruto * 0.17
if empleado["obra_social"]:
descuentos = descuentos + 8000
neto = bruto - descuentos
if neto < 0:
neto = 0
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
La función calcula sueldo bruto, bonificaciones, descuentos y sueldo neto. Funciona, pero tiene varias reglas mezcladas.
Crea el archivo tests/test_liquidacion.py:
from liquidacion import liquidar_empleado
def test_liquida_empleado_senior_con_antiguedad_y_obra_social():
empleado = {
"legajo": "E-100",
"horas": 160,
"valor_hora": 2000,
"categoria": "senior",
"antiguedad": 6,
"obra_social": True,
}
assert liquidar_empleado(empleado) == {
"legajo": "E-100",
"bruto": 407000.0,
"descuentos": 77190.0,
"neto": 329810.0,
}
def test_liquida_empleado_junior_sin_adicionales():
empleado = {
"legajo": "E-200",
"horas": 100,
"valor_hora": 1500,
"categoria": "junior",
"antiguedad": 1,
"obra_social": False,
}
assert liquidar_empleado(empleado) == {
"legajo": "E-200",
"bruto": 150000,
"descuentos": 25500.0,
"neto": 124500.0,
}
Ejecuta las pruebas antes de tocar el código:
python -m pytest tests/test_liquidacion.py
El primer cambio será dar nombre a los valores importantes. Es un refactoring pequeño porque no cambia fórmulas ni ramas condicionales.
BONO_SENIOR = 50000
BONO_SEMI = 25000
PORCENTAJE_ANTIGUEDAD = 0.10
PORCENTAJE_DESCUENTOS = 0.17
COSTO_OBRA_SOCIAL = 8000
def liquidar_empleado(empleado):
bruto = empleado["horas"] * empleado["valor_hora"]
if empleado["categoria"] == "senior":
bruto = bruto + BONO_SENIOR
elif empleado["categoria"] == "semi":
bruto = bruto + BONO_SEMI
if empleado["antiguedad"] >= 5:
bruto = bruto + bruto * PORCENTAJE_ANTIGUEDAD
descuentos = bruto * PORCENTAJE_DESCUENTOS
if empleado["obra_social"]:
descuentos = descuentos + COSTO_OBRA_SOCIAL
neto = bruto - descuentos
if neto < 0:
neto = 0
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
Después del cambio ejecuta:
python -m pytest tests/test_liquidacion.py
Ahora podemos extraer la decisión del bono a una función separada. La función nueva recibe un dato simple y devuelve un número.
def obtener_bono_categoria(categoria):
if categoria == "senior":
return BONO_SENIOR
if categoria == "semi":
return BONO_SEMI
return 0
La función principal queda parcialmente más clara:
def liquidar_empleado(empleado):
bruto = empleado["horas"] * empleado["valor_hora"]
bruto = bruto + obtener_bono_categoria(empleado["categoria"])
if empleado["antiguedad"] >= 5:
bruto = bruto + bruto * PORCENTAJE_ANTIGUEDAD
descuentos = bruto * PORCENTAJE_DESCUENTOS
if empleado["obra_social"]:
descuentos = descuentos + COSTO_OBRA_SOCIAL
neto = bruto - descuentos
if neto < 0:
neto = 0
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
Ejecuta las pruebas. Si fallan, el error está en un cambio muy localizado.
El cálculo del bruto tiene suficiente significado como para darle nombre propio.
def calcular_sueldo_bruto(empleado):
bruto = empleado["horas"] * empleado["valor_hora"]
bruto = bruto + obtener_bono_categoria(empleado["categoria"])
if empleado["antiguedad"] >= 5:
bruto = bruto + bruto * PORCENTAJE_ANTIGUEDAD
return bruto
Ahora la función principal delega ese cálculo:
def liquidar_empleado(empleado):
bruto = calcular_sueldo_bruto(empleado)
descuentos = bruto * PORCENTAJE_DESCUENTOS
if empleado["obra_social"]:
descuentos = descuentos + COSTO_OBRA_SOCIAL
neto = bruto - descuentos
if neto < 0:
neto = 0
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
Vuelve a ejecutar python -m pytest. En refactoring, cada paso se cierra con verificación.
Aplicamos la misma idea al cálculo de descuentos.
def calcular_descuentos(bruto, tiene_obra_social):
descuentos = bruto * PORCENTAJE_DESCUENTOS
if tiene_obra_social:
descuentos = descuentos + COSTO_OBRA_SOCIAL
return descuentos
La función principal queda más cercana a una descripción del proceso:
def liquidar_empleado(empleado):
bruto = calcular_sueldo_bruto(empleado)
descuentos = calcular_descuentos(bruto, empleado["obra_social"])
neto = bruto - descuentos
if neto < 0:
neto = 0
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
La regla que impide devolver un neto negativo también puede tener nombre.
def calcular_sueldo_neto(bruto, descuentos):
neto = bruto - descuentos
if neto < 0:
return 0
return neto
Luego actualizamos la función principal:
def liquidar_empleado(empleado):
bruto = calcular_sueldo_bruto(empleado)
descuentos = calcular_descuentos(bruto, empleado["obra_social"])
neto = calcular_sueldo_neto(bruto, descuentos)
return {
"legajo": empleado["legajo"],
"bruto": round(bruto, 2),
"descuentos": round(descuentos, 2),
"neto": round(neto, 2),
}
Ejecuta las pruebas. El cambio es pequeño, pero aun así debe verificarse.
Cuando terminamos varios pasos pequeños, conviene ejecutar una verificación más amplia:
ruff check .
black --check .
python -m pytest
Si el proyecto también usa tipos, agrega:
mypy src
Estas herramientas no sustituyen el criterio de diseño, pero ayudan a detectar errores simples antes de continuar.
Si las pruebas pasan y el código quedó mejor, guarda un punto estable:
git status
git add src/liquidacion.py tests/test_liquidacion.py
git commit -m "Refactorizar liquidación en pasos pequeños"
Conviene confirmar cambios cuando el proyecto está verde. Un commit que pasa pruebas es un punto al que podemos volver con confianza.
Cuando una prueba falla durante un refactoring, evita seguir modificando código. Primero identifica si el error viene de:
Si no encuentras rápido el problema, vuelve al último paso pequeño que funcionaba y repite con un cambio más chico.
Este tipo de cambio es riesgoso durante un refactoring:
# Mala idea para un solo paso:
# - Cambiar nombres.
# - Extraer funciones.
# - Cambiar la estructura del diccionario.
# - Agregar validaciones.
# - Modificar los descuentos.
# - Ajustar pruebas al mismo tiempo.
Si algo falla después de un cambio así, tendremos demasiadas causas posibles. El ciclo de refactoring funciona mejor cuando cada paso tiene una intención única.
Extraer funciones es útil cuando el nombre mejora la comprensión o permite probar una regla aislada. Pero crear funciones para cada línea puede empeorar la lectura.
Antes de extraer, pregunta:
Crea el archivo src/reservas.py:
def calcular_reserva(reserva):
total = reserva["noches"] * reserva["precio_noche"]
if reserva["temporada"] == "alta":
total = total * 1.25
if reserva["cliente"] == "frecuente":
total = total * 0.9
if reserva["desayuno"]:
total = total + reserva["personas"] * reserva["noches"] * 3000
if reserva["total_pagado"] >= total:
estado = "pagada"
else:
estado = "pendiente"
return {"total": round(total, 2), "estado": estado}
Realiza estas tareas aplicando el ciclo de refactoring:
Una versión refactorizada podría empezar así:
RECARGO_TEMPORADA_ALTA = 1.25
DESCUENTO_CLIENTE_FRECUENTE = 0.9
COSTO_DESAYUNO_POR_PERSONA = 3000
def calcular_total_base(reserva):
return reserva["noches"] * reserva["precio_noche"]
def agregar_desayuno(total, reserva):
if reserva["desayuno"]:
return total + reserva["personas"] * reserva["noches"] * COSTO_DESAYUNO_POR_PERSONA
return total
def determinar_estado(total, total_pagado):
if total_pagado >= total:
return "pagada"
return "pendiente"
def calcular_reserva(reserva):
total = calcular_total_base(reserva)
if reserva["temporada"] == "alta":
total = total * RECARGO_TEMPORADA_ALTA
if reserva["cliente"] == "frecuente":
total = total * DESCUENTO_CLIENTE_FRECUENTE
total = agregar_desayuno(total, reserva)
estado = determinar_estado(total, reserva["total_pagado"])
return {"total": round(total, 2), "estado": estado}
Esta no tiene por qué ser la versión final. Lo importante es que cada paso pueda verificarse y justificarse.
Antes de continuar, verifica que puedes explicar estos puntos:
En este tema practicamos el ciclo central del refactoring: partir de pruebas verdes, aplicar una mejora pequeña, ejecutar pruebas y confirmar avances. Esta disciplina permite modificar código existente sin acumular incertidumbre.
En el próximo tema trabajaremos con una de las refactorizaciones más frecuentes y valiosas: renombrar variables, funciones y clases para expresar intención.