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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Conviene detenerse y revisar si durante el refactor ocurre algo de esto:
Un refactor seguro suele avanzar con pasos breves:
Este ritmo puede parecer lento, pero evita perder tiempo buscando en qué momento se rompió el comportamiento.
Partí del código inicial de src/descuentos.py y realizá el refactor en pasos.
python -m pytest y confirmá que la suite esté en verde.calcular a calcular_total_con_descuento.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.