IF0100 - Programación OO II

Unidad 2: Técnicas de Desarrollo

Clase 02: pytest Avanzado - Fixtures, Mocks y Testing de APIs

Jueves, 05 de marzo de 2026 Semana 5 - Jueves (60 minutos) 30 min Teoría 30 min Práctica

E2: Taller TDD/BDD - Jueves 12/03/2026 (7 días)

Introducción

En la clase anterior aprendimos los fundamentos de TDD (Test-Driven Development) y cómo escribir tests básicos con pytest. Hoy profundizaremos en técnicas avanzadas de testing que nos permitirán escribir tests más robustos, mantenibles y profesionales.

¿Por qué necesitamos técnicas avanzadas de testing?

Imagina que estás construyendo una aplicación web compleja como TaskFlow. Tus tests necesitan:

  • Preparar datos consistentes: Cada test necesita un entorno limpio y predecible
  • Probar múltiples escenarios: Un mismo comportamiento con diferentes entradas
  • Aislar dependencias: No queremos que nuestros tests fallen porque un servicio externo está caído
  • Testear APIs sin servidor: Ejecutar tests rápidamente sin levantar todo el sistema
Video Recomendado

Curso completo de pytest con fixtures y mocks (en inglés, pero muy completo):

Ver Playlist "Unit Testing in Python with pytest" 10 videos | Canal: Indian Pythonista

Para resolver estos desafíos, pytest proporciona cuatro herramientas fundamentales:

Fixtures

Preparan el entorno de prueba de forma reutilizable y automática

Parametrización

Ejecutan el mismo test con múltiples conjuntos de datos

Mocks

Simulan objetos y servicios externos para aislar el código

TestClient

Testea APIs FastAPI sin necesidad de levantar un servidor real

Objetivos de Aprendizaje

Al finalizar esta clase, serás capaz de:

  • Crear fixtures reutilizables que automaticen la preparación de datos de prueba
  • Comprender los scopes de fixtures y utilizarlos apropiadamente (function, class, module, session)
  • Parametrizar tests eficientemente para probar múltiples casos sin repetir código
  • Crear mocks y patches para aislar tu código de dependencias externas
  • Testear APIs FastAPI usando TestClient de forma integral
  • Aplicar mejores prácticas de testing en proyectos Python profesionales

1. Fixtures - La Base del Testing Robusto

¿Qué es una Fixture?

Una fixture es una función que prepara el entorno necesario para ejecutar un test. Piensa en ella como un "setup automático" que pytest ejecuta antes de cada test que la necesita.

Analogía: Imagina que vas a cocinar. Una fixture sería como tener un ayudante que prepara todos los ingredientes y utensilios antes de que empieces. Tú solo te concentras en cocinar, no en preparar.

1.1 Fixture Básica - El Fundamento

Comencemos con el ejemplo más simple. Una fixture se define con el decorador @pytest.fixture y se "inyecta" en los tests que la necesitan como parámetros:

import pytest

# Definimos la fixture
@pytest.fixture
def usuario():
    """
    Fixture que crea un usuario de prueba estándar.
    Esta fixture se ejecutará automáticamente antes de cada test
    que tenga 'usuario' como parámetro.
    """
    return {
        "id": 1,
        "username": "testuser",
        "email": "test@example.com",
        "activo": True
    }

# Usamos la fixture - pytest la inyecta automáticamente
def test_usuario_tiene_email_valido(usuario):
    """Test que verifica el email del usuario."""
    assert "@" in usuario["email"]
    assert usuario["email"].endswith(".com")

def test_usuario_esta_activo(usuario):
    """Test que verifica que el usuario está activo."""
    assert usuario["activo"] is True
¿Qué está pasando aquí?
  1. Definimos usuario() como una fixture con @pytest.fixture
  2. Los tests test_usuario_tiene_email_valido y test_usuario_esta_activo reciben usuario como parámetro
  3. pytest detecta automáticamente que necesitan la fixture y la ejecuta antes de cada test
  4. Cada test recibe una copia fresca del usuario

1.2 Fixture con Setup y Teardown - Ciclo de Vida Completo

Muchas veces necesitamos no solo preparar datos, sino también limpiar después. Por ejemplo, crear archivos temporales, conectar a bases de datos, o inicializar servicios. Para esto usamos yield en lugar de return:

import pytest
import tempfile
import os

@pytest.fixture
def archivo_temporal():
    """
    Fixture que crea un archivo temporal y lo limpia después.
    
    FASE 1 - SETUP: Todo antes del yield se ejecuta ANTES del test
    FASE 2 - YIELD: Entrega el recurso al test
    FASE 3 - TEARDOWN: Todo después del yield se ejecuta DESPUÉS del test
    """
    # ===== SETUP =====
    print("\n[SETUP] Creando archivo temporal...")
    fd, ruta = tempfile.mkstemp(suffix=".txt")
    
    # Escribimos contenido inicial
    with os.fdopen(fd, 'w') as f:
        f.write("contenido de prueba")
    
    # ===== ENTREGA AL TEST =====
    yield ruta  # El test recibe esta ruta
    
    # ===== TEARDOWN =====
    print("\n[TEARDOWN] Eliminando archivo temporal...")
    os.unlink(ruta)  # Limpiamos después del test

def test_leer_archivo(archivo_temporal):
    """Test que usa el archivo temporal."""
    # El archivo ya existe y tiene contenido
    with open(archivo_temporal, 'r') as f:
        contenido = f.read()
    
    assert contenido == "contenido de prueba"
    # Al terminar, la fixture limpia automáticamente

def test_archivo_existe(archivo_temporal):
    """Otro test que usa el archivo temporal."""
    assert os.path.exists(archivo_temporal)
