42. Caso práctico integrador: probar una unidad completa

42.1 Introducción

En los temas anteriores estudiamos conceptos por separado: aserciones, casos límite, validaciones, reglas de negocio, dobles de prueba, fixtures, pruebas parametrizadas, refactorización y buenas prácticas de mantenimiento.

En este último tema integraremos esas ideas en un caso práctico. Tomaremos una unidad pequeña pero realista, analizaremos qué comportamientos debe cumplir y construiremos una suite de pruebas unitarias alrededor de ella.

El objetivo no es mostrar un proyecto enorme, sino practicar el razonamiento completo: entender la unidad, elegir casos relevantes, escribir pruebas claras y verificar que la suite aporte información útil.

42.2 La unidad que vamos a probar

Usaremos una clase llamada Carrito. Representa un carrito de compras con productos, cantidades, stock disponible y una regla simple de descuento.

Esta unidad tiene varios comportamientos interesantes:

  • Inicia vacía.
  • Permite agregar productos activos con stock suficiente.
  • Rechaza cantidades inválidas.
  • Rechaza productos inactivos.
  • Rechaza compras superiores al stock disponible.
  • Calcula subtotal, descuento y total.
  • Permite vaciar el carrito.

Es suficientemente pequeña para probarla unitariamente y suficientemente rica para aplicar varios criterios del curso.

42.3 Código de la unidad

Primero veamos una implementación posible. En un proyecto real este código estaría en un archivo como carrito.py.

class Producto:
    def __init__(self, nombre, precio, stock, activo=True):
        self.nombre = nombre
        self.precio = precio
        self.stock = stock
        self.activo = activo


class Carrito:
    def __init__(self):
        self._items = []

    def agregar(self, producto, cantidad):
        if cantidad <= 0:
            raise ValueError("La cantidad debe ser positiva")

        if producto.activo is False:
            raise ValueError("El producto no esta activo")

        if cantidad > producto.stock:
            raise ValueError("Stock insuficiente")

        self._items.append({"producto": producto, "cantidad": cantidad})

    def cantidad_items(self):
        return sum(item["cantidad"] for item in self._items)

    def subtotal(self):
        return sum(
            item["producto"].precio * item["cantidad"]
            for item in self._items
        )

    def descuento(self):
        if self.subtotal() >= 10000:
            return self.subtotal() * 0.10
        return 0

    def total(self):
        return self.subtotal() - self.descuento()

    def vaciar(self):
        self._items = []

Las pruebas deben concentrarse en el comportamiento público: métodos como agregar, cantidad_items, subtotal, descuento, total y vaciar.

42.4 Identificar comportamientos observables

Antes de escribir pruebas, conviene listar qué puede observarse desde afuera de la unidad. Esto evita probar detalles internos como _items.

En este caso podemos observar:

  • La cantidad total de unidades agregadas.
  • El subtotal calculado.
  • El descuento aplicado.
  • El total final.
  • Las excepciones cuando una operación es inválida.
  • El estado del carrito después de vaciarlo.
El foco de las pruebas será lo que el carrito promete hacer, no cómo guarda internamente los productos.

42.5 Elegir casos relevantes

No necesitamos probar todos los productos ni todas las cantidades posibles. Elegimos casos que representen reglas importantes.

Comportamiento Caso elegido Motivo
Estado inicial Carrito recién creado. Define el punto de partida.
Agregar producto Producto activo con stock suficiente. Caso válido principal.
Cantidad inválida 0 y valores negativos. Límite inferior de la regla.
Stock insuficiente Cantidad mayor al stock. Regla de rechazo.
Descuento Subtotal 9999, 10000 y 10001. Límite del descuento.

42.6 Primera prueba: estado inicial

Comenzamos con una prueba simple. Un carrito nuevo debe iniciar vacío.

def test_carrito_nuevo_inicia_vacio():
    carrito = Carrito()

    assert carrito.cantidad_items() == 0
    assert carrito.subtotal() == 0
    assert carrito.total() == 0

