28. Mejoras pequeñas y seguras guiadas por pruebas existentes

28.1 Objetivo del tema

Mejorar código con code smells no significa reescribir todo. La forma más segura suele ser avanzar con cambios pequeños, ejecutar pruebas con frecuencia y separar claramente cambios de comportamiento de cambios de estructura.

En este tema veremos un flujo práctico para mejorar código existente usando pruebas como red de seguridad.

Objetivo práctico: aplicar mejoras pequeñas sobre código Python, verificando cada paso con pruebas existentes y herramientas de calidad.

28.2 Qué significa una mejora segura

Una mejora segura es un cambio que reduce complejidad, duplicación o confusión sin alterar el comportamiento esperado. Para lograrlo, necesitamos saber cuál es el comportamiento actual y comprobarlo después de cada modificación.

Ejemplos de mejoras seguras:

  • Renombrar una variable para expresar intención.
  • Extraer una función sin cambiar lógica.
  • Reemplazar valores mágicos por constantes.
  • Separar cálculo de salida por consola.
  • Eliminar código muerto confirmado.

28.3 Regla base: pruebas primero

Antes de tocar código, ejecuta las pruebas:

python -m pytest

Si las pruebas fallan antes de modificar, no tienes una base confiable. Primero debes entender si el fallo es conocido, si el entorno está mal o si el proyecto ya estaba roto.

28.4 Si no hay pruebas suficientes

Cuando el código no tiene pruebas, agrega pruebas de caracterización para capturar el comportamiento actual.

def test_calcular_total_actual():
    productos = [
        {"precio": 3000, "cantidad": 2},
        {"precio": 1500, "cantidad": 1},
    ]

    assert calcular_total_venta(productos, "vip", "AR") == 9213.75

Estas pruebas no necesariamente afirman que el diseño sea bueno. Sirven para evitar cambios accidentales.

28.5 Un cambio por vez

Evita mezclar muchos cambios en una sola edición. Por ejemplo, no conviene renombrar variables, extraer funciones, cambiar reglas y modificar formato al mismo tiempo.

Un flujo más seguro:

  • Ejecutar pruebas.
  • Hacer un cambio pequeño.
  • Ejecutar pruebas.
  • Revisar el diff.
  • Continuar con el siguiente cambio.

28.6 Ejemplo inicial

Partimos de una función con varios smells:

def proc(items, c, p):
    t = 0
    for x in items:
        t += x["precio"] * x["cantidad"]
    if c == "vip":
        t *= 0.85
    if p == "AR":
        t *= 1.21
    if t < 10000:
        t += 1500
    return round(t, 2)

Antes de mejorarla, agregamos o ejecutamos pruebas que protejan casos importantes.

28.7 Paso 1: renombrar

El primer cambio puede ser solo de nombres:

def calcular_total_venta(productos, tipo_cliente, pais):
    total = 0
    for producto in productos:
        total += producto["precio"] * producto["cantidad"]
    if tipo_cliente == "vip":
        total *= 0.85
    if pais == "AR":
        total *= 1.21
    if total < 10000:
        total += 1500
    return round(total, 2)

Después ejecuta:

python -m pytest

28.8 Paso 2: constantes

Luego extraemos valores mágicos:

DESCUENTO_VIP = 0.15
IVA_ARGENTINA = 0.21
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def calcular_total_venta(productos, tipo_cliente, pais):
    total = 0
    for producto in productos:
        total += producto["precio"] * producto["cantidad"]
    if tipo_cliente == "vip":
        total *= 1 - DESCUENTO_VIP
    if pais == "AR":
        total *= 1 + IVA_ARGENTINA
    if total < LIMITE_ENVIO_GRATIS:
        total += COSTO_ENVIO
    return round(total, 2)

Otra vez, ejecuta las pruebas. Si fallan, el cambio no fue equivalente.

28.9 Paso 3: extraer subtotal

Extraemos una función pequeña:

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


def calcular_total_venta(productos, tipo_cliente, pais):
    total = calcular_subtotal(productos)
    if tipo_cliente == "vip":
        total *= 1 - DESCUENTO_VIP
    if pais == "AR":
        total *= 1 + IVA_ARGENTINA
    if total < LIMITE_ENVIO_GRATIS:
        total += COSTO_ENVIO
    return round(total, 2)

