4. Ciclo de refactoring: cambiar poco, ejecutar pruebas y confirmar avances

4.1 Objetivo del tema

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.

Objetivo práctico: aplicar varios refactorings pequeños y verificar cada paso con pruebas automatizadas.

4.2 El ciclo básico

Durante el curso usaremos este ciclo:

  • Verde: ejecutar las pruebas y confirmar que el punto de partida funciona.
  • Cambio pequeño: aplicar una modificación estructural acotada.
  • Verificación: ejecutar pruebas y herramientas de revisión.
  • Confirmación: guardar el avance si el comportamiento se mantiene.

Si una prueba falla, no seguimos acumulando cambios. Primero entendemos qué pasó y volvemos a un estado confiable.

4.3 Código inicial

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.

4.4 Pruebas iniciales

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

4.5 Paso 1: extraer constantes

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

4.6 Paso 2: extraer el bono por categoría

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.

4.7 Paso 3: extraer sueldo bruto

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.

4.8 Paso 4: extraer descuentos

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),
    }

4.9 Paso 5: extraer sueldo neto

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.

4.10 Ejecutar una verificación más completa

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.

4.11 Confirmar avances con Git

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.

4.12 Qué hacer si una prueba falla

Cuando una prueba falla durante un refactoring, evita seguir modificando código. Primero identifica si el error viene de:

  • Un cambio accidental de comportamiento.
  • Una prueba que dependía de un detalle interno innecesario.
  • Un valor esperado mal calculado.
  • Una refactorización demasiado grande para revisar fácilmente.

Si no encuentras rápido el problema, vuelve al último paso pequeño que funcionaba y repite con un cambio más chico.

4.13 Un cambio demasiado grande

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.

4.14 Refactoring no es avanzar siempre hacia más funciones

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:

  • ¿El nuevo nombre explica una intención real?
  • ¿La función extraída tiene una responsabilidad clara?
  • ¿El código principal queda más fácil de leer?
  • ¿La nueva función evita duplicación o reduce complejidad?

4.15 Ejercicio propuesto

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:

  • Escribe al menos dos pruebas antes de modificar el código.
  • Ejecuta las pruebas y confirma que pasan.
  • Extrae constantes para porcentajes y costo de desayuno.
  • Extrae una función para calcular el total base.
  • Extrae una función para determinar el estado de la reserva.
  • Ejecuta pruebas después de cada paso.

4.16 Una posible solución parcial

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.

4.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Por qué las pruebas deben estar verdes antes de empezar.
  • Qué significa hacer un cambio estructural pequeño.
  • Por qué ejecutar pruebas después de cada paso reduce el riesgo.
  • Cuándo conviene confirmar avances en Git.
  • Qué hacer cuando una prueba falla durante el refactoring.
  • Por qué más funciones no siempre significan mejor diseño.

4.18 Conclusión

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.