¿Por qué usar yield en lugar de return?
Cuando usas return, la función termina inmediatamente. Con yield, la función se "pausa", entrega el valor al test, y cuando el test termina, continúa ejecutándose para hacer cleanup. Es como un "préstamo" del recurso.
Ciclo de Vida de un Fixture
SETUP Preparar recurso (antes del yield) YIELD Entrega al test (test ejecuta) TEARDOWN Limpiar recurso (después del yield) Inicio Durante Test Fin

1.3 Scopes de Fixtures - Controlando el Ciclo de Vida

Un aspecto crucial es entender cuándo se crean y destruyen las fixtures. Por defecto, cada test recibe una instancia nueva. Pero podemos cambiar esto con el parámetro scope:

Scope Cuándo se crea Cuándo se destruye Uso típico
function (default) Antes de cada test Después de cada test Datos aislados, tests independientes
class Antes de la primera prueba de la clase Después de la última prueba Setup compartido para TestClass
module Antes del primer test del módulo Después del último test Conexiones a BD, carga de datos pesados
session Una vez al inicio de la sesión Al final de todos los tests Configuración global, conexiones persistentes

Ejemplo Práctico: Fixture de Módulo para Base de Datos

import pytest

@pytest.fixture(scope="module")
def conexion_db():
    """
    Fixture de módulo: una sola conexión para todos los tests de este archivo.
    
    Esto es eficiente porque:
    1. Evita crear/eliminar conexiones repetidamente (lento)
    2. Los tests pueden compartir estado si es necesario
    3. El setup/teardown costoso solo ocurre una vez
    """
    print("\n" + "="*50)
    print("[SETUP] Conectando a base de datos...")
    print("="*50)
    
    # Simulamos una conexión a BD
    db = {
        "conexion": "activa",
        "datos": [],
        "query_count": 0
    }
    
    yield db  # Compartimos la misma instancia con todos los tests
    
    print("\n" + "="*50)
    print(f"[TEARDOWN] Cerrando conexión. Queries ejecutados: {db['query_count']}")
    print("="*50)

# Test 1: Agrega un dato
def test_agregar_dato(conexion_db):
    conexion_db["datos"].append("dato1")
    conexion_db["query_count"] += 1
    assert len(conexion_db["datos"]) == 1

# Test 2: El dato anterior SIGUE AHÍ (misma instancia)
def test_agregar_otro_dato(conexion_db):
    print(f"\n   Datos existentes: {conexion_db['datos']}")
    conexion_db["datos"].append("dato2")
    conexion_db["query_count"] += 1
    assert len(conexion_db["datos"]) == 2  # ¡Tiene dato1 + dato2!

# Test 3: Verificamos el estado acumulado
def test_verificar_estado(conexion_db):
    print(f"\n   Estado final: {conexion_db}")
    assert conexion_db["query_count"] == 2
⚠️ Advertencia sobre scopes:
Usar scope="module" o scope="session" significa que los tests comparten estado. Esto puede causar que un test afecte a otro si no se tiene cuidado. Úsalo solo cuando:
  • El setup es muy costoso (conexiones a BD, carga de archivos grandes)
  • Necesitas tests que verifiquen acumulación de datos
  • Entiendes y controlas las dependencias entre tests

1.4 conftest.py - Fixtures Compartidas Globalmente

Hasta ahora hemos definido fixtures en el mismo archivo de tests. Pero en proyectos reales, necesitamos fixtures disponibles para múltiples archivos de test. La solución es conftest.py:

tests/conftest.py
import pytest

@pytest.fixture
def usuario_base():
    """
    Fixture disponible para TODOS los tests
    en este directorio y subdirectorios.
    """
    return {
        "username": "usuario_test",
        "email": "test@example.com",
        "rol": "estudiante"
    }

@pytest.fixture
def cliente_autenticado():
    """Fixture para simular usuario logueado."""
    return {
        "user_id": 1,
        "token": "abc123",
        "permisos": ["read", "write"]
    }
tests/test_usuarios.py
# No necesitamos importar nada!
# Las fixtures de conftest.py están disponibles automáticamente

def test_usuario_tiene_rol(usuario_base):
    assert usuario_base["rol"] == "estudiante"

def test_cliente_tiene_permisos(cliente_autenticado):
    assert "read" in cliente_autenticado["permisos"]
¿Cómo funciona conftest.py?
pytest busca automáticamente archivos conftest.py en cada directorio. Las fixtures definidas en conftest.py están disponibles para todos los tests en ese directorio y sus subdirectorios, sin necesidad de importarlas explícitamente. Esto promueve la reutilización y mantiene los tests limpios.

1.5 Fixtures que dependen de otras Fixtures

Las fixtures pueden usar otras fixtures, creando cadenas de dependencias. Esto es muy poderoso para construir escenarios complejos:

import pytest

@pytest.fixture
def configuracion_base():
    """Fixture base con configuración mínima."""
    return {
        "debug": False,
        "entorno": "testing",
        "db_url": "sqlite:///:memory:"
    }

@pytest.fixture
def configuracion_completa(configuracion_base):
    """
    Fixture que extiende la configuración base.
    Recibe configuracion_base automáticamente.
    """
    config = configuracion_base.copy()
    config.update({
        "cache_enabled": True,
        "max_requests": 100,
        "features": ["auth", "api", "admin"]
    })
    return config

@pytest.fixture
def app_configurada(configuracion_completa):
    """
    Fixture que simula una app inicializada
    con la configuración completa.
    """
    class App:
        def __init__(self, config):
            self.config = config
            self.inicializada = True
    
    return App(configuracion_completa)

def test_app_esta_inicializada(app_configurada):
    """Test que usa la cadena completa de fixtures."""
    assert app_configurada.inicializada is True
    assert app_configurada.config["entorno"] == "testing"
    assert "auth" in app_configurada.config["features"]
Flujo de ejecución:
  1. pytest detecta que test_app_esta_inicializada necesita app_configurada
  2. Para crear app_configurada, necesita configuracion_completa
  3. Para crear configuracion_completa, necesita configuracion_base
  4. pytest ejecuta en orden: configuracion_base → configuracion_completa → app_configurada
  5. Finalmente ejecuta el test

