En este tema construiremos una calculadora de descuentos aplicando TDD desde cero. Avanzaremos con pasos pequeños: escribir una prueba que falla, implementar lo mínimo para pasarla y refactorizar cuando tengamos la suite en verde.
El ejemplo será práctico y acumulativo. Cada nueva regla de descuento aparecerá primero como una prueba.
Comenzamos con una regla simple:
Esta regla parece obvia, pero es un buen primer caso porque define el comportamiento base del cálculo.
Escribimos la prueba antes de crear la implementación.
Archivo a crear: tests/test_descuentos.py
from descuentos import calcular_total
def test_sin_descuento_devuelve_el_total_original():
total_final = calcular_total(total=100, tipo_cliente="normal")
assert total_final == 100
Ejecutamos python -m pytest. La prueba fallará porque todavía no existe el
módulo descuentos o la función calcular_total.
Creamos la función con el comportamiento mínimo necesario.
Archivo a crear: src/descuentos.py
def calcular_total(total, tipo_cliente):
return total
Ejecutamos nuevamente python -m pytest. Si la prueba pasa, llegamos a verde.
Ahora agregamos una regla de negocio:
Archivo a modificar: tests/test_descuentos.py
def test_cliente_frecuente_recibe_diez_por_ciento_de_descuento():
total_final = calcular_total(total=100, tipo_cliente="frecuente")
assert total_final == 90
Esta prueba debe fallar con la implementación actual porque la función siempre devuelve el total original.
Agregamos solo la condición necesaria para pasar la nueva prueba.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente):
if tipo_cliente == "frecuente":
return total * 0.90
return total
Volvemos a ejecutar la suite completa. Deben pasar la prueba del cliente normal y la del cliente frecuente.
Agregamos otra regla:
Archivo a modificar: tests/test_descuentos.py
def test_cliente_premium_recibe_veinte_por_ciento_de_descuento():
total_final = calcular_total(total=100, tipo_cliente="premium")
assert total_final == 80
Otra vez pasamos por rojo. La prueba nueva guía el siguiente cambio.
Extendemos la función con una nueva condición.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente):
if tipo_cliente == "frecuente":
return total * 0.90
if tipo_cliente == "premium":
return total * 0.80
return total
Ejecutamos python -m pytest. Si todo está en verde, podemos evaluar si ya
aparece una oportunidad de refactor.
Los multiplicadores 0.90 y 0.80 funcionan, pero expresan el total
restante en lugar del porcentaje de descuento. Podemos mejorar la claridad.
Archivo a modificar: src/descuentos.py
DESCUENTOS_POR_CLIENTE = {
"frecuente": 0.10,
"premium": 0.20,
}
def calcular_total(total, tipo_cliente):
descuento = DESCUENTOS_POR_CLIENTE.get(tipo_cliente, 0)
return total - total * descuento
Este cambio no agrega una regla nueva. Solo mejora la estructura. Después del refactor, ejecutamos toda la suite.
Ahora aparece una nueva necesidad: además del tipo de cliente, puede existir un cupón.
Archivo a modificar: tests/test_descuentos.py
def test_cupon_bienvenida_descuenta_quince():
total_final = calcular_total(
total=100,
tipo_cliente="normal",
cupon="BIENVENIDA"
)
assert total_final == 85
Esta prueba nos obliga a cambiar la firma de la función. En TDD, ese cambio se justifica porque apareció un comportamiento nuevo.
Para no romper las pruebas anteriores, podemos agregar cupon=None como valor
por defecto.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente, cupon=None):
descuento = DESCUENTOS_POR_CLIENTE.get(tipo_cliente, 0)
total_final = total - total * descuento
if cupon == "BIENVENIDA":
total_final -= 15
return total_final
Ejecutamos la suite completa. Las pruebas anteriores deben seguir pasando junto con la nueva.
El diseño actual permite combinar descuento por cliente y cupón. Lo expresamos con una prueba para dejar la regla protegida.
Archivo a modificar: tests/test_descuentos.py
def test_cliente_frecuente_con_cupon_combina_descuentos():
total_final = calcular_total(
total=100,
tipo_cliente="frecuente",
cupon="BIENVENIDA"
)
assert total_final == 75
La expectativa es 100 menos 10% y luego menos 15. Si el negocio quisiera otro orden de aplicación, debería quedar explícito en la prueba.
Un descuento no debería generar un total final menor que cero.
Archivo a modificar: tests/test_descuentos.py
def test_total_final_no_puede_ser_negativo():
total_final = calcular_total(
total=10,
tipo_cliente="normal",
cupon="BIENVENIDA"
)
assert total_final == 0
Esta prueba falla con el código actual porque 10 menos 15 da -5.
Ajustamos la función para que nunca devuelva valores negativos.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente, cupon=None):
descuento = DESCUENTOS_POR_CLIENTE.get(tipo_cliente, 0)
total_final = total - total * descuento
if cupon == "BIENVENIDA":
total_final -= 15
if total_final < 0:
return 0
return total_final
Ejecutamos python -m pytest. Si todo está en verde, pasamos a refactor.
La función principal empieza a mezclar varias responsabilidades: descuento por cliente, descuento por cupón y límite mínimo. Podemos extraer funciones con nombres claros.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente, cupon=None):
total_final = total
total_final -= descuento_por_cliente(total, tipo_cliente)
total_final -= descuento_por_cupon(cupon)
return limitar_a_cero(total_final)
Este cambio solo reordena el diseño. La suite en verde confirma que no cambiamos el comportamiento.
Implementamos las funciones extraídas.
Archivo a modificar: src/descuentos.py
DESCUENTOS_POR_CLIENTE = {
"frecuente": 0.10,
"premium": 0.20,
}
def calcular_total(total, tipo_cliente, cupon=None):
total_final = total
total_final -= descuento_por_cliente(total, tipo_cliente)
total_final -= descuento_por_cupon(cupon)
return limitar_a_cero(total_final)
def descuento_por_cliente(total, tipo_cliente):
porcentaje = DESCUENTOS_POR_CLIENTE.get(tipo_cliente, 0)
return total * porcentaje
def descuento_por_cupon(cupon):
if cupon == "BIENVENIDA":
return 15
return 0
def limitar_a_cero(total):
if total < 0:
return 0
return total
Ejecutamos nuevamente la suite completa.
Hasta ahora no validamos entradas inválidas. Agregamos una regla para rechazar totales negativos.
Archivo a modificar: tests/test_descuentos.py
import pytest
def test_total_no_puede_ser_negativo():
with pytest.raises(ValueError, match="El total no puede ser negativo"):
calcular_total(total=-100, tipo_cliente="normal")
Esta prueba aparece recién cuando el requisito existe. No agregamos validaciones por anticipado.
Agregamos la validación al inicio de la función principal.
Archivo a modificar: src/descuentos.py
def calcular_total(total, tipo_cliente, cupon=None):
if total < 0:
raise ValueError("El total no puede ser negativo")
total_final = total
total_final -= descuento_por_cliente(total, tipo_cliente)
total_final -= descuento_por_cupon(cupon)
return limitar_a_cero(total_final)
Ejecutamos todas las pruebas. La validación nueva no debe romper los casos existentes.
Al final del recorrido, el archivo de pruebas documenta las reglas de negocio principales.
Archivo final: tests/test_descuentos.py
import pytest
from descuentos import calcular_total
def test_sin_descuento_devuelve_el_total_original():
assert calcular_total(total=100, tipo_cliente="normal") == 100
def test_cliente_frecuente_recibe_diez_por_ciento_de_descuento():
assert calcular_total(total=100, tipo_cliente="frecuente") == 90
def test_cliente_premium_recibe_veinte_por_ciento_de_descuento():
assert calcular_total(total=100, tipo_cliente="premium") == 80
def test_cupon_bienvenida_descuenta_quince():
assert calcular_total(100, "normal", cupon="BIENVENIDA") == 85
def test_cliente_frecuente_con_cupon_combina_descuentos():
assert calcular_total(100, "frecuente", cupon="BIENVENIDA") == 75
def test_total_final_no_puede_ser_negativo():
assert calcular_total(10, "normal", cupon="BIENVENIDA") == 0
def test_total_no_puede_ser_negativo():
with pytest.raises(ValueError, match="El total no puede ser negativo"):
calcular_total(total=-100, tipo_cliente="normal")
Extendé la calculadora usando TDD.
VERANO que descuente 25 unidades.vip con 30% de descuento.python -m pytest después de cada cambio.Construir una calculadora de descuentos con TDD muestra cómo una solución puede crecer de manera ordenada. En lugar de diseñar todas las reglas desde el inicio, dejamos que cada prueba introduzca una necesidad concreta.
En el próximo tema construiremos un validador de datos con reglas acumulativas.