29. Cómo dividir una historia de usuario en pruebas ejecutables

29.1 Objetivo del tema

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.

29.2 Historia de usuario del ejemplo

Partiremos de esta historia:

Como comprador, quiero aplicar un cupón de descuento a mi carrito para pagar menos cuando la promoción sea válida.

La historia es útil para conversar, pero todavía es demasiado amplia para empezar a programar con precisión.

29.3 Primer paso: identificar reglas

Antes de escribir código, separamos la historia en reglas de negocio.

  • Un cupón válido descuenta un porcentaje del total.
  • Un cupón vencido no puede aplicarse.
  • Un cupón desconocido no puede aplicarse.
  • El descuento no puede dejar el total en negativo.
  • El carrito debe informar el total final luego del cupón.

Cada regla puede convertirse en una o varias pruebas ejecutables.

29.4 Segundo paso: elegir el primer ejemplo

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%.

Dado un carrito con total 100, cuando aplico el cupón PROMO10, entonces el total final debe ser 90.

Este ejemplo tiene entrada, acción y resultado esperado. Ya puede convertirse en prueba.

29.5 Primera prueba ejecutable

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.

29.6 Implementación mínima

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.

29.7 Tercer paso: agregar el caso sin cupón válido

Una historia no se completa con el camino feliz. Agregamos el caso de cupón desconocido.

Dado un carrito con total 100, cuando uso un cupón desconocido, entonces debe rechazarse con el mensaje "Cupón inválido".

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.

29.8 Implementar catálogo mínimo de cupones

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.

29.9 Cuarto paso: incorporar vencimiento

La historia menciona que la promoción debe ser válida. Eso incluye una fecha de vencimiento.

Dado un cupón vencido, cuando intento aplicarlo, entonces debe rechazarse con el mensaje "Cupón vencido".

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.

29.10 Implementar vencimiento

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.

29.11 Quinto paso: probar el borde del vencimiento

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.

29.12 Sexto paso: total mínimo

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.

29.13 Implementar cupón de importe fijo

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.

29.14 Séptimo paso: refactorizar cuando la historia crece

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.

29.15 Funciones auxiliares

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.

29.16 Mapa de historia a pruebas

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

29.17 Orden recomendado de pruebas

No todas las pruebas se escriben al mismo tiempo. Un orden práctico es:

  1. Camino feliz más simple.
  2. Error más evidente.
  3. Borde importante.
  4. Variante que obliga a generalizar.
  5. Refactor con todas las pruebas en verde.

Este orden permite avanzar sin perder claridad ni sobrediseñar desde el primer paso.

29.18 Señales de una buena división

  • Cada prueba responde una pregunta concreta.
  • Los nombres de pruebas se entienden como reglas de negocio.
  • Los casos de error no se mezclan con el camino feliz.
  • Los bordes importantes tienen ejemplos explícitos.
  • El refactor aparece después de tener comportamiento protegido.

29.19 Errores frecuentes

  • Escribir una sola prueba enorme para toda la historia.
  • Intentar implementar todos los criterios antes de tener pruebas.
  • No probar casos de error porque el camino feliz ya funciona.
  • Confundir detalles técnicos con reglas de aceptación.
  • Agregar abstracciones antes de que aparezcan variantes reales.

29.20 Ejercicio práctico

Dividí esta historia en pruebas ejecutables:

Como cliente, quiero elegir un método de envío para saber cuánto pagar y cuándo llegará mi pedido.
  1. Identificá al menos cuatro reglas de negocio.
  2. Elegí el primer ejemplo más simple.
  3. Escribí una prueba roja con pytest.
  4. Implementá el mínimo código para pasarla.
  5. Agregá un caso de error y un borde de fecha o importe.

29.21 Checklist del tema

  • La historia se convirtió en reglas concretas.
  • Cada regla importante tiene al menos una prueba ejecutable.
  • Las fechas y bordes se expresan con datos explícitos.
  • El orden de pruebas ayuda a descubrir el diseño gradualmente.
  • La suite final funciona como documentación de aceptación.

29.22 Conclusión

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.