2. Parametrización - Tests DRY (Don't Repeat Yourself)

¿Qué es la Parametrización?

La parametrización nos permite ejecutar el mismo test con diferentes conjuntos de datos. Esto evita copiar y pegar código, hace los tests más mantenibles y asegura que probamos múltiples escenarios.

Problema sin parametrizar:

# ¡MAL! Código repetido
def test_email_valido_1():
    assert validar_email("user@example.com") is True

def test_email_valido_2():
    assert validar_email("user.name@example.com") is True

def test_email_invalido_1():
    assert validar_email("invalido") is False

2.1 Parametrización Básica

Usamos el decorador @pytest.mark.parametrize para definir múltiples casos:

import pytest

# Definimos los casos de prueba como una lista de tuplas
# Cada tupla representa un caso: (input, expected_output)
@pytest.mark.parametrize(
    "email, resultado_esperado",
    [
        ("usuario@example.com", True),           # Caso 1: Email válido simple
        ("user.name@example.com", True),         # Caso 2: Email con punto
        ("usuario+tag@example.com", True),       # Caso 3: Email con +tag
        ("invalido", False),                     # Caso 4: Sin @
        ("@example.com", False),                 # Caso 5: Sin usuario
        ("usuario@", False),                     # Caso 6: Sin dominio
        ("", False),                             # Caso 7: Vacío
        ("usuario@@example.com", False),         # Caso 8: Doble @
        ("usuario@example", False),              # Caso 9: Sin TLD
    ]
)
def test_validar_email(email, resultado_esperado):
    """
    Un solo test que se ejecuta 9 veces con diferentes datos.
    
    Args:
        email: El email a validar (viene de la tupla)
        resultado_esperado: Lo que esperamos (viene de la tupla)
    """
    from taskflow.utils import validar_email
    
    resultado = validar_email(email)
    assert resultado == resultado_esperado, \
        f"Email '{email}' debería retornar {resultado_esperado}, pero retornó {resultado}"

# Ejecutar: pytest -v
# Verás 9 tests individuales:
# test_validar_email[usuario@example.com-True] PASSED
# test_validar_email[user.name@example.com-True] PASSED
# ...
Sintaxis explicada:
  • "email, resultado_esperado": Nombres de los parámetros que recibirá el test
  • [...]: Lista de tuplas, cada una es un caso de prueba
  • pytest ejecutará el test una vez por cada tupla
  • Si un caso falla, los demás siguen ejecutándose

2.2 Parametrización con Fixtures

Podemos combinar parametrización con fixtures para escenarios más complejos:

import pytest

@pytest.fixture
def servicio_email():
    """Fixture que provee el servicio a testear."""
    from taskflow.services import EmailService
    return EmailService()

# Parametrizamos sobre la fixture
@pytest.mark.parametrize(
    "username, longitud_minima, es_valido",
    [
        ("abc", 3, True),           # Justo en el límite
        ("ab", 3, False),           # Uno menos del mínimo
        ("valido", 3, True),        # Bien por encima
        ("x" * 100, 50, True),      # Límite superior
        ("x" * 51, 50, False),      # Excede el límite
        ("", 1, False),             # Vacío
    ]
)
def test_validar_username(servicio_email, username, longitud_minima, es_valido):
    """
    Test parametrizado que usa una fixture.
    
    El orden de los parámetros importa:
    1. Primero las fixtures (servicio_email)
    2. Luego los parámetros parametrizados
    """
    resultado = servicio_email.validar_username(username, min_length=longitud_minima)
    assert resultado == es_valido

# También podemos parametrizar fixtures
data_usuarios = [
    ({"username": "admin", "rol": "administrador"}, True),
    ({"username": "user", "rol": "usuario"}, True),
    ({"username": "", "rol": "usuario"}, False),
    ({"username": "test", "rol": ""}, False),
]

@pytest.fixture(params=[dato[0] for dato in data_usuarios])
def usuario_data(request):
    """
    Fixture parametrizada: se ejecuta una vez por cada usuario.
    request.param contiene el valor actual.
    """
    return request.param

def test_usuario_tiene_datos(usuario_data):
    """Este test se ejecuta 4 veces (una por cada usuario)."""
    assert "username" in usuario_data
    assert "rol" in usuario_data
    assert usuario_data["username"] != "" or usuario_data["rol"] != ""

2.3 Parametrización Múltiple - Producto Cartesiano

Cuando usamos múltiples @pytest.mark.parametrize, pytest genera el producto cartesiano de todas las combinaciones:

import pytest

@pytest.mark.parametrize("metodo", ["GET", "POST", "PUT", "DELETE"])
@pytest.mark.parametrize("endpoint", ["/usuarios", "/tareas", "/proyectos"])
def test_endpoints_responden_200(metodo, endpoint):
    """
    Este test se ejecuta 4 métodos × 3 endpoints = 12 veces!
    
    Combinaciones:
    - GET /usuarios
    - GET /tareas
    - GET /proyectos
    - POST /usuarios
    - POST /tareas
    - ... (12 total)
    """
    response = client.request(metodo, endpoint)
    assert response.status_code == 200

# Podemos usar ids para nombres más descriptivos
@pytest.mark.parametrize(
    "entrada, esperado",
    [
        pytest.param(2, 4, id="positivo_simple"),
        pytest.param(-2, 4, id="negativo"),
        pytest.param(0, 0, id="cero"),
        pytest.param(2.5, 6.25, id="decimal"),
    ]
)
def test_cuadrado(entrada, esperado):
    """Los ids aparecerán en el reporte de pytest."""
    assert entrada ** 2 == esperado

# Salida: test_cuadrado[positivo_simple], test_cuadrado[negativo], ...
Beneficios de la parametrización:
  • Cobertura exhaustiva: Pruebas múltiples escenarios fácilmente
  • Mantenibilidad: Cambias la lógica en un solo lugar
  • Claridad: Los casos de prueba son datos, no código repetido
  • Reportes detallados: Cada caso aparece como un test individual

3. Mocks - Aislando el Código

¿Qué es un Mock?

Un mock es un objeto simulado que imita el comportamiento de objetos reales de forma controlada. Nos permite:

  • Aislar el código: Testear una unidad sin depender de otras
  • Controlar el entorno: Simular errores, respuestas específicas, timeouts
  • Evitar efectos secundarios: No enviar emails reales ni modificar bases de datos
  • Testear más rápido: No esperar respuestas de servicios lentos

Ejemplo real: Testear un servicio de pagos sin hacer cobros reales a tarjetas de crédito.

¿Cómo funcionan los Mocks?
Test Unidad a probar Llama a 🎭 Mock Objeto simulado (respuesta controlada) Servicio Real (BD, API, Email...) ❌ No se usa ✅ Aislamiento ✅ Control ✅ Sin efectos secundarios ✅ Rápido

3.1 Mock Básico con unittest.mock

Python incluye el módulo unittest.mock que proporciona la clase Mock:

from unittest.mock import Mock

# Creamos un mock que simula un repositorio de usuarios
mock_repositorio = Mock()

# Configuramos qué debe retornar cuando se llame a ciertos métodos
mock_repositorio.obtener_usuario.return_value = {
    "id": 1,
    "username": "testuser",
    "email": "test@example.com"
}

mock_repositorio.guardar.return_value = True
mock_repositorio.existe_usuario.return_value = False

# Usamos el mock como si fuera el objeto real
usuario = mock_repositorio.obtener_usuario(1)
print(usuario)  # {'id': 1, 'username': 'testuser', ...}

# Verificamos que el método fue llamado correctamente
mock_repositorio.obtener_usuario.assert_called_once_with(1)
mock_repositorio.obtener_usuario.assert_called_with(1)  # Más flexible

# También podemos verificar que NO se llamó otro método
mock_repositorio.eliminar.assert_not_called()

# Verificamos cuántas veces se llamó un método
print(mock_repositorio.obtener_usuario.call_count)  # 1
¿Qué podemos hacer con un Mock?
  • return_value: Define el valor de retorno
  • side_effect: Define una función a ejecutar o excepción a lanzar
  • assert_called_*: Verifica que se llamó como esperamos
  • call_count: Número de veces que se llamó
  • call_args: Argumentos de la última llamada

3.2 Simulando Excepciones con side_effect

from unittest.mock import Mock
import pytest

mock_servicio = Mock()

# Simulamos diferentes comportamientos por llamada
mock_servicio.procesar.side_effect = [
    "ok",           # Primera llamada
    "ok",           # Segunda llamada
    Exception("Error de conexión"),  # Tercera llamada - lanza excepción
    "ok",           # Cuarta llamada
]

# También podemos usar side_effect con una función
def comportamiento_condicional(*args, **kwargs):
    if args[0] > 100:
        return "valor_grande"
    return "valor_pequeño"

mock_servicio.calcular.side_effect = comportamiento_condicional

# O lanzar siempre la misma excepción
mock_servicio.conexion_insegura.side_effect = ConnectionError("Timeout")

def test_maneja_excepcion():
    with pytest.raises(ConnectionError):
        mock_servicio.conexion_insegura()

3.3 Patch - Reemplazando Objetos en Tiempo de Ejecución

El decorador/context manager patch es la herramienta más poderosa. Permite reemplazar temporalmente objetos reales con mocks:

from unittest.mock import patch, Mock

# Estructura del código a testear:
# taskflow/services/usuario_service.py
class UsuarioService:
    def __init__(self):
        from taskflow.repositories import UsuarioRepository
        self.repo = UsuarioRepository()
    
    def crear_usuario(self, username, email):
        if self.repo.existe_username(username):
            raise ValueError("Username ya existe")
        
        usuario = {"username": username, "email": email}
        return self.repo.guardar(usuario)

# Test usando patch
@patch('taskflow.services.usuario_service.UsuarioRepository')
def test_crear_usuario_nuevo(MockRepo):
    """
    Reemplazamos UsuarioRepository con un Mock durante este test.
    
    Importante: patch recibe la RUTA donde se IMPORTA el objeto,
    no donde se DEFINE.
    """
    # Configuramos el mock
    mock_repo = MockRepo.return_value
    mock_repo.existe_username.return_value = False
    mock_repo.guardar.return_value = {"id": 1, "username": "newuser", "email": "new@example.com"}
    
    # Creamos el servicio (ahora usa el mock automáticamente)
    service = UsuarioService()
    
    # Ejecutamos
    resultado = service.crear_usuario("newuser", "new@example.com")
    
    # Verificamos
    assert resultado["username"] == "newuser"
    mock_repo.existe_username.assert_called_once_with("newuser")
    mock_repo.guardar.assert_called_once()

def test_username_existente():
    """Mismo test usando patch como context manager."""
    with patch('taskflow.services.usuario_service.UsuarioRepository') as MockRepo:
        mock_repo = MockRepo.return_value
        mock_repo.existe_username.return_value = True
        
        service = UsuarioService()
        
        with pytest.raises(ValueError, match="Username ya existe"):
            service.crear_usuario("existente", "email@example.com")
        
        mock_repo.guardar.assert_not_called()
⚠️ Regla de Oro del Patch:
Siempre parcha donde se usa el objeto, no donde se define. Si tu código hace from modulo import Clase, debes parchar 'modulo.Clase'. Si hace import modulo; modulo.Clase(), debes parchar 'modulo.Clase'.

3.4 Mock de Llamadas HTTP

Uno de los usos más comunes es mockear llamadas a APIs externas:

from unittest.mock import patch, Mock
import requests

# Servicio real que queremos testear
class WeatherService:
    def __init__(self, api_key):
        self.api_key = api_key
        self.base_url = "https://api.weather.com/v1"
    
    def obtener_temperatura(self, ciudad):
        url = f"{self.base_url}/current?city={ciudad}&apikey={self.api_key}"
        response = requests.get(url)
        
        if response.status_code != 200:
            raise Exception(f"Error {response.status_code}")
        
        data = response.json()
        return data["temperature"]

@patch('requests.get')
def test_obtener_temperatura_exitosa(mock_get):
    """Testeamos el servicio sin hacer llamadas HTTP reales."""
    
    # Configuramos el mock de la respuesta
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "temperature": 25.5,
        "city": "Bogotá",
        "unit": "celsius"
    }
    mock_get.return_value = mock_response
    
    # Ejecutamos
    service = WeatherService(api_key="test-key")
    temp = service.obtener_temperatura("Bogotá")
    
    # Verificamos
    assert temp == 25.5
    mock_get.assert_called_once()
    
    # Verificamos que se llamó con la URL correcta
    llamada = mock_get.call_args
    assert "Bogotá" in llamada[0][0]  # URL contiene la ciudad
    assert "test-key" in llamada[0][0]  # URL contiene el API key

