Introducción
Hasta ahora has aprendido a programar escribiendo código primero y probándolo después. ¿Y si te dijéramos que hay una forma mejor? Una forma que produce código más confiable, con menos bugs y más fácil de mantener.
El Problema del Desarrollo Tradicional
- Escribes mucho código sin probarlo
- Descubres bugs cuando ya es tarde (en producción)
- Tienes miedo de refactorizar porque algo puede romperse
- La documentación queda desactualizada rápidamente
TDD (Test-Driven Development / Desarrollo Guiado por Pruebas) invierte este proceso: primero escribes el test, luego el código.
Video Recomendado
Para entender TDD en 3 minutos, mira este video introductorio:
Ver "Conoce TDD en ESPAÑOL" Duración: 2:50 minutos | Canal: DeveloperoObjetivos de Aprendizaje
Al finalizar esta clase, serás capaz de:
- Comprender el propósito y beneficios de TDD en el desarrollo de software
- Aplicar el ciclo Red-Green-Refactor paso a paso
- Instalar y configurar pytest en un proyecto Python
- Escribir tests unitarios básicos usando assert
- Diseñar la interfaz pública de tus clases pensando en los tests primero
- Testear el modelo Usuario de TaskFlow usando TDD
1. ¿Qué es TDD? (15 min)
Definición Formal
TDD (Test-Driven Development) es una metodología de desarrollo de software donde escribimos las pruebas unitarias antes de escribir el código de producción. Las pruebas guían el diseño y aseguran que el código cumple con los requisitos desde el inicio.
1.1 ¿Por qué usar TDD?
- Código más confiable: Tests verifican cada parte del código
- Menos bugs: Los encuentras al escribir, no en producción
- Diseño mejor: Piensas en la interfaz antes de implementar
- Documentación viva: Los tests muestran cómo usar el código
- Refactor seguro: Los tests detectan si algo se rompe
- Retroalimentación rápida: Sabes inmediatamente si funciona
- Curva de aprendizaje: Requiere cambiar hábitos
- Tiempo inicial: Parece más lento al principio
- Disciplina: Hay que resistir escribir código primero
- Diseño de tests: Escribir buenos tests es una habilidad
1.2 Analogía: Construir una Casa
Construyes toda la casa y luego:
- Descubres que la cocina está muy lejos del comedor
- El baño no tiene suficiente ventilación
- Las puertas son demasiado pequeñas
Resultado: Cambios costosos y estructurales.
Antes de construir:
- Diseñas planos y los revisas con el cliente (tests)
- Construyes habitación por habitación verificando
- Detectas problemas temprano y barato
Resultado: Casa bien diseñada desde el inicio.
2. Ciclo Red-Green-Refactor (20 min)
El corazón de TDD es un ciclo iterativo de tres pasos. Cada nuevo feature, cada nueva función, sigue este ciclo:
RED: Escribir el test que falle
¿Qué hacemos?
- Piensa en el comportamiento que necesitas implementar
- Escribe un test que describe ese comportamiento
- El test debe fallar porque la funcionalidad no existe aún
- Verifica que falle por la razón correcta (no por error en el test)
GREEN: Hacer pasar el test
¿Qué hacemos?
- Escribe el código mínimo necesario para hacer pasar el test
- No importa si el código es "feo" o subóptimo
- Puedes hacer trampa temporalmente (hardcodear) si es necesario
- Todo lo que importa es: todos los tests verdes
REFACTOR: Mejorar el código
¿Qué hacemos?
- Ahora que tienes un test verde, mejora el código
- Elimina duplicación
- Mejora nombres de variables y funciones
- Simplifica lógica compleja
- Mantén los tests verdes durante todo el proceso
Flujo del Ciclo
Repite para cada nueva funcionalidad
Diagrama Visual del Ciclo
Videos Adicionales Recomendados
3. Instalar y Configurar pytest (15 min)
pytest es el framework de testing más popular de Python. Es simple, potente y extensible.
3.1 Instalación
# Instalar pytest
pip install pytest
# Verificar instalación
pytest --version
# Instalar plugins útiles (opcional)
pip install pytest-cov # Para cobertura de código
pip install pytest-xdist # Para ejecutar tests en paralelo
3.2 Convenciones de pytest
pytest descubre automáticamente los tests siguiendo estas convenciones:
| Elemento | Convención | Ejemplo |
|---|---|---|
| Archivos de test | test_*.py o *_test.py |
test_calculadora.py |
| Funciones de test | def test_nombre(): |
def test_suma(): |
| Clases de test | class TestNombre: |
class TestCalculadora: |
| Métodos de test | def test_nombre(self): |
def test_multiplicar(self): |
3.3 Primer Test con pytest
# test_calculadora.py
# Nota: El nombre del archivo comienza con 'test_'
def test_suma():
"""
Test simple que verifica que 1 + 1 = 2.
En pytest, usamos 'assert' directamente (no necesitamos unittest.TestCase).
"""
resultado = 1 + 1
assert resultado == 2
def test_resta():
"""Test que verifica resta."""
assert 5 - 3 == 2
def test_multiplicacion():
"""Test que verifica multiplicación."""
assert 4 * 3 == 12
def test_division():
"""Test que verifica división."""
assert 10 / 2 == 5.0
3.4 Ejecutar Tests
# Ejecutar todos los tests en el directorio actual
pytest
# Ejecutar con verbose (más detalle)
pytest -v
# Ejecutar un archivo específico
pytest test_calculadora.py
# Ejecutar una función específica
pytest test_calculadora.py::test_suma
# Ejecutar tests que coincidan con un patrón
pytest -k "suma or resta"
# Ejecutar mostrando prints (útil para debugging)
pytest -s
# Salida esperada con -v:
# test_calculadora.py::test_suma PASSED
# test_calculadora.py::test_resta PASSED
# test_calculadora.py::test_multiplicacion PASSED
# test_calculadora.py::test_division PASSED
3.5 Mensajes de Error Claros
Una ventaja de pytest es que cuando un test falla, te da información muy útil:
# test_ejemplo_falla.py
def test_ejemplo_falla():
resultado = 2 + 2
assert resultado == 5, "La suma debería ser 4, no 5"
# Al ejecutar, verás:
# > assert resultado == 5, "La suma debería ser 4, no 5"
# E AssertionError: La suma debería ser 4, no 5
# E assert 4 == 5
# E + where 4 = resultado
4. Ejemplo TDD Completo: Calculadora (30 min)
Vamos a implementar una calculadora paso a paso usando TDD. Cada funcionalidad seguirá el ciclo Red-Green-Refactor.
4.1 Funcionalidad: Suma
Paso 1: RED - Escribir test que falle
# test_calculadora.py
def test_suma_dos_numeros_positivos():
"""
Test para sumar dos números positivos.
"""
from calculadora import suma
assert suma(2, 3) == 5 # 🔴 RED - ImportError: no existe 'suma'
$ pytest test_calculadora.py::test_suma_dos_numeros_positivos -v
# ERROR: ModuleNotFoundError: No module named 'calculadora'
# ✅ RED - El test falla como esperamos porque el módulo no existe
Paso 2: GREEN - Implementar mínimo
# calculadora.py
def suma(a, b):
"""
Suma dos números.
Args:
a: Primer número
b: Segundo número
Returns:
La suma de a y b
"""
return a + b # Implementación mínima para pasar el test
$ pytest test_calculadora.py::test_suma_dos_numeros_positivos -v
# test_calculadora.py::test_suma_dos_numeros_positivos PASSED
# ✅ GREEN - El test pasa, ¡excelente!
Paso 3: REFACTOR - En este caso, el código es simple
La implementación return a + b ya es limpia. No necesitamos refactorizar.
4.2 Funcionalidad: Resta
RED
# test_calculadora.py
def test_suma_dos_numeros_positivos():
from calculadora import suma
assert suma(2, 3) == 5
def test_resta_dos_numeros():
"""Test para restar dos números."""
from calculadora import resta
assert resta(5, 3) == 2 # 🔴 RED - ImportError: no existe 'resta'
GREEN
# calculadora.py
def suma(a, b):
return a + b
def resta(a, b):
"""Resta dos números."""
return a - b # 🟢 GREEN
4.3 Funcionalidad: Multiplicación y División
# test_calculadora.py
def test_multiplicacion():
from calculadora import multiplicacion
assert multiplicacion(4, 3) == 12
def test_division():
from calculadora import division
assert division(10, 2) == 5.0
def test_division_por_cero():
"""Test importante: división por cero debe lanzar excepción."""
from calculadora import division
import pytest
with pytest.raises(ValueError, match="No se puede dividir por cero"):
division(10, 0)
# calculadora.py
def multiplicacion(a, b):
"""Multiplica dos números."""
return a * b
def division(a, b):
"""
Divide dos números.
Raises:
ValueError: Si b es cero.
"""
if b == 0:
raise ValueError("No se puede dividir por cero")
return a / b
4.4 Código Final de Calculadora
# calculadora.py
"""
Módulo calculadora - Ejemplo TDD completo.
Este módulo fue desarrollado siguiendo TDD, donde cada función
fue implementada después de escribir su test correspondiente.
"""
def suma(a, b):
"""
Suma dos números.
Args:
a: Primer número (int o float)
b: Segundo número (int o float)
Returns:
La suma de a y b
Examples:
>>> suma(2, 3)
5
>>> suma(-1, 1)
0
"""
return a + b
def resta(a, b):
"""
Resta dos números.
Args:
a: Primer número
b: Segundo número
Returns:
La diferencia a - b
"""
return a - b
def multiplicacion(a, b):
"""
Multiplica dos números.
Args:
a: Primer número
b: Segundo número
Returns:
El producto de a y b
"""
return a * b
def division(a, b):
"""
Divide dos números.
Args:
a: Dividendo
b: Divisor
Returns:
El cociente de a / b
Raises:
ValueError: Si b es cero
Examples:
>>> division(10, 2)
5.0
>>> division(7, 2)
3.5
"""
if b == 0:
raise ValueError("No se puede dividir por cero")
return a / b
5. Buenas Prácticas de TDD
5.1 Tests Pequeños y Específicos
# ❌ MAL: Test que hace muchas cosas
def test_usuario():
usuario = Usuario("juan", "juan@email.com")
assert usuario.username == "juan"
assert usuario.email == "juan@email.com"
assert usuario.activo is True
usuario.desactivar()
assert usuario.activo is False
usuario.cambiar_email("nuevo@email.com")
assert usuario.email == "nuevo@email.com"
# ✅ BIEN: Tests pequeños y específicos
def test_usuario_se_crea_con_datos_correctos():
usuario = Usuario("juan", "juan@email.com")
assert usuario.username == "juan"
assert usuario.email == "juan@email.com"
def test_usuario_nuevo_esta_activo():
usuario = Usuario("juan", "juan@email.com")
assert usuario.activo is True
def test_desactivar_usuario_lo_desactiva():
usuario = Usuario("juan", "juan@email.com")
usuario.desactivar()
assert usuario.activo is False
5.2 Nombres de Tests Descriptivos
# ❌ MAL: Nombre poco claro
def test1():
assert suma(2, 2) == 4
# ✅ BIEN: Nombre describe el comportamiento
def test_suma_dos_numeros_positivos_retorna_resultado_correcto():
assert suma(2, 2) == 4
# ❌ MAL
def test_division():
with pytest.raises(ValueError):
division(10, 0)
# ✅ BIEN
def test_division_por_cero_lanza_valueerror():
with pytest.raises(ValueError):
division(10, 0)
5.3 Un Assert por Test (Ideal)
5.4 Tests Independientes
# ❌ MAL: Tests dependientes (no hacer esto)
usuario_id = None
def test_crear_usuario():
global usuario_id
usuario = crear_usuario("test")
usuario_id = usuario.id
assert usuario.id is not None
def test_obtener_usuario(): # Depende del anterior
usuario = obtener_usuario(usuario_id)
assert usuario.username == "test"
# ✅ BIEN: Tests independientes
def test_crear_usuario_retorna_usuario_con_id():
usuario = crear_usuario("test")
assert usuario.id is not None
assert usuario.username == "test"
def test_obtener_usuario_existente_retorna_usuario():
# Crea su propio usuario
usuario_creado = crear_usuario("test2")
# Obtiene ese mismo usuario
usuario_obtenido = obtener_usuario(usuario_creado.id)
assert usuario_obtenido.username == "test2"
5.5 Primero el Test Más Simple
Cuando implementes una nueva funcionalidad:
- Empieza con el caso más simple y feliz
- Luego agrega casos límite
- Finalmente agrega casos de error
# Orden recomendado:
def test_suma_numeros_positivos():
assert suma(2, 3) == 5 # Caso feliz simple
def test_suma_con_cero():
assert suma(5, 0) == 5 # Caso límite
def test_suma_numeros_negativos():
assert suma(-2, -3) == -5 # Otro caso
Ejercicio: Testear Modelo Usuario con TDD (30 min)
Aplica TDD para implementar el modelo Usuario de TaskFlow.
Requisitos del Modelo Usuario
- Crear usuario con datos válidos (username, email, nombre completo)
- Username debe tener al menos 3 caracteres
- Email debe contener "@" y tener dominio válido
- Usuario se crea activo por defecto
- Puede activarse y desactivarse
- Puede cambiar su email
Tests a Implementar
# test_usuario.py
import pytest
from usuario import Usuario
class TestUsuario:
"""Tests para el modelo Usuario."""
def test_crear_usuario_con_datos_validos(self):
"""Usuario se crea correctamente con datos válidos."""
usuario = Usuario("juan123", "juan@email.com", "Juan Pérez")
assert usuario.username == "juan123"
assert usuario.email == "juan@email.com"
assert usuario.nombre_completo == "Juan Pérez"
assert usuario.activo is True # Por defecto activo
def test_username_muy_corto_lanza_error(self):
"""Username con menos de 3 caracteres lanza ValueError."""
with pytest.raises(ValueError, match="Username debe tener al menos 3 caracteres"):
Usuario("ab", "juan@email.com", "Juan Pérez")
def test_email_sin_arroba_lanza_error(self):
"""Email sin @ lanza ValueError."""
with pytest.raises(ValueError, match="Email inválido"):
Usuario("juan123", "email-sin-arroba", "Juan Pérez")
def test_email_sin_dominio_lanza_error(self):
"""Email sin dominio después de @ lanza ValueError."""
with pytest.raises(ValueError, match="Email inválido"):
Usuario("juan123", "juan@", "Juan Pérez")
def test_desactivar_usuario_lo_desactiva(self):
"""Desactivar cambia estado a inactivo."""
usuario = Usuario("juan123", "juan@email.com", "Juan Pérez")
assert usuario.esta_activo() is True
usuario.desactivar()
assert usuario.activo is False
assert usuario.esta_activo() is False
def test_activar_usuario_lo_activa(self):
"""Activar cambia estado a activo."""
usuario = Usuario("juan123", "juan@email.com", "Juan Pérez")
usuario.desactivar()
assert usuario.esta_activo() is False
usuario.activar()
assert usuario.activo is True
assert usuario.esta_activo() is True
def test_cambiar_email_valido_actualiza_email(self):
"""Cambiar email actualiza el email correctamente."""
usuario = Usuario("juan123", "juan@email.com", "Juan Pérez")
usuario.cambiar_email("nuevo@email.com")
assert usuario.email == "nuevo@email.com"
def test_cambiar_email_invalido_lanza_error(self):
"""Cambiar a email inválido lanza ValueError."""
usuario = Usuario("juan123", "juan@email.com", "Juan Pérez")
with pytest.raises(ValueError, match="Email inválido"):
usuario.cambiar_email("email-invalido")
def test_dos_usuarios_con_mismo_username_son_diferentes(self):
"""Dos usuarios son diferentes objetos aunque tengan mismos datos."""
usuario1 = Usuario("juan123", "juan1@email.com", "Juan")
usuario2 = Usuario("juan123", "juan2@email.com", "Juan")
assert usuario1 is not usuario2
Implementación Sugerida
# usuario.py
class Usuario:
"""
Representa un usuario en el sistema TaskFlow.
Attributes:
username: Identificador único del usuario
email: Correo electrónico del usuario
nombre_completo: Nombre completo del usuario
activo: Indica si el usuario está activo
"""
def __init__(self, username: str, email: str, nombre_completo: str):
# Validaciones
if len(username) < 3:
raise ValueError("Username debe tener al menos 3 caracteres")
if "@" not in email or "." not in email.split("@")[-1]:
raise ValueError("Email inválido")
self.username = username
self.email = email
self.nombre_completo = nombre_completo
self.activo = True
def esta_activo(self) -> bool:
"""Retorna True si el usuario está activo."""
return self.activo
def desactivar(self):
"""Desactiva el usuario."""
self.activo = False
def activar(self):
"""Activa el usuario."""
self.activo = True
def cambiar_email(self, nuevo_email: str):
"""
Cambia el email del usuario.
Args:
nuevo_email: Nuevo correo electrónico
Raises:
ValueError: Si el email es inválido
"""
if "@" not in nuevo_email or "." not in nuevo_email.split("@")[-1]:
raise ValueError("Email inválido")
self.email = nuevo_email
Comandos Útiles
# Ejecutar todos los tests
pytest
# Ejecutar con verbose (más detalle)
pytest -v
# Ejecutar un archivo específico
pytest test_usuario.py
# Ejecutar una función específica
pytest test_usuario.py::test_crear_usuario_con_datos_validos
# Ejecutar una clase de test completa
pytest test_usuario.py::TestUsuario
# Ver cobertura (necesitas pytest-cov)
pytest --cov=usuario --cov-report=html
# Abre htmlcov/index.html en tu navegador
Resumen y Checklist
Conceptos Clave
- TDD: Tests primero, código después
- RED: Escribe test que falle
- GREEN: Código mínimo para pasar
- REFACTOR: Mejora manteniendo tests verdes
- pytest: Framework de testing para Python
- assert: Verifica condiciones en tests