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.
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:
Es suficientemente pequeña para probarla unitariamente y suficientemente rica para aplicar varios criterios del curso.
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.
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:
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. |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Después de escribir las pruebas, conviene revisar si los comportamientos importantes están representados.
Esta revisión nos muestra una omisión razonable. Podemos agregar un caso para vaciar.
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.
Una suite unitaria también debe tener límites claros. En este caso no probamos:
Esos comportamientos pueden requerir pruebas de integración o end-to-end. No forman parte de la prueba unitaria de esta clase.
Una vez que las pruebas existen, podemos revisar si se mantienen claras. Algunas mejoras posibles son:
carrito donde no oculte intención.mouse para producto válido común.Refactorizar no cambia qué comportamientos se prueban. Solo mejora cómo se expresan.
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.