@patch('requests.get')
def test_obtener_temperatura_error(mock_get):
    """Testeamos el manejo de errores."""
    
    mock_response = Mock()
    mock_response.status_code = 404
    mock_get.return_value = mock_response
    
    service = WeatherService(api_key="test-key")
    
    with pytest.raises(Exception, match="Error 404"):
        service.obtener_temperatura("CiudadInexistente")

3.5 Mock con Autospec - Manteniendo la Interfaz

Por defecto, los mocks aceptan cualquier método. Para ser más estrictos, usamos autospec=True:

from unittest.mock import patch

class EmailService:
    def enviar_email(self, destinatario, asunto, cuerpo):
        pass
    
    def verificar_email(self, email):
        return "@" in email

# Sin autospec: el mock acepta cualquier método
@patch('taskflow.services.EmailService')
def test_mock_permisivo(MockService):
    service = MockService.return_value
    service.enviar_email("test@test.com", "Hola", "Mundo")
    service.metodo_que_no_existe()  # ¡Esto no falla! Es peligroso

# Con autospec: el mock respeta la interfaz real
@patch('taskflow.services.EmailService', autospec=True)
def test_mock_estricto(MockService):
    service = MockService.return_value
    service.enviar_email("test@test.com", "Hola", "Mundo")
    # service.metodo_que_no_existe()  # ¡Esto falla! AttributeError
    
    # También verifica argumentos
    # service.enviar_email("solo_un_arg")  # ¡Falla! Falta asunto y cuerpo
