En este tema aprenderemos a transformar una historia de usuario en pruebas ejecutables. La idea es pasar de una descripción amplia a ejemplos concretos que puedan guiar el diseño con TDD.
Trabajaremos con una historia de carrito de compras y la dividiremos en criterios,
escenarios y pruebas pequeñas en Python con pytest.
Partiremos de esta historia:
La historia es útil para conversar, pero todavía es demasiado amplia para empezar a programar con precisión.
Antes de escribir código, separamos la historia en reglas de negocio.
Cada regla puede convertirse en una o varias pruebas ejecutables.
En TDD conviene empezar por el ejemplo más simple que aporte valor. Para esta historia, podemos comenzar con un cupón válido de 10%.
Este ejemplo tiene entrada, acción y resultado esperado. Ya puede convertirse en prueba.
Escribimos una prueba enfocada en el comportamiento principal.
Archivo a crear: tests/test_cupones.py
from datetime import date
from cupones import aplicar_cupon
def test_cupon_valido_descuenta_diez_por_ciento():
total_final = aplicar_cupon(
total=100,
codigo="PROMO10",
hoy=date(2026, 5, 10)
)
assert total_final == 90
Ejecutamos python -m pytest. La prueba falla porque todavía no existe la función.
Creamos el código mínimo para pasar el primer ejemplo.
Archivo a crear: src/cupones.py
def aplicar_cupon(total, codigo, hoy):
return total * 0.90
Esta implementación es incompleta, pero suficiente para la primera prueba. La siguiente prueba indicará qué falta.
Una historia no se completa con el camino feliz. Agregamos el caso de cupón desconocido.
Archivo a modificar: tests/test_cupones.py
import pytest
def test_cupon_desconocido_se_rechaza():
with pytest.raises(ValueError, match="Cupón inválido"):
aplicar_cupon(
total=100,
codigo="NO_EXISTE",
hoy=date(2026, 5, 10)
)
Esta prueba transforma una regla de error en comportamiento ejecutable.
Para distinguir cupones válidos e inválidos, introducimos un catálogo simple.
Archivo a modificar: src/cupones.py
CUPONES = {
"PROMO10": {
"descuento": 0.10,
}
}
def aplicar_cupon(total, codigo, hoy):
cupon = CUPONES.get(codigo)
if cupon is None:
raise ValueError("Cupón inválido")
return total - total * cupon["descuento"]
Ejecutamos toda la suite. El camino feliz y el error de cupón desconocido deben pasar.
La historia menciona que la promoción debe ser válida. Eso incluye una fecha de vencimiento.
Archivo a modificar: tests/test_cupones.py
def test_cupon_vencido_se_rechaza():
with pytest.raises(ValueError, match="Cupón vencido"):
aplicar_cupon(
total=100,
codigo="PROMO10",
hoy=date(2026, 6, 1)
)
Usamos una fecha explícita para mantener la prueba determinística.
Agregamos la fecha de vencimiento al catálogo.
Archivo a modificar: src/cupones.py
from datetime import date
CUPONES = {
"PROMO10": {
"descuento": 0.10,
"vence": date(2026, 5, 31),
}
}
def aplicar_cupon(total, codigo, hoy):
cupon = CUPONES.get(codigo)
if cupon is None:
raise ValueError("Cupón inválido")
if hoy > cupon["vence"]:
raise ValueError("Cupón vencido")
return total - total * cupon["descuento"]
Volvemos a ejecutar python -m pytest. La historia avanza con un criterio más.
Un borde importante es el mismo día de vencimiento. ¿El cupón todavía vale? Definimos la regla con una prueba.
Archivo a modificar: tests/test_cupones.py
def test_cupon_es_valido_el_mismo_dia_del_vencimiento():
total_final = aplicar_cupon(
total=100,
codigo="PROMO10",
hoy=date(2026, 5, 31)
)
assert total_final == 90
Esta prueba documenta una decisión del negocio y evita ambigüedad futura.
Agregamos una regla de protección para que el total final no sea negativo. Primero creamos un cupón de importe fijo para mostrar el caso.
Archivo a modificar: tests/test_cupones.py
def test_descuento_no_puede_dejar_total_negativo():
total_final = aplicar_cupon(
total=20,
codigo="REGALO50",
hoy=date(2026, 5, 10)
)
assert total_final == 0
Esta prueba agrega un ejemplo nuevo y obliga a enriquecer el modelo de cupón.
Extendemos el catálogo con tipos de descuento.
Archivo a modificar: src/cupones.py
CUPONES = {
"PROMO10": {
"tipo": "porcentaje",
"valor": 0.10,
"vence": date(2026, 5, 31),
},
"REGALO50": {
"tipo": "importe",
"valor": 50,
"vence": date(2026, 5, 31),
},
}
def aplicar_cupon(total, codigo, hoy):
cupon = CUPONES.get(codigo)
if cupon is None:
raise ValueError("Cupón inválido")
if hoy > cupon["vence"]:
raise ValueError("Cupón vencido")
if cupon["tipo"] == "porcentaje":
total_final = total - total * cupon["valor"]
else:
total_final = total - cupon["valor"]
if total_final < 0:
return 0
return total_final
El código pasa las pruebas, pero ya muestra señales de que pronto convendrá refactorizar.
Con la suite en verde, podemos separar responsabilidades para que la función principal se lea mejor.
Archivo a modificar: src/cupones.py
def aplicar_cupon(total, codigo, hoy):
cupon = buscar_cupon(codigo)
validar_vigencia(cupon, hoy)
total_final = calcular_total_con_descuento(total, cupon)
return limitar_a_cero(total_final)
El refactor no agrega criterios nuevos. Solo ordena el código que las pruebas ya protegen.
Extraemos las reglas con nombres concretos.
Archivo a modificar: src/cupones.py
def buscar_cupon(codigo):
cupon = CUPONES.get(codigo)
if cupon is None:
raise ValueError("Cupón inválido")
return cupon
def validar_vigencia(cupon, hoy):
if hoy > cupon["vence"]:
raise ValueError("Cupón vencido")
def calcular_total_con_descuento(total, cupon):
if cupon["tipo"] == "porcentaje":
return total - total * cupon["valor"]
return total - cupon["valor"]
def limitar_a_cero(total):
if total < 0:
return 0
return total
Ejecutamos toda la suite después del refactor.
La historia inicial quedó dividida así:
| Regla | Prueba |
|---|---|
| Cupón válido aplica descuento | test_cupon_valido_descuenta_diez_por_ciento |
| Cupón desconocido se rechaza | test_cupon_desconocido_se_rechaza |
| Cupón vencido se rechaza | test_cupon_vencido_se_rechaza |
| Día de vencimiento sigue válido | test_cupon_es_valido_el_mismo_dia_del_vencimiento |
| Total final no puede ser negativo | test_descuento_no_puede_dejar_total_negativo |
No todas las pruebas se escriben al mismo tiempo. Un orden práctico es:
Este orden permite avanzar sin perder claridad ni sobrediseñar desde el primer paso.
Dividí esta historia en pruebas ejecutables:
pytest.Dividir una historia de usuario en pruebas ejecutables permite que TDD avance con dirección. En lugar de convertir una historia amplia en una implementación grande, la transformamos en ejemplos pequeños que guían decisiones de diseño y validan reglas de negocio.
En el próximo tema veremos cómo registrar decisiones y cuándo escribir la siguiente prueba.