24. Construcción de un carrito de compras con comportamiento incremental

24.1 Objetivo del tema

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.

24.2 Requisito inicial

Comenzamos con la regla más simple:

Un carrito nuevo debe estar vacío y su total debe ser cero.

Aunque parezca poco, esta prueba define el estado inicial del objeto y nos permite crear la primera estructura del modelo.

24.3 Primera prueba: carrito vacío

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.

24.4 Implementación mínima

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

24.5 Segunda regla: agregar un producto

Ahora agregamos el primer comportamiento real del carrito.

Al agregar un producto, el carrito deja de estar vacío y su total aumenta con el precio del producto.

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.

24.6 Código mínimo para un producto

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.

24.7 Tercera regla: agregar varios productos

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.

24.8 Cuarta regla: cantidades

El siguiente requisito agrega cantidad.

Si se agrega un producto con cantidad mayor que uno, el total debe considerar precio por 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.

24.9 Agregar cantidad con valor por defecto

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.

24.10 Quinta regla: consultar cantidad de ítems

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

24.11 Implementar cantidad total

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.

24.12 Sexta regla: mismo producto acumula cantidad

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.

24.13 Implementar acumulación

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.

24.14 Séptima regla: quitar un producto

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

24.15 Implementar quitar

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.

24.16 Octava regla: validar cantidad

Ahora agregamos una regla de entrada inválida.

No se puede agregar un producto con cantidad menor o igual a cero.

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)

24.17 Implementar validación de cantidad

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.

24.18 Refactor: extraer ItemCarrito

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.

24.19 Código refactorizado del carrito

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.

24.20 Pruebas como documentación del comportamiento

Al final del recorrido, la suite cuenta qué sabe hacer el carrito:

  • Nace vacío.
  • Permite agregar productos.
  • Calcula total y cantidad total.
  • Acumula cantidades del mismo producto.
  • Permite quitar un producto.
  • Rechaza cantidades inválidas.

24.21 Ejercicio práctico

Extendé el carrito aplicando TDD.

  1. Agregá una prueba para disminuir una unidad de un producto.
  2. Agregá una prueba para que al disminuir la última unidad el producto desaparezca.
  3. Agregá una prueba para rechazar precios menores o iguales a cero.
  4. Implementá cada regla con el mínimo código posible.
  5. Refactorizá solo cuando la suite esté en verde.

24.22 Checklist del tema

  • El carrito se construyó con comportamiento incremental.
  • Cada regla nueva apareció primero como prueba.
  • Las validaciones se agregaron solo cuando existió un requisito.
  • El refactor a ItemCarrito se hizo con la suite en verde.
  • Las pruebas describen reglas observables, no detalles internos.

24.23 Conclusión

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.