¿Cuándo usar mocks?
✅ APIs externasNo depender de servicios de terceros
✅ Bases de datosTests más rápidos, datos predecibles
✅ Servicios de emailNo enviar spam en tests
✅ Funciones aleatoriasTests determinísticos
✅ Reloj/fechaTestear comportamiento temporal
❌ No uses para todoAlgunos tests deben ser de integración reales

4. TestClient - Testing de APIs FastAPI

¿Qué es TestClient?

TestClient es una utilidad proporcionada por FastAPI (basada en httpx y starlette) que permite testear APIs sin levantar un servidor real.

Ventajas:

  • Velocidad: Tests en milisegundos, no segundos
  • Aislamiento: Cada test tiene su propio contexto
  • Facilidad: No necesitas manejar puertos, procesos, o servidores
  • Debugging: Puedes usar pdb normalmente
  • CI/CD: Funciona perfectamente en pipelines de integración continua

4.1 Instalación y Configuración Básica

# Instalamos las dependencias necesarias
pip install httpx pytest
# taskflow/main.py - Nuestra aplicación FastAPI
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="TaskFlow API")

class Tarea(BaseModel):
    id: int | None = None
    titulo: str
    descripcion: str = ""
    completada: bool = False

# Base de datos en memoria para el ejemplo
tareas_db = []
id_counter = 1

@app.get("/tareas")
def listar_tareas():
    """Lista todas las tareas."""
    return tareas_db

@app.post("/tareas", status_code=201)
def crear_tarea(tarea: Tarea):
    """Crea una nueva tarea."""
    global id_counter
    tarea.id = id_counter
    id_counter += 1
    tareas_db.append(tarea)
    return tarea

@app.get("/tareas/{tarea_id}")
def obtener_tarea(tarea_id: int):
    """Obtiene una tarea específica."""
    for tarea in tareas_db:
        if tarea.id == tarea_id:
            return tarea
    raise HTTPException(status_code=404, detail="Tarea no encontrada")

@app.delete("/tareas/{tarea_id}")
def eliminar_tarea(tarea_id: int):
    """Elimina una tarea."""
    global tareas_db
    tareas_db = [t for t in tareas_db if t.id != tarea_id]
    return {"message": "Tarea eliminada"}

4.2 Test Básico con TestClient

# tests/test_api.py
from fastapi.testclient import TestClient
from taskflow.main import app

# Creamos el cliente de test una sola vez
client = TestClient(app)

def test_listar_tareas_vacio():
    """Test: GET /tareas cuando no hay tareas."""
    response = client.get("/tareas")
    
    assert response.status_code == 200
    assert response.json() == []
    assert response.headers["content-type"] == "application/json"

def test_crear_tarea():
    """Test: POST /tareas crea una tarea correctamente."""
    datos_tarea = {
        "titulo": "Aprender pytest",
        "descripcion": "Estudiar fixtures y mocks"
    }
    
    response = client.post("/tareas", json=datos_tarea)
    
    assert response.status_code == 201
    
    data = response.json()
    assert data["titulo"] == "Aprender pytest"
    assert data["descripcion"] == "Estudiar fixtures y mocks"
    assert data["id"] == 1  # Primera tarea
    assert data["completada"] is False

