1. Qué es refactoring y cuándo conviene aplicarlo

1.1 Objetivo del tema

Refactoring, o refactorización, es modificar la estructura interna del código sin cambiar su comportamiento externo. El usuario del programa debería obtener los mismos resultados antes y después del cambio, pero el código debería quedar más fácil de entender, modificar y probar.

En este primer tema vamos a distinguir refactoring de corrección de errores y de agregado de funcionalidad. También haremos una práctica pequeña en Python para observar el ciclo básico: partir de código que funciona, cubrir su comportamiento con pruebas simples, aplicar cambios pequeños y verificar que todo sigue funcionando.

Objetivo práctico: mejorar un fragmento de código Python sin cambiar el resultado que entrega.

1.2 Qué es refactoring

Un refactoring no busca que el programa haga algo nuevo. Busca que el código quede en mejores condiciones para el trabajo futuro. Por ejemplo, podemos renombrar variables, extraer funciones, simplificar condicionales, mover responsabilidades o separar entrada y salida de la lógica de negocio.

La idea central es conservar el comportamiento observable. Si una función recibía ciertos datos y devolvía cierto resultado, después del refactoring debe seguir devolviendo ese mismo resultado para esos mismos datos.

Refactorizar no es reescribir desde cero. Refactorizar es cambiar el diseño interno en pasos pequeños, manteniendo el programa funcionando.

1.3 Qué no es refactoring

Conviene separar tres actividades que muchas veces se mezclan:

  • Corregir un bug: cambia un comportamiento incorrecto por uno correcto.
  • Agregar una funcionalidad: incorpora un comportamiento nuevo al sistema.
  • Refactorizar: conserva el comportamiento y mejora la estructura del código.

En un proyecto real se pueden hacer las tres cosas durante una misma jornada, pero no conviene mezclarlas en el mismo cambio. Si cambiamos la estructura y el comportamiento al mismo tiempo, cuando algo falla será más difícil encontrar la causa.

1.4 Cuándo conviene refactorizar

El refactoring tiene más valor cuando reduce el costo de un cambio próximo. No se trata de embellecer código por gusto, sino de preparar el código para trabajar con menos riesgo.

Algunas situaciones típicas son:

  • Antes de agregar una funcionalidad en una zona confusa del proyecto.
  • Después de hacer funcionar una solución inicial y antes de dejarla como definitiva.
  • Cuando una prueba es difícil de escribir porque la lógica está mezclada con archivos, consola, red o base de datos.
  • Cuando una función acumula demasiadas reglas y cada modificación produce errores laterales.
  • Cuando aparece duplicación y una misma regla debe cambiarse en varios lugares.

1.5 Cuándo no conviene refactorizar todavía

No todo código incómodo debe refactorizarse de inmediato. A veces es mejor esperar o hacer un cambio más pequeño.

  • Si no entendemos qué comportamiento debe conservarse.
  • Si no tenemos forma de comprobar rápidamente que el programa sigue funcionando.
  • Si hay una entrega urgente y el cambio no reduce un riesgo inmediato.
  • Si el código será eliminado pronto y no afecta el trabajo actual.
  • Si estamos intentando cambiar diseño y comportamiento en el mismo paso.

En esos casos, el primer trabajo suele ser crear una red de seguridad: pruebas, ejemplos ejecutables o verificaciones manuales claras.

1.6 Código inicial

Vamos a trabajar con una función que calcula el total de una compra. El código funciona, pero mezcla varias decisiones en un solo bloque y usa nombres poco expresivos.

def calc(items, tipo):
    total = 0
    for i in items:
        total += i["p"] * i["q"]

    if tipo == "vip":
        total = total - total * 0.15
    elif tipo == "regular":
        total = total - total * 0.05

    total = total + total * 0.21

    if total < 10000:
        total = total + 1200

    return total

Antes de modificarlo, necesitamos saber qué comportamiento debemos conservar.

1.7 Probar el comportamiento actual

Crea un archivo llamado test_refactoring_tema1.py. En este primer ejemplo usaremos pruebas simples con pytest para fijar el comportamiento actual.

from compra import calc


