Saltar al contenido principal
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.

40 min de lectura
3 retos prácticos
1 caso real
Básico
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 personalizadasEl ORM de Django (ya está testeado)
Tus métodos: __str__, clean(), etc.Las migraciones (eso es Django)
Tus propiedades @property calculadasQue CharField(max_length=200) funciona
Valores por defecto de tus camposEl 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
Python
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
Python
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
Python
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=True es el valor por defecto
  • 4Test: __str__ retorna el nombre
  • 5Test: el slug es único (lanza IntegrityError al duplicar)
Pista: from django.db import IntegrityErrorwith 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(): True si activo y usos_actuales < usos_maximos
  • 3Método usar(): incrementa usos_actuales, lanza ValueError si 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 Categoria el método valor_inventario_total() que sume precio × stock de 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, Fself.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
Python
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
OUTPUT
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 operaciones save()
  • Comparar Decimal correctamente (no float)
  • Testear que las excepciones se lanzan correctamente con assertRaises
  • Testear que el estado no cambia si hay un error
  • Testear propiedades @property y validaciones clean()
  • Testear relaciones ForeignKey y el related_name