En este tema veremos cómo aplicar TDD cuando el código ya existe y no tiene pruebas. Antes de cambiar comportamiento, necesitamos entender y proteger lo que el sistema hace hoy.
Para eso usaremos pruebas de caracterización: pruebas que describen el comportamiento actual, incluso cuando el diseño interno todavía no es bueno.
Una prueba de caracterización captura cómo se comporta el código existente. No necesariamente afirma que ese comportamiento sea ideal; afirma que ese es el comportamiento actual.
Supongamos que encontramos esta función en producción. Calcula el total de una factura, pero mezcla reglas, descuentos, recargos y redondeo.
Archivo existente: src/facturacion.py
def calcular_total(cliente, items, envio):
total = 0
for item in items:
total += item["precio"] * item["cantidad"]
if cliente["tipo"] == "premium":
total = total * 0.90
if envio == "express":
total += 500
else:
total += 200
if total > 10000:
total = total * 0.95
return round(total, 2)
Antes de refactorizar esta función, necesitamos pruebas que nos avisen si rompemos algo.
Elegimos un caso simple y escribimos lo que la función devuelve actualmente.
Archivo a crear: tests/test_facturacion_caracterizacion.py
from facturacion import calcular_total
def test_cliente_normal_con_envio_estandar():
cliente = {"tipo": "normal"}
items = [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
]
total = calcular_total(cliente, items, envio="estandar")
assert total == 2700
Ejecutamos python -m pytest. Si pasa, ya tenemos un primer punto de seguridad.
Agregamos un caso que cubra el descuento por tipo de cliente.
Archivo a modificar: tests/test_facturacion_caracterizacion.py
def test_cliente_premium_recibe_descuento():
cliente = {"tipo": "premium"}
items = [
{"precio": 1000, "cantidad": 2},
]
total = calcular_total(cliente, items, envio="estandar")
assert total == 2000
El subtotal es 2000, el descuento premium deja 1800 y el envío estándar suma 200.
Cubrimos otro camino de la función.
Archivo a modificar: tests/test_facturacion_caracterizacion.py
def test_envio_express_agrega_recargo():
cliente = {"tipo": "normal"}
items = [
{"precio": 1000, "cantidad": 1},
]
total = calcular_total(cliente, items, envio="express")
assert total == 1500
Esta prueba protege el recargo de envío express.
Cubrimos el descuento que aparece cuando el total supera 10000.
Archivo a modificar: tests/test_facturacion_caracterizacion.py
def test_total_mayor_a_diez_mil_recibe_descuento_adicional():
cliente = {"tipo": "normal"}
items = [
{"precio": 10000, "cantidad": 1},
]
total = calcular_total(cliente, items, envio="express")
assert total == 9975
El subtotal 10000 suma envío express 500. Luego el total 10500 recibe 5% de descuento y queda 9975.
Tal vez pensemos que el descuento por monto alto debería aplicarse antes del envío. Puede ser cierto, pero si el sistema actual aplica el descuento después del envío, primero lo caracterizamos.
Con varias pruebas pasando, podemos mejorar la estructura sin cambiar comportamiento.
Archivo a modificar: src/facturacion.py
def calcular_total(cliente, items, envio):
total = calcular_subtotal(items)
if cliente["tipo"] == "premium":
total = total * 0.90
if envio == "express":
total += 500
else:
total += 200
if total > 10000:
total = total * 0.95
return round(total, 2)
def calcular_subtotal(items):
total = 0
for item in items:
total += item["precio"] * item["cantidad"]
return total
Ejecutamos toda la suite. Si las pruebas pasan, el refactor mantuvo el comportamiento.
Seguimos con pasos pequeños.
Archivo a modificar: src/facturacion.py
def calcular_total(cliente, items, envio):
total = calcular_subtotal(items)
total = aplicar_descuento_cliente(total, cliente)
total = agregar_costo_envio(total, envio)
total = aplicar_descuento_monto_alto(total)
return round(total, 2)
Esta versión deja visible el orden actual de las reglas. No lo cambia.
Implementamos las funciones auxiliares preservando el comportamiento.
Archivo a modificar: src/facturacion.py
def aplicar_descuento_cliente(total, cliente):
if cliente["tipo"] == "premium":
return total * 0.90
return total
def agregar_costo_envio(total, envio):
if envio == "express":
return total + 500
return total + 200
def aplicar_descuento_monto_alto(total):
if total > 10000:
return total * 0.95
return total
Volvemos a ejecutar python -m pytest. El diseño está mejor, pero el comportamiento
sigue caracterizado.
Supongamos que el negocio confirma una regla nueva: el descuento por monto alto debe aplicarse antes del costo de envío.
Esta prueba ya no caracteriza el comportamiento viejo. Ahora guía un cambio.
Archivo a crear: tests/test_facturacion_reglas.py
from facturacion import calcular_total
def test_descuento_por_monto_alto_se_aplica_antes_del_envio():
cliente = {"tipo": "normal"}
items = [
{"precio": 10000, "cantidad": 1},
]
total = calcular_total(cliente, items, envio="express")
assert total == 10500
Esta prueba espera 10000 sin descuento adicional porque no supera 10000 antes del envío. Luego se suma el envío express de 500.
La prueba de caracterización anterior esperaba 9975. Si el negocio cambió, esa expectativa ya no corresponde.
No la borramos sin pensar: la reemplazamos por una prueba que documente la nueva regla o la movemos fuera de la suite activa si solo servía para el refactor previo.
Con el código refactorizado, cambiar el orden es simple.
Archivo a modificar: src/facturacion.py
def calcular_total(cliente, items, envio):
total = calcular_subtotal(items)
total = aplicar_descuento_cliente(total, cliente)
total = aplicar_descuento_monto_alto(total)
total = agregar_costo_envio(total, envio)
return round(total, 2)
Ejecutamos toda la suite y ajustamos las pruebas que representaban el comportamiento anterior.
Cuando tenemos que modificar código existente, una secuencia segura es:
A veces una prueba de caracterización revela un comportamiento extraño. No conviene corregirlo de inmediato si no sabemos si alguien depende de él.
Tomá esta función existente y agregá pruebas de caracterización antes de refactorizar.
def calcular_envio(peso, zona):
costo = 300
if peso > 1:
costo += (peso - 1) * 120
if zona == "lejana":
costo += 500
if costo > 1000:
costo *= 0.90
return round(costo, 2)
TDD también puede aplicarse en código existente, pero el primer paso cambia: antes de guiar nuevo comportamiento, necesitamos caracterizar lo que ya sucede. Esa red de seguridad permite refactorizar, comprender el diseño y modificar reglas con menos riesgo.
En el próximo tema realizaremos un ejercicio integrador: un gestor de tareas desarrollado con TDD.