20. Refactorizar nombres, estructura y duplicación manteniendo todas las pruebas en verde

20.1 Objetivo del tema

En este tema vamos a practicar la etapa de refactor dentro de TDD. El objetivo no es agregar comportamiento nuevo, sino mejorar nombres, estructura y duplicación sin cambiar lo que el sistema hace.

Para trabajar con seguridad, partimos de una suite en verde, hacemos cambios pequeños y ejecutamos las pruebas con frecuencia.

20.2 Qué significa refactorizar

Refactorizar es cambiar la forma interna del código sin modificar su comportamiento observable. Si una prueba que describe una regla del dominio empieza a fallar, ya no estamos solo refactorizando: probablemente cambiamos comportamiento.

En TDD, el refactor ocurre después del verde. Primero logramos que la prueba pase; luego mejoramos el diseño manteniendo la suite en verde.

20.3 Punto de partida

Supongamos que tenemos una calculadora de descuentos que ya pasa sus pruebas, pero el código empieza a ser difícil de leer.

Archivo existente: src/descuentos.py

def calcular(total, tipo_cliente, cupon):
    descuento = 0

    if tipo_cliente == "frecuente":
        descuento = descuento + total * 0.10

    if tipo_cliente == "premium":
        descuento = descuento + total * 0.20

    if cupon == "BIENVENIDA":
        descuento = descuento + 15

    if descuento > total:
        descuento = total

    return total - descuento

El código funciona, pero el nombre calcular no dice qué calcula y la lógica de descuentos empieza a mezclarse.

20.4 Pruebas existentes

Antes de refactorizar, necesitamos una suite que describa el comportamiento actual.

Archivo existente: tests/test_descuentos.py

from descuentos import calcular


def test_cliente_frecuente_recibe_diez_por_ciento_de_descuento():
    total_final = calcular(100, "frecuente", None)

    assert total_final == 90


def test_cliente_premium_recibe_veinte_por_ciento_de_descuento():
    total_final = calcular(100, "premium", None)

    assert total_final == 80


def test_cupon_bienvenida_resta_quince_pesos():
    total_final = calcular(100, "nuevo", "BIENVENIDA")

    assert total_final == 85


def test_descuento_no_puede_superar_el_total():
    total_final = calcular(10, "premium", "BIENVENIDA")

    assert total_final == 0

Ejecutamos python -m pytest. Si todo está en verde, recién ahí empezamos a refactorizar.

20.5 Primer refactor: mejorar el nombre

El nombre calcular es demasiado amplio. Podemos cambiarlo por calcular_total_con_descuento, que expresa mejor el resultado.

Archivo a modificar: src/descuentos.py

def calcular_total_con_descuento(total, tipo_cliente, cupon):
    descuento = 0

    if tipo_cliente == "frecuente":
        descuento = descuento + total * 0.10

    if tipo_cliente == "premium":
        descuento = descuento + total * 0.20

    if cupon == "BIENVENIDA":
        descuento = descuento + 15

    if descuento > total:
        descuento = total

    return total - descuento

Luego actualizamos las pruebas para importar el nuevo nombre.

20.6 Actualizar las pruebas sin cambiar intención

El cambio de nombre no debería modificar ninguna regla. Las pruebas siguen describiendo los mismos casos.

Archivo a modificar: tests/test_descuentos.py

from descuentos import calcular_total_con_descuento


def test_cliente_frecuente_recibe_diez_por_ciento_de_descuento():
    total_final = calcular_total_con_descuento(100, "frecuente", None)

    assert total_final == 90

Después de este cambio, ejecutamos python -m pytest. Un refactor pequeño se verifica rápido.

20.7 Mantener compatibilidad cuando conviene

En un proyecto real, si otras partes del código todavía usan el nombre anterior, podemos dejar una función delegadora temporal.

Archivo a modificar: src/descuentos.py

def calcular(total, tipo_cliente, cupon):
    return calcular_total_con_descuento(total, tipo_cliente, cupon)

Esta técnica permite refactorizar por pasos. Luego, cuando todos los llamados usen el nuevo nombre, se puede eliminar la función vieja.

20.8 Segundo refactor: extraer una regla

La regla del descuento por tipo de cliente puede tener su propia función. No cambia el comportamiento, solo mejora la estructura.

Archivo a modificar: src/descuentos.py

def calcular_descuento_por_cliente(total, tipo_cliente):
    if tipo_cliente == "frecuente":
        return total * 0.10

    if tipo_cliente == "premium":
        return total * 0.20

    return 0

Esta función tiene un nombre que habla del dominio. Todavía no agregamos nuevos tipos de cliente.

20.9 Integrar la función extraída

Usamos la función dentro del cálculo principal.

Archivo a modificar: src/descuentos.py

def calcular_total_con_descuento(total, tipo_cliente, cupon):
    descuento = calcular_descuento_por_cliente(total, tipo_cliente)

    if cupon == "BIENVENIDA":
        descuento = descuento + 15

    if descuento > total:
        descuento = total

    return total - descuento

Ejecutamos las pruebas. Si todo sigue en verde, el refactor fue correcto.

20.10 Tercer refactor: eliminar duplicación

Los porcentajes pueden expresarse en una estructura de datos. Esto evita repetir bloques parecidos y deja más claro qué tipos de cliente tienen descuento.

Archivo a modificar: src/descuentos.py

PORCENTAJES_POR_CLIENTE = {
    "frecuente": 0.10,
    "premium": 0.20,
}