Esta prueba tiene varias aserciones, pero todas describen el mismo estado inicial del carrito. En este caso es razonable mantenerlas juntas.

42.7 Probar agregar un producto válido

Ahora probamos el caso principal: agregar un producto activo, con cantidad positiva y stock suficiente.

def test_agregar_producto_valido_incrementa_cantidad_y_subtotal():
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=5)

    carrito.agregar(producto, 2)

    assert carrito.cantidad_items() == 2
    assert carrito.subtotal() == 2000

La prueba usa datos simples. Si agregamos 2 unidades de un producto de precio 1000, el subtotal esperado se entiende rápidamente.

42.8 Probar varias líneas de productos

El carrito debe sumar correctamente productos diferentes. Esta prueba verifica una acumulación sencilla.

def test_agregar_varios_productos_calcula_subtotal():
    carrito = Carrito()
    mouse = Producto("mouse", precio=1000, stock=5)
    teclado = Producto("teclado", precio=2000, stock=3)

    carrito.agregar(mouse, 2)
    carrito.agregar(teclado, 1)

    assert carrito.cantidad_items() == 3
    assert carrito.subtotal() == 4000

Esta prueba no repite la anterior exactamente. Agrega una situación distinta: acumulación de distintos productos y cantidades.

42.9 Probar cantidades inválidas

La cantidad debe ser positiva. Los valores 0 y negativos deben rechazarse. Como comparten la misma regla, podemos usar parametrización.

import pytest


@pytest.mark.parametrize("cantidad", [0, -1, -5])
def test_agregar_cantidad_no_positiva_genera_error(cantidad):
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=5)

    with pytest.raises(ValueError) as error:
        carrito.agregar(producto, cantidad)

    assert str(error.value) == "La cantidad debe ser positiva"

Todos los casos verifican la misma intención. La parametrización reduce repetición sin ocultar la regla.

42.10 Probar producto inactivo

Un producto inactivo no debe poder agregarse aunque tenga stock suficiente.

def test_agregar_producto_inactivo_genera_error():
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=5, activo=False)

    with pytest.raises(ValueError) as error:
        carrito.agregar(producto, 1)

    assert str(error.value) == "El producto no esta activo"

La prueba deja visible el dato relevante: activo=False. No lo ocultamos en una fixture porque explica el caso.

42.11 Probar stock insuficiente

Otra regla de rechazo es el stock. Si el producto tiene 2 unidades disponibles, no se deben poder agregar 3.

def test_agregar_mas_cantidad_que_stock_genera_error():
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=2)

    with pytest.raises(ValueError) as error:
        carrito.agregar(producto, 3)

    assert str(error.value) == "Stock insuficiente"

El caso elegido está justo por encima del stock disponible. Eso hace que el motivo del rechazo sea claro.

42.12 Probar que un rechazo no modifica el carrito

Además de comprobar el error, podemos verificar que una operación rechazada no dejó cambios parciales en el carrito.

def test_producto_rechazado_no_modifica_carrito():
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=2)

    with pytest.raises(ValueError):
        carrito.agregar(producto, 3)

    assert carrito.cantidad_items() == 0
    assert carrito.subtotal() == 0

Esta prueba protege consistencia de estado después de una operación inválida.

42.13 Probar descuento sin superar el límite

La regla dice que hay 10% de descuento desde subtotal 10000. Primero probamos un subtotal menor.

def test_subtotal_menor_a_10000_no_tiene_descuento():
    carrito = Carrito()
    producto = Producto("monitor", precio=9999, stock=1)

    carrito.agregar(producto, 1)

    assert carrito.descuento() == 0
    assert carrito.total() == 9999

El valor 9999 está justo debajo del límite, por eso es más útil que un número arbitrario como 5000.

42.14 Probar el límite exacto del descuento

