En este tema construiremos un carrito de compras aplicando TDD. El carrito empezará con un comportamiento mínimo y crecerá con reglas nuevas: agregar productos, calcular totales, acumular cantidades, quitar productos y validar entradas inválidas.
La clave será avanzar en pasos pequeños. Cada comportamiento aparecerá primero como una prueba y luego como código de producción.
Comenzamos con la regla más simple:
Aunque parezca poco, esta prueba define el estado inicial del objeto y nos permite crear la primera estructura del modelo.
Escribimos la prueba antes de crear la clase.
Archivo a crear: tests/test_carrito.py
from carrito import Carrito
def test_carrito_nuevo_esta_vacio():
carrito = Carrito()
assert carrito.esta_vacio()
assert carrito.total == 0
Ejecutamos python -m pytest. La prueba falla porque todavía no existe
carrito.py ni la clase Carrito.
Creamos lo mínimo para llegar a verde.
Archivo a crear: src/carrito.py
class Carrito:
@property
def total(self):
return 0
def esta_vacio(self):
return True
Esta implementación es muy limitada, pero pasa el primer comportamiento. Todavía no agregamos capacidad para productos porque ninguna prueba lo pidió.
Ahora agregamos el primer comportamiento real del carrito.
Archivo a modificar: tests/test_carrito.py
def test_agregar_producto_actualiza_el_total():
carrito = Carrito()
carrito.agregar("Libro", precio=30)
assert not carrito.esta_vacio()
assert carrito.total == 30
La prueba falla porque no existe el método agregar y el total siempre devuelve
cero.
Agregamos una lista interna de ítems y calculamos el total a partir de ella.
Archivo a modificar: src/carrito.py
class Carrito:
def __init__(self):
self._items = []
def agregar(self, nombre, precio):
self._items.append({"nombre": nombre, "precio": precio})
@property
def total(self):
return sum(item["precio"] for item in self._items)
def esta_vacio(self):
return len(self._items) == 0
Ejecutamos toda la suite. La prueba del carrito vacío y la del producto agregado deben quedar en verde.
Ahora verificamos que el total se calcule con más de un ítem.
Archivo a modificar: tests/test_carrito.py
def test_agregar_varios_productos_suma_sus_precios():
carrito = Carrito()
carrito.agregar("Libro", precio=30)
carrito.agregar("Lápiz", precio=5)
assert carrito.total == 35
Esta prueba probablemente ya pase, pero protege una regla importante: el carrito acumula productos.
El siguiente requisito agrega cantidad.
Archivo a modificar: tests/test_carrito.py
def test_producto_con_cantidad_multiplica_precio_por_cantidad():
carrito = Carrito()
carrito.agregar("Cuaderno", precio=12, cantidad=3)
assert carrito.total == 36
La prueba fuerza un cambio en la firma del método agregar.
Para no romper las pruebas anteriores, agregamos cantidad=1 como valor por
defecto.
Archivo a modificar: src/carrito.py
class Carrito:
def __init__(self):
self._items = []
def agregar(self, nombre, precio, cantidad=1):
self._items.append({
"nombre": nombre,
"precio": precio,
"cantidad": cantidad,
})
@property
def total(self):
return sum(
item["precio"] * item["cantidad"]
for item in self._items
)
def esta_vacio(self):
return len(self._items) == 0
Ejecutamos python -m pytest. El cambio debe mantener verdes todos los casos.
Puede ser útil saber cuántas unidades hay en el carrito.
Archivo a modificar: tests/test_carrito.py
def test_cantidad_total_de_unidades():
carrito = Carrito()
carrito.agregar("Cuaderno", precio=12, cantidad=3)
carrito.agregar("Lápiz", precio=5, cantidad=2)
assert carrito.cantidad_total == 5
Agregamos una propiedad calculada desde los ítems.
Archivo a modificar: src/carrito.py
@property
def cantidad_total(self):
return sum(item["cantidad"] for item in self._items)
Esta propiedad no guarda estado duplicado. Se calcula a partir de la lista interna.
Si el usuario agrega dos veces el mismo producto, puede ser mejor acumular la cantidad en un único ítem.
Archivo a modificar: tests/test_carrito.py
def test_agregar_mismo_producto_acumula_cantidad():
carrito = Carrito()
carrito.agregar("Lápiz", precio=5, cantidad=2)
carrito.agregar("Lápiz", precio=5, cantidad=3)
assert carrito.cantidad_de("Lápiz") == 5
assert carrito.total == 25
La prueba introduce un nuevo comportamiento observable: consultar la cantidad de un producto.
Modificamos agregar para buscar un ítem existente antes de crear uno nuevo.
Archivo a modificar: src/carrito.py
def agregar(self, nombre, precio, cantidad=1):
item_existente = self._buscar_item(nombre)
if item_existente:
item_existente["cantidad"] += cantidad
return
self._items.append({
"nombre": nombre,
"precio": precio,
"cantidad": cantidad,
})
def cantidad_de(self, nombre):
item = self._buscar_item(nombre)
if item is None:
return 0
return item["cantidad"]
def _buscar_item(self, nombre):
for item in self._items:
if item["nombre"] == nombre:
return item
return None
Ejecutamos la suite. Si algo falla, revisamos si cambiamos una regla existente.
Agregamos la posibilidad de quitar completamente un producto del carrito.
Archivo a modificar: tests/test_carrito.py
def test_quitar_producto_elimina_su_total():
carrito = Carrito()
carrito.agregar("Libro", precio=30)
carrito.agregar("Lápiz", precio=5, cantidad=2)
carrito.quitar("Libro")
assert carrito.cantidad_de("Libro") == 0
assert carrito.total == 10
Filtramos la lista dejando todos los ítems excepto el indicado.
Archivo a modificar: src/carrito.py
def quitar(self, nombre):
self._items = [
item for item in self._items
if item["nombre"] != nombre
]
La prueba define que quitar elimina todo el producto, no solo una unidad.
Ahora agregamos una regla de entrada inválida.
Archivo a modificar: tests/test_carrito.py
import pytest
def test_no_permite_cantidad_menor_o_igual_a_cero():
carrito = Carrito()
with pytest.raises(ValueError, match="La cantidad debe ser positiva"):
carrito.agregar("Libro", precio=30, cantidad=0)
Agregamos la validación al inicio de agregar.
Archivo a modificar: src/carrito.py
def agregar(self, nombre, precio, cantidad=1):
if cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
item_existente = self._buscar_item(nombre)
if item_existente:
item_existente["cantidad"] += cantidad
return
self._items.append({
"nombre": nombre,
"precio": precio,
"cantidad": cantidad,
})
Ejecutamos todas las pruebas para confirmar que la validación no rompió casos válidos.
El uso de diccionarios funciona, pero el concepto de ítem ya aparece varias veces. Con las pruebas en verde, podemos refactorizar hacia una clase pequeña.
Archivo a modificar: src/carrito.py
from dataclasses import dataclass
@dataclass
class ItemCarrito:
nombre: str
precio: float
cantidad: int
@property
def subtotal(self):
return self.precio * self.cantidad
Este refactor mejora el lenguaje del modelo sin agregar reglas nuevas.
El carrito queda más expresivo usando ItemCarrito.
Archivo a modificar: src/carrito.py
class Carrito:
def __init__(self):
self._items = []
def agregar(self, nombre, precio, cantidad=1):
if cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
item_existente = self._buscar_item(nombre)
if item_existente:
item_existente.cantidad += cantidad
return
self._items.append(ItemCarrito(nombre, precio, cantidad))
def quitar(self, nombre):
self._items = [
item for item in self._items
if item.nombre != nombre
]
def cantidad_de(self, nombre):
item = self._buscar_item(nombre)
if item is None:
return 0
return item.cantidad
@property
def total(self):
return sum(item.subtotal for item in self._items)
@property
def cantidad_total(self):
return sum(item.cantidad for item in self._items)
def esta_vacio(self):
return len(self._items) == 0
def _buscar_item(self, nombre):
for item in self._items:
if item.nombre == nombre:
return item
return None
Ejecutamos python -m pytest. El refactor es correcto si todas las pruebas
siguen pasando.
Al final del recorrido, la suite cuenta qué sabe hacer el carrito:
Extendé el carrito aplicando TDD.
ItemCarrito se hizo con la suite en verde.Un carrito de compras es un buen ejemplo de TDD porque su comportamiento crece naturalmente. Empezamos con un objeto vacío y agregamos reglas a medida que las pruebas las hicieron necesarias. El resultado es un modelo simple, expresivo y respaldado por una suite clara.
En el próximo tema veremos cómo manejar fechas, tiempo y reglas de negocio sin perder determinismo.