DÍA 03 — NIVEL BÁSICO
Tests unitarios:
modelos y campos
Aprende a testear el corazón de tu aplicación Django: modelos, sus campos, métodos personalizados, propiedades calculadas y relaciones entre entidades.
Resumen del Día 2
Creamos
settings_test.py con BD en memoria · Configuramos email backend para capturar correos · Organizamos tests en carpeta tests/ con __init__.py · Aprendimos setUp y setUpClass · Ejecutamos tests con --settings específicos
Teoría del Día
// qué testear en los modelos
¿Por qué testear modelos?
Los modelos son el núcleo de tu aplicación Django. Si tu modelo falla, todo falla. Contienen la lógica de negocio más crítica de tu sistema.
| ✅ SÍ testear | ❌ NO testear |
|---|---|
| Tu lógica de negocio (métodos propios) | Que Django guarda en BD (ya lo testea Django) |
| Tus validaciones personalizadas | El ORM de Django (ya está testeado) |
Tus métodos: __str__, clean(), etc. | Las migraciones (eso es Django) |
Tus propiedades @property calculadas | Que CharField(max_length=200) funciona |
| Valores por defecto de tus campos | El comportamiento de auto_now_add |
Regla de oro
Testea TU código. No testees el código de Django, de DRF ni de ninguna librería externa. Solo testea lo que tú escribiste.
Demostración en Vivo
// el modelo y sus tests completos
El modelo que testaremos — E-commerce real
productos/models.py
from django.db import models from django.core.exceptions import ValidationError class Categoria(models.Model): nombre = models.CharField(max_length=100) slug = models.SlugField(unique=True) activa = models.BooleanField(default=True) def __str__(self): return self.nombre class Producto(models.Model): ESTADO_CHOICES = [ ('activo', 'Activo'), ('inactivo', 'Inactivo'), ('agotado', 'Agotado'), ] nombre = models.CharField(max_length=200) precio = models.DecimalField(max_digits=10, decimal_places=2) stock = models.PositiveIntegerField(default=0) estado = models.CharField(max_length=10, choices=ESTADO_CHOICES, default='activo') categoria = models.ForeignKey(Categoria, on_delete=models.PROTECT, related_name='productos') def __str__(self): return f"{self.nombre} (${self.precio})" def esta_disponible(self): """True si el producto tiene stock y está activo""" return self.estado == 'activo' and self.stock > 0 def aplicar_descuento(self, porcentaje): """Retorna el precio con descuento. Rango válido: 0-100.""" if not 0 <= porcentaje <= 100: raise ValueError(f"Porcentaje inválido: {porcentaje}. Debe ser 0-100.") return self.precio - (self.precio * (porcentaje / 100)) def reducir_stock(self, cantidad): """Reduce el stock. Si llega a 0, cambia estado a 'agotado'.""" if cantidad <= 0: raise ValueError("La cantidad debe ser mayor a 0") if cantidad > self.stock: raise ValueError( f"Stock insuficiente. Actual: {self.stock}, solicitado: {cantidad}" ) self.stock -= cantidad if self.stock == 0: self.estado = 'agotado' self.save() def clean(self): if self.precio <= 0: raise ValidationError({'precio': 'El precio debe ser mayor a cero.'}) @property def tiene_stock_bajo(self): """True si hay menos de 5 unidades""" return self.stock < 5 @property def valor_inventario(self): """Valor total del inventario de este producto""" return self.precio * self.stock
Funciones helper — evita repetir código en tests
productos/tests/test_models.py
from django.test import TestCase from django.core.exceptions import ValidationError from decimal import Decimal from productos.models import Producto, Categoria # ── HELPERS — crean datos de prueba reutilizables ───────────── def crear_categoria(nombre="Electrónica", slug="electronica"): return Categoria.objects.create(nombre=nombre, slug=slug) def crear_producto( nombre="Laptop Gaming", precio=Decimal("999.99"), stock=10, estado="activo", categoria=None ): if categoria is None: categoria = crear_categoria() return Producto.objects.create( nombre=nombre, precio=precio, stock=stock, estado=estado, categoria=categoria )
Tests de creación y campos básicos
test_models.py — continuación
class ProductoCreacionTest(TestCase): def setUp(self): self.categoria = crear_categoria() self.producto = crear_producto(categoria=self.categoria) def test_campos_tienen_valores_correctos(self): p = self.producto self.assertEqual(p.nombre, "Laptop Gaming") self.assertEqual(p.precio, Decimal("999.99")) self.assertEqual(p.stock, 10) self.assertEqual(p.estado, "activo") def test_estado_por_defecto_es_activo(self): p = Producto.objects.create( nombre="Teclado", precio=Decimal("50.00"), categoria=self.categoria ) self.assertEqual(p.estado, "activo") def test_stock_por_defecto_es_cero(self): p = Producto.objects.create( nombre="Mouse", precio=Decimal("30.00"), categoria=self.categoria ) self.assertEqual(p.stock, 0) def test_str_retorna_nombre_y_precio(self): esperado = "Laptop Gaming ($999.99)" self.assertEqual(str(self.producto), esperado) class ProductoDisponibilidadTest(TestCase): def setUp(self): self.cat = crear_categoria() def test_activo_con_stock_esta_disponible(self): p = crear_producto(estado='activo', stock=5, categoria=self.cat) self.assertTrue(p.esta_disponible()) def test_inactivo_no_esta_disponible(self): p = crear_producto(estado='inactivo', stock=10, categoria=self.cat) self.assertFalse(p.esta_disponible()) # ↑ Aunque tiene stock, está inactivo def test_sin_stock_no_esta_disponible(self): p = crear_producto(estado='activo', stock=0, categoria=self.cat) self.assertFalse(p.esta_disponible()) class ProductoStockTest(TestCase): def setUp(self): self.cat = crear_categoria() self.producto = crear_producto(stock=10, categoria=self.cat) def test_reducir_stock_correctamente(self): self.producto.reducir_stock(3) self.producto.refresh_from_db() # ↑ refresh_from_db() recarga desde BD — ¡no olvides esto! self.assertEqual(self.producto.stock, 7) def test_agotar_stock_cambia_estado(self): self.producto.reducir_stock(10) self.producto.refresh_from_db() self.assertEqual(self.producto.stock, 0) self.assertEqual(self.producto.estado, 'agotado') # ↑ CRÍTICO: estado debe cambiar automáticamente def test_sobreventa_lanza_error(self): with self.assertRaises(ValueError): self.producto.reducir_stock(999) def test_stock_intacto_si_hay_error(self): # Si falla, el stock debe quedar sin cambios try: self.producto.reducir_stock(999) except ValueError: pass self.producto.refresh_from_db() self.assertEqual(self.producto.stock, 10) class ProductoPropertyTest(TestCase): def setUp(self): self.cat = crear_categoria() def test_stock_bajo_cuando_es_menor_a_5(self): p = crear_producto(stock=4, categoria=self.cat) self.assertTrue(p.tiene_stock_bajo) def test_no_stock_bajo_cuando_es_5_o_mas(self): p = crear_producto(stock=5, categoria=self.cat) self.assertFalse(p.tiene_stock_bajo) def test_valor_inventario_calcula_correctamente(self): p = crear_producto(precio=Decimal("100.00"), stock=5, categoria=self.cat) self.assertEqual(p.valor_inventario, Decimal("500.00")) # ↑ SIEMPRE usa Decimal para campos DecimalField, nunca float class ProductoValidacionTest(TestCase): def setUp(self): self.cat = crear_categoria() def test_precio_cero_es_invalido(self): p = Producto(nombre="X", precio=Decimal("0.00"), categoria=self.cat) with self.assertRaises(ValidationError) as ctx: p.clean() self.assertIn('precio', ctx.exception.message_dict) def test_precio_positivo_es_valido(self): p = Producto(nombre="X", precio=Decimal("9.99"), categoria=self.cat) try: p.clean() # No debe lanzar ninguna excepción except ValidationError: self.fail("clean() lanzó ValidationError con precio válido")
3 errores críticos al testear modelos
No usar
refresh_from_db() después de save() · Comparar Decimal con float (da falsos negativos) · Hacer que un test dependa del resultado de otro test anterior
Retos del Día
// practica lo aprendido
RETO 01
★☆☆
Testeando el modelo Categoria
Escribir tests completos para el modelo Categoria cubriendo sus campos y comportamientos.
- 1Crea la clase
CategoriaTest(TestCase) - 2Test: se crea con nombre y slug correctos
- 3Test:
activa=Truees el valor por defecto - 4Test:
__str__retorna el nombre - 5Test: el slug es único (lanza
IntegrityErroral duplicar)
Pista:
from django.db import IntegrityError → with self.assertRaises(IntegrityError): Categoria.objects.create(slug="repetido")
RETO 02
★★☆
Modelo Cupon con lógica de negocio
Crear un modelo nuevo con lógica propia y testearlo exhaustivamente.
- 1Agrega el modelo
Cupon:codigo(único),descuento_pct,activo=True,usos_maximos=100,usos_actuales=0 - 2Método
esta_vigente():Truesiactivoyusos_actuales < usos_maximos - 3Método
usar(): incrementausos_actuales, lanzaValueErrorsi ya agotado - 4Escribe tests para todos los métodos incluyendo los casos borde
Pista: Prueba el caso borde:
usos_actuales == usos_maximos - 1 (aún vigente) y luego usar() → pasa a agotado
RETO 03
★★★
Valor de inventario total de una categoría
Testear lógica que involucra múltiples objetos relacionados en BD.
- 1Agrega a
Categoriael métodovalor_inventario_total()que sumeprecio × stockde todos sus productos activos - 2Test: categoría con 3 productos activos calcula bien
- 3Test: categoría con mezcla activos/inactivos (solo cuenta activos)
- 4Test: categoría sin productos retorna
Decimal('0')
Pista: En el modelo usa
from django.db.models import Sum, F → self.productos.filter(estado='activo').aggregate(total=Sum(F('precio')*F('stock')))Caso Práctico Real
// $2000/mes en devoluciones por bugs de inventario
MegaStore — Pedidos de productos sin stock
// e-commerce · 200 productos · bodega en caos
Contexto
Trabajas en MegaStore. El equipo de logística reportó pedidos de productos sin stock. El CTO descubrió que
reducir_stock() no funciona correctamente en casos de concurrencia y los estados no se actualizan bien.Requerimiento
Tests exhaustivos de
reducir_stock(). Los errores de inventario cuestan $2000/mes en devoluciones. El sistema debe garantizar que el stock nunca quede negativo y los estados sean siempre consistentes.
productos/tests/test_models.py — Solución guiada
class ReducirStockMegaStoreTest(TestCase): """Suite completa — cada test = un escenario real del negocio""" def setUp(self): cat = crear_categoria() self.p = crear_producto(stock=10, estado="activo", categoria=cat) def test_compra_normal_reduce_stock(self): # Escenario: cliente compra 2 de 10 self.p.reducir_stock(2) self.p.refresh_from_db() self.assertEqual(self.p.stock, 8) self.assertEqual(self.p.estado, "activo") def test_comprar_ultimo_producto_lo_agota(self): # Escenario: cliente compra la última unidad self.p.reducir_stock(10) self.p.refresh_from_db() self.assertEqual(self.p.stock, 0) self.assertEqual(self.p.estado, "agotado") # ← CRÍTICO def test_sobreventa_no_modifica_stock(self): # Escenario: intento de comprar más del disponible with self.assertRaises(ValueError): self.p.reducir_stock(11) self.p.refresh_from_db() self.assertEqual(self.p.stock, 10) # ← stock intacto self.assertEqual(self.p.estado, "activo") # ← estado intacto def test_cantidad_cero_es_invalida(self): with self.assertRaises(ValueError) as ctx: self.p.reducir_stock(0) self.assertIn("mayor a 0", str(ctx.exception)) def test_stock_bajo_activa_alerta(self): self.p.reducir_stock(7) # 10 - 7 = 3 unidades self.p.refresh_from_db() self.assertTrue(self.p.tiene_stock_bajo) def test_compras_multiples_acumulan_correctamente(self): self.p.reducir_stock(3) # 10 → 7 self.p.reducir_stock(4) # 7 → 3 self.p.reducir_stock(2) # 3 → 1 self.p.refresh_from_db() self.assertEqual(self.p.stock, 1) self.assertEqual(self.p.estado, "activo")
resultado
test_compra_normal_reduce_stock .............. ok test_comprar_ultimo_producto_lo_agota ........ ok test_compras_multiples_acumulan_correctamente ok test_cantidad_cero_es_invalida ............... ok test_sobreventa_no_modifica_stock ............ ok test_stock_bajo_activa_alerta ................ ok Ran 6 tests in 0.089s OK 🎉 — $2000/mes en devoluciones → $0 💰
Checklist de Aprendizaje
// marca lo que ya dominas
Haz clic en cada casilla para marcarla. Tu progreso se guarda automáticamente.
- Saber qué SÍ y qué NO testear en modelos Django
- Crear funciones helper para datos de prueba reutilizables
- Testear valores por defecto de campos
- Testear el método
__str__de los modelos - Testear métodos personalizados con lógica de negocio
- Usar
refresh_from_db()después de operacionessave() - Comparar
Decimalcorrectamente (nofloat) - Testear que las excepciones se lanzan correctamente con
assertRaises - Testear que el estado no cambia si hay un error
- Testear propiedades
@propertyy validacionesclean() - Testear relaciones
ForeignKeyy elrelated_name