Podemos agregar una prueba específica para calcular_subtotal.

28.10 Paso 4: extraer reglas

def aplicar_descuento(total, tipo_cliente):
    if tipo_cliente == "vip":
        return total * (1 - DESCUENTO_VIP)
    return total


def aplicar_impuesto(total, pais):
    if pais == "AR":
        return total * (1 + IVA_ARGENTINA)
    return total


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

La función principal queda como una secuencia:

def calcular_total_venta(productos, tipo_cliente, pais):
    total = calcular_subtotal(productos)
    total = aplicar_descuento(total, tipo_cliente)
    total = aplicar_impuesto(total, pais)
    total = aplicar_envio(total)
    return round(total, 2)

28.11 Verificar con herramientas

Después de una serie de mejoras, ejecuta:

python -m ruff check src tests
python -m mypy src
python -m pytest

Si usas formateadores:

python -m isort src tests
python -m black src tests

28.12 Revisar el diff

Una mejora segura debería tener un diff fácil de explicar. Si el diff mezcla formato, renombres, cambios de reglas y nuevas funciones, será más difícil de revisar.

Buenas preguntas:

  • ¿Qué comportamiento debía mantenerse?
  • ¿Qué smell se redujo?
  • ¿Qué prueba protege el cambio?
  • ¿Hay cambios de formato mezclados con cambios de lógica?

28.13 Señales de que el cambio es demasiado grande

Conviene detenerse si:

  • Las pruebas fallan y no sabes qué cambio lo causó.
  • Modificaste muchas responsabilidades al mismo tiempo.
  • El cambio requiere explicar demasiadas excepciones.
  • Empezaste a cambiar comportamiento sin decidirlo.
  • El código quedó más abstracto pero no más claro.

28.14 Cambios de comportamiento separados

Si descubres que una regla está mal, sepárala de la mejora estructural. Primero mejora sin cambiar comportamiento; luego haz otro cambio explícito para corregir la regla.

Separar refactorización de cambio funcional facilita revisar, probar y revertir si algo sale mal.

28.15 Aplicación sobre ventas_demo

En ventas_demo, elige una función con smells y aplica este ciclo:

python -m pytest
# hacer un cambio pequeño
python -m pytest
python -m ruff check src tests

Registra en una nota qué smell corregiste y qué prueba protege el comportamiento.

28.16 Ejercicio guiado

Mejora esta función en tres pasos pequeños:

def f(items):
    s = 0
    for x in items:
        if x["ok"]:
            s += x["p"] * x["q"]
    if s > 5000:
        s -= 300
    return s

Pasos sugeridos:

  • Renombrar función y variables.
  • Extraer constantes.
  • Extraer cálculo de subtotal aprobado.

28.17 Una posible mejora

LIMITE_DESCUENTO = 5000
DESCUENTO = 300


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


def aplicar_descuento(total):
    if total > LIMITE_DESCUENTO:
        return total - DESCUENTO
    return total


def calcular_total_aprobado(items):
    subtotal = calcular_subtotal_aprobado(items)
    return aplicar_descuento(subtotal)

28.18 Ejercicio propuesto

En ventas_demo, realiza una mejora pequeña y segura:

  • Ejecuta pruebas antes de cambiar.
  • Elige un smell específico.
  • Haz un cambio de bajo riesgo.
  • Ejecuta pruebas después.
  • Escribe una explicación de dos líneas: qué cambió y qué prueba lo cubre.
python -m pytest
python -m ruff check src tests

28.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Ejecutar pruebas antes de modificar.
  • Agregar pruebas de caracterización cuando faltan pruebas.
  • Hacer un cambio pequeño por vez.
  • Separar cambios de estructura de cambios de comportamiento.
  • Revisar el diff con criterio.
  • Usar herramientas de calidad después de mejorar código.

28.20 Conclusión

En este tema vimos que mejorar código con seguridad requiere ritmo y disciplina: pruebas primero, cambios pequeños, verificación frecuente y revisión del diff.

En el próximo tema estudiaremos métricas de mantenibilidad y límites razonables para un proyecto Python.