def test_obtener_tarea_existente():
    """Test: GET /tareas/{id} devuelve la tarea correcta."""
    # Primero creamos una tarea
    client.post("/tareas", json={"titulo": "Tarea de prueba"})
    
    # Luego la obtenemos
    response = client.get("/tareas/1")
    
    assert response.status_code == 200
    assert response.json()["titulo"] == "Tarea de prueba"

def test_obtener_tarea_no_existe():
    """Test: GET /tareas/{id} con ID inexistente retorna 404."""
    response = client.get("/tareas/99999")
    
    assert response.status_code == 404
    assert response.json()["detail"] == "Tarea no encontrada"

def test_eliminar_tarea():
    """Test: DELETE /tareas/{id} elimina la tarea."""
    # Creamos y luego eliminamos
    client.post("/tareas", json={"titulo": "Tarea temporal"})
    
    response = client.delete("/tareas/1")
    assert response.status_code == 200
    
    # Verificamos que ya no existe
    response = client.get("/tareas/1")
    assert response.status_code == 404
Métodos disponibles en TestClient:
  • client.get(url, params, headers)
  • client.post(url, json, data, headers, files)
  • client.put(url, json, data, headers)
  • client.patch(url, json, data, headers)
  • client.delete(url, headers)

4.3 Usando Fixtures con TestClient

La mejor práctica es crear una fixture que resetee el estado entre tests:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from taskflow.main import app, tareas_db, id_counter

@pytest.fixture
def client():
    """
    Fixture que proporciona un TestClient limpio.
    Resetea la base de datos antes de cada test.
    """
    # Setup: Limpiamos la BD
    tareas_db.clear()
    global id_counter
    id_counter = 1
    
    # Creamos y retornamos el cliente
    with TestClient(app) as test_client:
        yield test_client
    
    # Teardown: Limpiamos después (opcional, ya que el setup del siguiente test lo hará)
    tareas_db.clear()

# tests/test_tareas.py
def test_flujo_completo(client):
    """
    Test que verifica todo el flujo CRUD.
    La BD está limpia gracias a la fixture.
    """
    # 1. Lista vacía
    response = client.get("/tareas")
    assert response.json() == []
    
    # 2. Creamos dos tareas
    client.post("/tareas", json={"titulo": "Tarea 1"})
    client.post("/tareas", json={"titulo": "Tarea 2"})
    
    # 3. Verificamos que hay 2
    response = client.get("/tareas")
    assert len(response.json()) == 2
    
    # 4. Eliminamos una
    client.delete("/tareas/1")
    
    # 5. Verificamos que queda 1
    response = client.get("/tareas")
    assert len(response.json()) == 1

4.4 Test con Autenticación

# tests/test_auth.py
import pytest
from fastapi.testclient import TestClient
from taskflow.main import app

@pytest.fixture
def client():
    return TestClient(app)

@pytest.fixture
def token_autenticacion(client):
    """Fixture que obtiene un token válido."""
    response = client.post("/auth/login", json={
        "username": "testuser",
        "password": "testpass"
    })
    return response.json()["access_token"]

def test_acceso_sin_token(client):
    """Test: Endpoint protegido sin token retorna 401."""
    response = client.get("/usuarios/perfil")
    assert response.status_code == 401

def test_acceso_con_token(client, token_autenticacion):
    """Test: Endpoint protegido con token válido funciona."""
    headers = {"Authorization": f"Bearer {token_autenticacion}"}
    response = client.get("/usuarios/perfil", headers=headers)
    assert response.status_code == 200

def test_header_malformado(client):
    """Test: Token malformado retorna 403."""
    headers = {"Authorization": "Token invalido"}
    response = client.get("/usuarios/perfil", headers=headers)
    assert response.status_code == 403

4.5 Test con Dependencias Mockeadas

A veces necesitamos mockear dependencias de FastAPI:

from unittest.mock import patch, Mock
from fastapi.testclient import TestClient
from taskflow.main import app

client = TestClient(app)

def test_con_db_mockeada():
    """
    Test que mockea el repositorio de la base de datos.
    Útil cuando no queremos usar la BD real.
    """
    with patch('taskflow.routers.tareas.TareaRepository') as MockRepo:
        # Configuramos el mock
        mock_repo = MockRepo.return_value
        mock_repo.obtener_todas.return_value = [
            {"id": 1, "titulo": "Mock 1"},
            {"id": 2, "titulo": "Mock 2"}
        ]
        
        # Ejecutamos el test
        response = client.get("/tareas")
        
        assert response.status_code == 200
        assert len(response.json()) == 2
        assert response.json()[0]["titulo"] == "Mock 1"
        
        # Verificamos que se llamó al repositorio
        mock_repo.obtener_todas.assert_called_once()

def test_con_servicio_externo_mockeado():
    """
    Test que mockea un servicio externo (ej: envío de emails).
    """
    with patch('taskflow.services.email_service.send_email') as mock_send:
        mock_send.return_value = {"message_id": "12345", "status": "sent"}
        
        response = client.post("/usuarios", json={
            "email": "nuevo@example.com",
            "username": "nuevouser"
        })
        
        assert response.status_code == 201
        # Verificamos que se envió el email de bienvenida
        mock_send.assert_called_once()
        assert "bienvenida" in mock_send.call_args[1]["subject"].lower()

5. Mejores Prácticas de Testing

5.1 Estructura de Tests (Arrange-Act-Assert)

def test_ejemplo_estructurado():
    """
    Estructura AAA: Arrange - Act - Assert
    """
    # ARRANGE: Preparar el contexto
    usuario_data = {
        "username": "testuser",
        "email": "test@example.com"
    }
    servicio = UsuarioService()
    
    # ACT: Ejecutar la acción a testear
    resultado = servicio.crear_usuario(**usuario_data)
    
    # ASSERT: Verificar los resultados
    assert resultado["username"] == "testuser"
    assert resultado["email"] == "test@example.com"
    assert "id" in resultado

5.2 Nombres Descriptivos

# ❌ MAL: Nombre poco descriptivo
def test1():
    assert calcular(2, 3) == 5