El caso más importante es el límite exacto: subtotal 10000 debe recibir descuento.

def test_subtotal_10000_tiene_descuento_del_10_por_ciento():
    carrito = Carrito()
    producto = Producto("notebook", precio=10000, stock=1)

    carrito.agregar(producto, 1)

    assert carrito.descuento() == 1000
    assert carrito.total() == 9000

Esta prueba detectaría un error común: usar > 10000 en lugar de >= 10000.

42.15 Parametrizar casos de descuento

También podemos agrupar los valores cercanos al límite en una prueba parametrizada.

@pytest.mark.parametrize("precio, descuento_esperado, total_esperado", [
    (9999, 0, 9999),
    (10000, 1000, 9000),
    (10001, 1000.1, 9000.9),
])
def test_descuento_se_aplica_desde_10000(precio, descuento_esperado, total_esperado):
    carrito = Carrito()
    producto = Producto("producto", precio=precio, stock=1)

    carrito.agregar(producto, 1)

    assert carrito.descuento() == descuento_esperado
    assert carrito.total() == total_esperado

Esta parametrización es útil porque todos los casos verifican la misma regla: el descuento comienza en 10000.

42.16 Usar fixtures para preparación común

Si muchas pruebas necesitan un carrito vacío o un producto básico, podemos usar fixtures pequeñas.

@pytest.fixture
def carrito():
    return Carrito()


@pytest.fixture
def mouse():
    return Producto("mouse", precio=1000, stock=5)


def test_agregar_producto_valido_incrementa_cantidad(carrito, mouse):
    carrito.agregar(mouse, 2)

    assert carrito.cantidad_items() == 2

Estas fixtures son simples y sus nombres explican qué entregan. No ocultan una regla de negocio compleja.

42.17 No abusar de fixtures en el caso práctico

En algunas pruebas conviene no usar fixture porque el dato específico es parte de la intención.

def test_producto_inactivo_no_puede_agregarse(carrito):
    producto = Producto("mouse", precio=1000, stock=5, activo=False)

    with pytest.raises(ValueError):
        carrito.agregar(producto, 1)

El valor activo=False debe verse en la prueba. Si lo escondemos en una fixture llamada producto_especial, el lector pierde contexto.

42.18 Archivo de pruebas completo

Una versión resumida del archivo test_carrito.py podría verse así:

import pytest

from carrito import Carrito, Producto


@pytest.fixture
def carrito():
    return Carrito()


@pytest.fixture
def mouse():
    return Producto("mouse", precio=1000, stock=5)


def test_carrito_nuevo_inicia_vacio():
    carrito = Carrito()

    assert carrito.cantidad_items() == 0
    assert carrito.subtotal() == 0
    assert carrito.total() == 0


def test_agregar_producto_valido_incrementa_cantidad_y_subtotal(carrito, mouse):
    carrito.agregar(mouse, 2)

    assert carrito.cantidad_items() == 2
    assert carrito.subtotal() == 2000


@pytest.mark.parametrize("cantidad", [0, -1, -5])
def test_agregar_cantidad_no_positiva_genera_error(carrito, mouse, cantidad):
    with pytest.raises(ValueError):
        carrito.agregar(mouse, cantidad)


def test_agregar_producto_inactivo_genera_error(carrito):
    producto = Producto("mouse", precio=1000, stock=5, activo=False)

    with pytest.raises(ValueError):
        carrito.agregar(producto, 1)


def test_agregar_mas_cantidad_que_stock_genera_error(carrito):
    producto = Producto("mouse", precio=1000, stock=2)

    with pytest.raises(ValueError):
        carrito.agregar(producto, 3)


def test_subtotal_10000_tiene_descuento_del_10_por_ciento(carrito):
    producto = Producto("notebook", precio=10000, stock=1)

    carrito.agregar(producto, 1)

    assert carrito.descuento() == 1000
    assert carrito.total() == 9000

