22. Construcción de una calculadora de descuentos con TDD paso a paso

22.1 Objetivo del tema

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.

22.2 Requisito inicial

Comenzamos con una regla simple:

Si el cliente no tiene descuento, el total final debe ser igual al total original.

Esta regla parece obvia, pero es un buen primer caso porque define el comportamiento base del cálculo.

22.3 Primera prueba roja

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.

22.4 Código mínimo para pasar

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.

22.5 Segunda regla: cliente frecuente

Ahora agregamos una regla de negocio:

Un cliente frecuente recibe 10% de descuento.

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.

22.6 Implementación mínima de cliente frecuente

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.

22.7 Tercera regla: cliente premium

Agregamos otra regla:

Un cliente premium recibe 20% de descuento.

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.

22.8 Implementación mínima de cliente premium

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.

22.9 Primer refactor: evitar números dispersos

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.

22.10 Cuarta regla: cupón fijo

Ahora aparece una nueva necesidad: además del tipo de cliente, puede existir un cupón.

El cupón BIENVENIDA descuenta 15 unidades monetarias.

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.

22.11 Adaptar la firma con compatibilidad

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.

22.12 Quinta regla: combinar descuentos

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.

22.13 Sexta regla: el total no puede ser negativo

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.

22.14 Implementar el límite mínimo

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.

22.15 Segundo refactor: separar reglas

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.

22.16 Funciones auxiliares

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.

22.17 Séptima regla: total inválido

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.

22.18 Implementar validación de entrada

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.

22.19 Suite final de pruebas

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")

22.20 Qué aprendimos del recorrido

  • Empezamos con el caso más simple.
  • Cada regla nueva apareció primero como una prueba.
  • El código mínimo permitió avanzar sin sobre diseñar.
  • El refactor separó responsabilidades cuando la duplicación apareció.
  • La suite final documenta el comportamiento esperado.

22.21 Ejercicio práctico

Extendé la calculadora usando TDD.

  1. Agregá una prueba para un cupón VERANO que descuente 25 unidades.
  2. Implementá el código mínimo para pasarla.
  3. Agregá una prueba para un cliente vip con 30% de descuento.
  4. Refactorizá si aparecen condiciones repetidas.
  5. Ejecutá python -m pytest después de cada cambio.

22.22 Checklist del tema

  • Cada regla se agregó con una prueba roja previa.
  • El código mínimo se escribió solo para pasar la prueba actual.
  • El refactor se hizo con la suite en verde.
  • Los nombres de funciones expresan reglas del dominio.
  • La calculadora final sigue siendo simple y verificable.

22.23 Conclusión

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.