# ✅ BIEN: Describe qué se testea y el resultado esperado
def test_suma_dos_numeros_positivos_retorna_resultado_correcto():
    assert calcular(2, 3) == 5

# ❌ MAL: No indica el escenario
def test_usuario():
    assert crear_usuario("test")

# ✅ BIEN: Describe el escenario específico
def test_crear_usuario_con_email_valido_guarda_en_base_de_datos():
    assert crear_usuario("test@test.com")

5.3 Un Assert por Test (Idealmente)

# ❌ MAL: Múltiples asserts no relacionados
def test_usuario_completo():
    usuario = crear_usuario("test", "test@test.com")
    assert usuario.username == "test"
    assert usuario.email == "test@test.com"
    assert usuario.esta_activo() is True
    assert usuario.ultimo_login is None

# ✅ BIEN: Dividir en tests específicos
def test_usuario_tiene_username_correcto():
    usuario = crear_usuario("test", "test@test.com")
    assert usuario.username == "test"

def test_usuario_nuevo_esta_activo():
    usuario = crear_usuario("test", "test@test.com")
    assert usuario.esta_activo() is True

def test_usuario_nuevo_no_ha_iniciado_sesion():
    usuario = crear_usuario("test", "test@test.com")
    assert usuario.ultimo_login is None

5.4 Tests Independientes

Nunca hagas que un test dependa de otro:
  • Cada test debe poder ejecutarse solo
  • El orden de ejecución no debe importar
  • Usa fixtures con scope="function" para estado limpio
# ❌ MAL: Test 2 depende del Test 1
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():  # Falla si test_crear_usuario no se ejecutó primero
    usuario = obtener_usuario(usuario_id)
    assert usuario.username == "test"

# ✅ BIEN: Tests independientes
def test_crear_usuario():
    usuario = crear_usuario("test")
    assert usuario.id is not None
    assert usuario.username == "test"

def test_obtener_usuario():
    # Crea su propio usuario
    usuario_creado = crear_usuario("test2")
    usuario_obtenido = obtener_usuario(usuario_creado.id)
    assert usuario_obtenido.username == "test2"

5.5 Cobertura de Tests

# Instalar pytest-cov
pip install pytest-cov

# Ejecutar tests con cobertura
pytest --cov=taskflow --cov-report=html --cov-report=term-missing

# Generar reporte en HTML (abrir htmlcov/index.html)
# Ver porcentaje de líneas cubiertas por tests
Meta de cobertura: Apunta al 80-90% de cobertura. No busques el 100% a costa de tests triviales. Es mejor tener tests significativos que tests que solo ejecutan código.

Ejercicio Práctico: Sistema de Gestión de Tareas

Objetivo

Crear un conjunto completo de tests para el API de TaskFlow aplicando todos los conceptos aprendidos:

  • Fixtures para preparar datos
  • Parametrización para múltiples casos
  • Mocks para aislar dependencias
  • TestClient para testear la API

Pasos del Ejercicio

Paso 1: Crear conftest.py

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from taskflow.main import app, tareas_db

@pytest.fixture
def client():
    """Fixture que proporciona un cliente limpio."""
    tareas_db.clear()
    with TestClient(app) as test_client:
        yield test_client

@pytest.fixture
def tarea_ejemplo():
    """Fixture con datos de una tarea de ejemplo."""
    return {
        "titulo": "Tarea de prueba",
        "descripcion": "Descripción de ejemplo",
        "completada": False
    }

Paso 2: Test de CRUD Básico

# tests/test_tareas_crud.py
import pytest
from fastapi.testclient import TestClient

class TestTareasCRUD:
    """Tests para operaciones CRUD de tareas."""
    
    def test_crear_tarea_retorna_201(self, client, tarea_ejemplo):
        """Crear tarea debe retornar código 201."""
        response = client.post("/tareas", json=tarea_ejemplo)
        assert response.status_code == 201
    
    def test_crear_tarea_asigna_id(self, client, tarea_ejemplo):
        """Crear tarea debe asignar un ID automático."""
        response = client.post("/tareas", json=tarea_ejemplo)
        data = response.json()
        assert "id" in data
        assert data["id"] is not None
    
    def test_listar_tareas_retorna_lista(self, client):
        """Listar tareas debe retornar una lista."""
        response = client.get("/tareas")
        assert response.status_code == 200
        assert isinstance(response.json(), list)
    
    def test_obtener_tarea_existente_retorna_200(self, client, tarea_ejemplo):
        """Obtener tarea existente debe retornar 200."""
        created = client.post("/tareas", json=tarea_ejemplo).json()
        response = client.get(f"/tareas/{created['id']}")
        assert response.status_code == 200
    
    def test_obtener_tarea_no_existente_retorna_404(self, client):
        """Obtener tarea inexistente debe retornar 404."""
        response = client.get("/tareas/99999")
        assert response.status_code == 404

Paso 3: Test Parametrizado de Validaciones

# tests/test_tareas_validacion.py
import pytest

class TestTareasValidacion:
    """Tests parametrizados para validaciones."""
    
    @pytest.mark.parametrize(
        "titulo, es_valido, descripcion",
        [
            ("Tarea válida", True, "Título normal"),
            ("A", True, "Mínimo 1 caracter (ajuste según tu validación)"),
            ("", False, "Título vacío no permitido"),
            ("x" * 101, False, "Título excede 100 caracteres"),
            ("   ", False, "Título solo espacios"),
        ]
    )
    def test_validacion_titulo(self, client, titulo, es_valido, descripcion):
        """Test parametrizado para validaciones de título."""
        print(f"\n   Probando: {descripcion}")
        
        response = client.post("/tareas", json={"titulo": titulo})
        
        if es_valido:
            assert response.status_code == 201, f"Esperado 201, obtenido {response.status_code}"
        else:
            assert response.status_code == 422, f"Esperado 422, obtenido {response.status_code}"
    
    @pytest.mark.parametrize(
        "campo_extra",
        [
            {"prioridad": "alta"},
            {"fecha_limite": "2026-12-31"},
            {"asignado_a": "usuario123"},
        ]
    )
    def test_campos_adicionales_son_ignorados(self, client, campo_extra):
        """Campos no definidos en el modelo deben ser ignorados."""
        datos = {"titulo": "Tarea", **campo_extra}
        response = client.post("/tareas", json=datos)
        assert response.status_code == 201
        
        # Verificar que el campo extra no se guardó
        tarea = response.json()
        assert "prioridad" not in tarea
        assert "fecha_limite" not in tarea