def calcular_descuento_por_cliente(total, tipo_cliente):
    porcentaje = PORCENTAJES_POR_CLIENTE.get(tipo_cliente, 0)

    return total * porcentaje

La suite debería seguir en verde. Todavía no cambiamos ninguna regla de negocio.

20.11 Extraer el descuento por cupón

La regla del cupón también puede separarse para que el cálculo principal sea más expresivo.

Archivo a modificar: src/descuentos.py

def calcular_descuento_por_cupon(cupon):
    if cupon == "BIENVENIDA":
        return 15

    return 0

Esta función queda aislada y puede cambiar en el futuro sin mezclar su lógica con otros descuentos.

20.12 Código principal después del refactor

El cálculo principal ahora coordina reglas más pequeñas.

Archivo a modificar: src/descuentos.py

def calcular_total_con_descuento(total, tipo_cliente, cupon):
    descuento = (
        calcular_descuento_por_cliente(total, tipo_cliente)
        + calcular_descuento_por_cupon(cupon)
    )

    descuento = limitar_descuento(descuento, total)

    return total - descuento

Todavía falta extraer limitar_descuento, porque esa regla también tiene un nombre propio.

20.13 Extraer una regla de límite

La regla dice que el descuento no puede superar el total. Le damos un nombre.

Archivo a modificar: src/descuentos.py

def limitar_descuento(descuento, total):
    if descuento > total:
        return total

    return descuento

Este cambio no agrega comportamiento nuevo. Solo hace explícita una regla que ya existía.

20.14 Código completo refactorizado

El archivo queda más legible y las reglas están separadas.

Archivo a modificar: src/descuentos.py

PORCENTAJES_POR_CLIENTE = {
    "frecuente": 0.10,
    "premium": 0.20,
}


def calcular_total_con_descuento(total, tipo_cliente, cupon):
    descuento = (
        calcular_descuento_por_cliente(total, tipo_cliente)
        + calcular_descuento_por_cupon(cupon)
    )

    descuento = limitar_descuento(descuento, total)

    return total - descuento


def calcular_descuento_por_cliente(total, tipo_cliente):
    porcentaje = PORCENTAJES_POR_CLIENTE.get(tipo_cliente, 0)

    return total * porcentaje


def calcular_descuento_por_cupon(cupon):
    if cupon == "BIENVENIDA":
        return 15

    return 0


def limitar_descuento(descuento, total):
    if descuento > total:
        return total

    return descuento

Después de este cambio, volvemos a ejecutar python -m pytest.

20.15 Refactorizar también las pruebas

Las pruebas también pueden mejorar. Si se repite mucho el llamado a la misma función, podemos usar una pequeña función auxiliar dentro del archivo de pruebas.

Archivo a modificar: tests/test_descuentos.py

from descuentos import calcular_total_con_descuento


def total_con_descuento(total, tipo_cliente="nuevo", cupon=None):
    return calcular_total_con_descuento(total, tipo_cliente, cupon)


def test_cliente_frecuente_recibe_diez_por_ciento_de_descuento():
    assert total_con_descuento(100, tipo_cliente="frecuente") == 90


def test_cliente_premium_recibe_veinte_por_ciento_de_descuento():
    assert total_con_descuento(100, tipo_cliente="premium") == 80

Esta ayuda es útil si mejora la lectura. Si oculta demasiado, conviene dejar el llamado explícito.

20.16 Señales de un buen refactor

  • Las pruebas siguen pasando.
  • Los nombres explican mejor la intención.
  • La duplicación disminuye sin crear abstracciones confusas.
  • Las reglas del dominio quedan más visibles.
  • El cambio puede revisarse en pasos pequeños.

20.17 Señales de riesgo

Conviene detenerse y revisar si durante el refactor ocurre algo de esto:

  • Se agregan casos nuevos sin pruebas rojas previas.
  • Se cambian valores esperados para que las pruebas pasen.
  • El refactor modifica varias responsabilidades a la vez.
  • Las pruebas fallan y no queda claro qué comportamiento cambió.
  • La nueva estructura es más difícil de explicar que la anterior.

20.18 Regla práctica: pasos pequeños

Un refactor seguro suele avanzar con pasos breves:

  1. Cambiar un nombre.
  2. Ejecutar las pruebas.
  3. Extraer una función.
  4. Ejecutar las pruebas.
  5. Eliminar una duplicación.
  6. Ejecutar las pruebas.

Este ritmo puede parecer lento, pero evita perder tiempo buscando en qué momento se rompió el comportamiento.

20.19 Ejercicio práctico

Partí del código inicial de src/descuentos.py y realizá el refactor en pasos.

  1. Ejecutá python -m pytest y confirmá que la suite esté en verde.
  2. Renombrá calcular a calcular_total_con_descuento.
  3. Extraé la regla de descuento por cliente.
  4. Extraé la regla de descuento por cupón.
  5. Extraé la regla de límite del descuento.
  6. Después de cada paso, ejecutá nuevamente las pruebas.

20.20 Checklist del tema

  • El refactor empieza con la suite en verde.
  • No se agregan reglas nuevas durante el refactor.
  • Los nombres nuevos expresan mejor el dominio.
  • La duplicación se elimina cuando mejora la claridad.
  • Las pruebas se ejecutan después de cada cambio importante.

20.21 Conclusión

Refactorizar en TDD es una actividad disciplinada: no consiste en reescribir por gusto, sino en mejorar el diseño protegido por pruebas. La suite en verde permite cambiar nombres, separar reglas y eliminar duplicación con confianza.

En el próximo tema compararemos dos formas de iniciar el diseño con TDD: outside-in e inside-out.