def test_calcula_total_para_cliente_vip_con_envio():
    items = [
        {"p": 3000, "q": 2},
        {"p": 1500, "q": 1},
    ]

    assert calc(items, "vip") == 8913.75


def test_calcula_total_para_cliente_sin_descuento_y_sin_envio():
    items = [
        {"p": 6000, "q": 2},
    ]

    assert calc(items, "nuevo") == 14520

Estas pruebas no dicen que el diseño sea bueno. Solo documentan el resultado actual para que podamos cambiar la estructura con más seguridad.

1.8 Ejecutar las pruebas

Guarda la función original en un archivo llamado compra.py y ejecuta:

pytest test_refactoring_tema1.py

Si las pruebas pasan, tenemos una primera red de seguridad. Todavía es pequeña, pero alcanza para practicar el ciclo de refactoring.

1.9 Primer refactoring: renombrar para expresar intención

El primer cambio será renombrar la función, los parámetros y las variables. No vamos a cambiar la fórmula ni los datos de entrada.

def calcular_total(items, tipo_cliente):
    total = 0
    for item in items:
        total += item["p"] * item["q"]

    if tipo_cliente == "vip":
        total = total - total * 0.15
    elif tipo_cliente == "regular":
        total = total - total * 0.05

    total = total + total * 0.21

    if total < 10000:
        total = total + 1200

    return total

Después de este cambio, las pruebas deberían adaptarse para llamar a calcular_total. El comportamiento esperado sigue siendo el mismo.

1.10 Segundo refactoring: extraer constantes

Los números importantes del dominio no deberían quedar escondidos dentro de una función. Podemos darles nombre sin alterar el cálculo.

IVA = 0.21
DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05
LIMITE_ENVIO = 10000
COSTO_ENVIO = 1200


def calcular_total(items, tipo_cliente):
    total = 0
    for item in items:
        total += item["p"] * item["q"]

    if tipo_cliente == "vip":
        total = total - total * DESCUENTO_VIP
    elif tipo_cliente == "regular":
        total = total - total * DESCUENTO_REGULAR

    total = total + total * IVA

    if total < LIMITE_ENVIO:
        total = total + COSTO_ENVIO

    return total

Ejecuta las pruebas después de este paso. En refactoring, cada mejora pequeña debería terminar con una verificación.

1.11 Tercer refactoring: extraer funciones

Ahora podemos separar partes del cálculo. El objetivo no es crear muchas funciones por costumbre, sino poner nombres a decisiones relevantes.

IVA = 0.21
DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05
LIMITE_ENVIO = 10000
COSTO_ENVIO = 1200


def calcular_subtotal(items):
    total = 0
    for item in items:
        total += item["p"] * item["q"]
    return total


def obtener_descuento(tipo_cliente):
    if tipo_cliente == "vip":
        return DESCUENTO_VIP
    if tipo_cliente == "regular":
        return DESCUENTO_REGULAR
    return 0


def aplicar_envio(total):
    if total < LIMITE_ENVIO:
        return total + COSTO_ENVIO
    return total


def calcular_total(items, tipo_cliente):
    subtotal = calcular_subtotal(items)
    descuento = obtener_descuento(tipo_cliente)
    total_con_descuento = subtotal * (1 - descuento)
    total_con_iva = total_con_descuento * (1 + IVA)
    return aplicar_envio(total_con_iva)

La función principal ahora cuenta la historia del cálculo: subtotal, descuento, impuesto y envío. Las pruebas deben seguir pasando.

1.12 Refactoring y cambios de interfaz

En el ejemplo anterior conservamos las claves "p" y "q" para no cambiar el formato de entrada. Cambiar esas claves por "precio" y "cantidad" podría ser una mejora, pero ya afecta el contrato con quien llama a la función.

Cuando un cambio modifica la forma de usar una función, conviene tratarlo con más cuidado. Puede seguir siendo parte de una mejora del diseño, pero ya no es un refactoring puramente interno si rompe llamadas existentes.

1.13 La regla de oro: un paso por vez

Un buen hábito de refactoring es avanzar en pasos pequeños:

  • Ejecutar las pruebas y confirmar que el punto de partida funciona.
  • Hacer un cambio estructural pequeño.
  • Ejecutar las pruebas otra vez.
  • Repetir el ciclo hasta que el diseño quede suficientemente claro.