Paso 4: Test con Mocks

# tests/test_tareas_mocks.py
import pytest
from unittest.mock import patch, Mock

class TestTareasConMocks:
    """Tests que utilizan mocks para aislar dependencias."""
    
    @patch('taskflow.routers.tareas.enviar_notificacion')
    def test_crear_tarea_envia_notificacion(self, mock_notificacion, client):
        """Al crear tarea, debe enviarse una notificación."""
        mock_notificacion.return_value = {"status": "sent"}
        
        response = client.post("/tareas", json={"titulo": "Nueva tarea"})
        
        assert response.status_code == 201
        mock_notificacion.assert_called_once()
        
        # Verificar que se envió con el título correcto
        llamada = mock_notificacion.call_args
        assert "Nueva tarea" in str(llamada)
    
    @patch('taskflow.services.notificacion_service.NotificadorEmail')
    def test_error_notificacion_no_impide_creacion(self, MockNotificador, client):
        """Si la notificación falla, la tarea igual debe crearse."""
        mock_notif = MockNotificador.return_value
        mock_notif.enviar.side_effect = Exception("Servicio de email caído")
        
        response = client.post("/tareas", json={"titulo": "Tarea importante"})
        
        # La tarea se crea a pesar del error en notificación
        assert response.status_code == 201
        assert response.json()["titulo"] == "Tarea importante"
    
    def test_creacion_tarea_con_servicio_externo_mockeado(self, client):
        """Test completo con múltiples mocks."""
        with patch('taskflow.routers.tareas.validar_titulo') as mock_validar, \
             patch('taskflow.routers.tareas.registrar_actividad') as mock_registrar:
            
            mock_validar.return_value = True
            mock_registrar.return_value = {"log_id": 123}
            
            response = client.post("/tareas", json={"titulo": "Test"})
            
            assert response.status_code == 201
            mock_validar.assert_called_once_with("Test")
            mock_registrar.assert_called_once()

Paso 5: Test de Integración

# tests/test_tareas_integracion.py
import pytest

class TestTareasIntegracion:
    """Tests de integración que verifican flujos completos."""
    
    def test_flujo_completo_ciclo_vida_tarea(self, client):
        """
        Test de integración: Crear → Listar → Actualizar → Eliminar.
        """
        # 1. Crear tarea
        response = client.post("/tareas", json={
            "titulo": "Tarea integración",
            "descripcion": "Flujo completo"
        })
        assert response.status_code == 201
        tarea_id = response.json()["id"]
        
        # 2. Verificar que aparece en la lista
        response = client.get("/tareas")
        tareas = response.json()
        assert any(t["id"] == tarea_id for t in tareas)
        
        # 3. Obtener detalle
        response = client.get(f"/tareas/{tarea_id}")
        assert response.json()["titulo"] == "Tarea integración"
        
        # 4. Marcar como completada
        response = client.patch(f"/tareas/{tarea_id}", json={"completada": True})
        assert response.json()["completada"] is True
        
        # 5. Eliminar
        response = client.delete(f"/tareas/{tarea_id}")
        assert response.status_code == 200
        
        # 6. Verificar que ya no existe
        response = client.get(f"/tareas/{tarea_id}")
        assert response.status_code == 404
    
    def test_concurrente_creacion_tareas(self, client):
        """Test que crea múltiples tareas y verifica independencia."""
        tareas_creadas = []
        
        # Crear 5 tareas
        for i in range(5):
            response = client.post("/tareas", json={"titulo": f"Tarea {i}"})
            tareas_creadas.append(response.json())
        
        # Verificar que cada una tiene ID único
        ids = [t["id"] for t in tareas_creadas]
        assert len(ids) == len(set(ids)), "Los IDs deben ser únicos"
        
        # Verificar que todas están en la lista
        response = client.get("/tareas")
        tareas_listadas = response.json()
        assert len(tareas_listadas) == 5
Requisitos para E2:
  • Cobertura mínima del 80%
  • Al menos 3 fixtures diferentes
  • Al menos 5 tests parametrizados
  • Al menos 3 mocks/patches
  • Tests de integración completos

Resumen y Checklist

Hemos cubierto las técnicas avanzadas de testing con pytest:

Fixtures
  • Preparan datos/entorno automáticamente
  • Usan yield para setup/teardown
  • Scopes: function, class, module, session
  • conftest.py para compartir
  • Pueden depender de otras fixtures
Parametrización
  • Un test, múltiples casos
  • Sintaxis: @pytest.mark.parametrize
  • Evita código duplicado
  • Cada caso aparece individualmente
  • Producto cartesiano con múltiples
Mocks
  • Aíslan código de dependencias
  • Mock() para objetos simulados
  • @patch para reemplazar
  • return_value y side_effect
  • Verificación de llamadas
TestClient
  • Testea APIs sin servidor
  • Basado en httpx
  • Métodos HTTP completos
  • Soporte para headers/auth
  • Compatible con mocks

Checklist de Buenas Prácticas

Videos Recomendados para Profundizar
Fixtures en pytest
Ver "Introduction to Fixtures" 9:20 min | Indian Pythonista
Mocking Avanzado
Ver "Advanced Mocking" 17:17 min | Indian Pythonista
← Anterior: Introducción a TDD
Clase 8 de 25
Siguiente: BDD →