El archivo combina pruebas individuales, fixtures y parametrización sin ocultar las reglas importantes.

42.19 Ejecutar la suite

Con pytest, podemos ejecutar el archivo de pruebas así:

pytest tests/test_carrito.py

Si todas las pruebas pasan, tenemos evidencia de que los comportamientos cubiertos siguen funcionando. No significa que el sistema completo esté libre de errores, pero sí que esta unidad cumple los casos probados.

Si una prueba falla, el nombre debería orientar el diagnóstico. Por ejemplo, test_subtotal_10000_tiene_descuento_del_10_por_ciento señala directamente el límite del descuento.

42.20 Revisar cobertura de comportamientos

Después de escribir las pruebas, conviene revisar si los comportamientos importantes están representados.

  • Estado inicial: cubierto.
  • Agregar producto válido: cubierto.
  • Cantidades inválidas: cubierto.
  • Producto inactivo: cubierto.
  • Stock insuficiente: cubierto.
  • Descuento bajo el límite, en el límite y sobre el límite: cubierto.
  • Vaciar carrito: falta una prueba específica.

Esta revisión nos muestra una omisión razonable. Podemos agregar un caso para vaciar.

42.21 Agregar una prueba faltante

La operación vaciar cambia el estado del carrito. Debemos verificar el estado observable después de ejecutarla.

def test_vaciar_carrito_elimina_items_y_total():
    carrito = Carrito()
    producto = Producto("mouse", precio=1000, stock=5)
    carrito.agregar(producto, 2)

    carrito.vaciar()

    assert carrito.cantidad_items() == 0
    assert carrito.subtotal() == 0
    assert carrito.total() == 0

La prueba prepara un carrito con datos, ejecuta la operación y verifica que el estado vuelva a cero.

42.22 Qué no probamos en este caso

Una suite unitaria también debe tener límites claros. En este caso no probamos:

  • Persistencia del carrito en una base de datos.
  • Interfaz web o formulario de compra.
  • Integración con pasarela de pago.
  • Actualización real de inventario en otro sistema.
  • Flujo completo de compra de un usuario.

Esos comportamientos pueden requerir pruebas de integración o end-to-end. No forman parte de la prueba unitaria de esta clase.

42.23 Refactorizar la suite del caso

Una vez que las pruebas existen, podemos revisar si se mantienen claras. Algunas mejoras posibles son:

  • Usar fixture carrito donde no oculte intención.
  • Usar fixture mouse para producto válido común.
  • Mantener productos especiales dentro de la prueba.
  • Parametrizar cantidades inválidas.
  • Parametrizar casos del límite de descuento si la tabla se mantiene legible.
  • Separar pruebas de rechazo por motivo para mejorar diagnóstico.

Refactorizar no cambia qué comportamientos se prueban. Solo mejora cómo se expresan.

42.24 Qué debes recordar de este caso

  • Antes de escribir pruebas, identifica comportamientos observables.
  • Elige casos válidos, inválidos y límites relevantes.
  • Usa datos simples para que el resultado esperado sea evidente.
  • Verifica errores esperados y también que no haya cambios parciales.
  • Usa fixtures para preparación común, pero no ocultes datos importantes.
  • Usa parametrización cuando varios casos comparten la misma intención.
  • Revisa qué comportamientos quedaron fuera y decide si corresponde cubrirlos.

42.25 Cierre del curso

A lo largo de este curso vimos que una prueba unitaria es mucho más que una función con un assert. Es una forma de expresar expectativas concretas sobre una unidad de código.

Una buena prueba unitaria ayuda a detectar errores temprano, documenta comportamientos importantes, da confianza al refactorizar y permite mantener el código con menos incertidumbre.

El criterio principal es siempre el mismo: probar comportamientos observables con casos claros, rápidos, independientes y mantenibles. Con esa base, las pruebas unitarias se convierten en una herramienta práctica para construir software más confiable.