IF0100 - Programación OO II

Unidad 2: Técnicas de Desarrollo

Clase 1: Introducción a TDD - Test Driven Development

Martes, 03 de marzo de 2026 Semana 5 - Martes (120 minutos) 60 min Teoría 60 min Práctica

Próxima Entrega: E2 - Calidad y Testing (12/03/2026)

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
  1. Escribes mucho código sin probarlo
  2. Descubres bugs cuando ya es tarde (en producción)
  3. Tienes miedo de refactorizar porque algo puede romperse
  4. 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: Developero

Objetivos 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?

Beneficios
  • 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
Desafíos
  • 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
Regla de Oro de TDD: No escribas código de producción hasta tener un test que falle. No escribas más código de producción del necesario para hacer pasar el test.

1.2 Analogía: Construir una Casa

Sin TDD

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.

Con TDD

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?

  1. Piensa en el comportamiento que necesitas implementar
  2. Escribe un test que describe ese comportamiento
  3. El test debe fallar porque la funcionalidad no existe aún
  4. Verifica que falle por la razón correcta (no por error en el test)
Importante: Ver que el test falle confirma que el test está funcionando correctamente. Si pasa de inmediato, algo está mal.

GREEN: Hacer pasar el test

¿Qué hacemos?

  1. Escribe el código mínimo necesario para hacer pasar el test
  2. No importa si el código es "feo" o subóptimo
  3. Puedes hacer trampa temporalmente (hardcodear) si es necesario
  4. Todo lo que importa es: todos los tests verdes
Truco: Si no sabes cómo implementar, escribe la solución más tonta que se te ocurra. Por ejemplo, si el test espera 5, retorna 5 directamente. Luego generalizarás en el refactor.

REFACTOR: Mejorar el código

¿Qué hacemos?

  1. Ahora que tienes un test verde, mejora el código
  2. Elimina duplicación
  3. Mejora nombres de variables y funciones
  4. Simplifica lógica compleja
  5. Mantén los tests verdes durante todo el proceso
Seguridad: Si un test falla durante el refactor, deshaz los cambios y vuelve al código que funcionaba. Luego refactoriza en pasos más pequeños.
Flujo del Ciclo
RED
GREEN
REFACTOR
RED

Repite para cada nueva funcionalidad

Diagrama Visual del Ciclo
RED Escribe Test GREEN Código Mínimo REFACTOR Mejora Código TDD

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)

Regla general: Un test debería verificar un solo concepto. Si falla, debe ser obvio qué salió mal.

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:

  1. Empieza con el caso más simple y feliz
  2. Luego agrega casos límite
  3. 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

  1. Crear usuario con datos válidos (username, email, nombre completo)
  2. Username debe tener al menos 3 caracteres
  3. Email debe contener "@" y tener dominio válido
  4. Usuario se crea activo por defecto
  5. Puede activarse y desactivarse
  6. 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
Conexión con TaskFlow: Todo el proyecto TaskFlow debe tener tests. La Evaluación 2 (15%) se basa en la calidad de tus tests y cobertura de código. Meta mínima: 80% de cobertura.

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

Checklist

Práctica para Casa: Antes de la próxima clase, completa los tests para la clase Calculadora (suma, resta, multiplicación, división, división por cero) e implementa al menos 5 tests para el modelo Usuario. Trae tus dudas a la siguiente clase.
← Anterior: Clases Abstractas
Clase 7 de 25
Siguiente: pytest Avanzado →