Si algo falla, el último cambio fue pequeño y será más fácil entender qué ocurrió.

1.14 Ejercicio guiado

Analiza la siguiente función. Primero escribe qué resultado esperas para dos casos distintos. Luego refactoriza sin cambiar esos resultados.

def procesar(usuario):
    r = 0
    if usuario["activo"]:
        r = r + 100
    if usuario["compras"] > 10:
        r = r + 50
    if usuario["pais"] == "AR":
        r = r + 20
    return r

Una posible primera mejora consiste en dar nombres a la función, a la variable acumuladora y a las reglas de puntaje.

PUNTOS_USUARIO_ACTIVO = 100
PUNTOS_CLIENTE_FRECUENTE = 50
PUNTOS_PAIS_LOCAL = 20
MINIMO_COMPRAS_FRECUENTES = 10
PAIS_LOCAL = "AR"


def calcular_puntaje_usuario(usuario):
    puntaje = 0

    if usuario["activo"]:
        puntaje += PUNTOS_USUARIO_ACTIVO
    if usuario["compras"] > MINIMO_COMPRAS_FRECUENTES:
        puntaje += PUNTOS_CLIENTE_FRECUENTE
    if usuario["pais"] == PAIS_LOCAL:
        puntaje += PUNTOS_PAIS_LOCAL

    return puntaje

1.15 Ejercicio propuesto

Crea un archivo llamado ejercicio_tema1.py con este código:

def f(pedido):
    t = 0
    for x in pedido["items"]:
        t = t + x["precio"] * x["cantidad"]
    if pedido["cliente"] == "premium":
        t = t * 0.9
    if pedido["urgente"]:
        t = t + 1500
    return t

Realiza estas tareas:

  • Escribe al menos dos pruebas que documenten el comportamiento actual.
  • Renombra la función y las variables para expresar intención.
  • Extrae constantes para el descuento y el costo urgente.
  • Extrae una función para calcular el subtotal.
  • Ejecuta las pruebas después de cada cambio importante.

1.16 Una posible solución

Una solución posible, manteniendo el mismo formato de datos, es la siguiente:

DESCUENTO_PREMIUM = 0.10
COSTO_PEDIDO_URGENTE = 1500


def calcular_subtotal(items):
    subtotal = 0
    for item in items:
        subtotal += item["precio"] * item["cantidad"]
    return subtotal


def aplicar_descuento_cliente(total, tipo_cliente):
    if tipo_cliente == "premium":
        return total * (1 - DESCUENTO_PREMIUM)
    return total


def aplicar_costo_urgente(total, es_urgente):
    if es_urgente:
        return total + COSTO_PEDIDO_URGENTE
    return total


def calcular_total_pedido(pedido):
    subtotal = calcular_subtotal(pedido["items"])
    total_con_descuento = aplicar_descuento_cliente(subtotal, pedido["cliente"])
    return aplicar_costo_urgente(total_con_descuento, pedido["urgente"])

Esta versión no agrega nuevas reglas de negocio. Solo hace que las reglas actuales sean más visibles y más fáciles de probar por separado.

1.17 Lista de verificación

Antes de continuar con el próximo tema, verifica que puedes explicar estos puntos:

  • Qué significa cambiar la estructura interna sin cambiar el comportamiento externo.
  • La diferencia entre refactorizar, corregir un bug y agregar una funcionalidad.
  • Por qué las pruebas ayudan a refactorizar con menos riesgo.
  • Cuándo conviene refactorizar y cuándo es mejor esperar.
  • Por qué es importante ejecutar pruebas después de cada paso pequeño.
  • Qué mejoras se lograron al renombrar, extraer constantes y extraer funciones.

1.18 Conclusión

En este tema vimos que refactoring no significa cambiar lo que el programa hace, sino cómo está organizado internamente. También practicamos un ciclo básico en Python: escribir pruebas sobre el comportamiento actual, aplicar una mejora pequeña y verificar que el resultado se mantiene.

A partir del próximo tema prepararemos un proyecto de práctica para refactorizar de forma más ordenada, con estructura de archivos, pruebas automatizadas y herramientas que nos permitan avanzar con confianza.