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 PythonistaPara 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
- Definimos
usuario()como una fixture con@pytest.fixture - Los tests
test_usuario_tiene_email_validoytest_usuario_esta_activorecibenusuariocomo parámetro - pytest detecta automáticamente que necesitan la fixture y la ejecuta antes de cada test
- 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)
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
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
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"]
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"]
- pytest detecta que
test_app_esta_inicializadanecesitaapp_configurada - Para crear
app_configurada, necesitaconfiguracion_completa - Para crear
configuracion_completa, necesitaconfiguracion_base - pytest ejecuta en orden: configuracion_base → configuracion_completa → app_configurada
- 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
# ...
"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], ...
- 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?
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
return_value: Define el valor de retornoside_effect: Define una función a ejecutar o excepción a lanzarassert_called_*: Verifica que se llamó como esperamoscall_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()
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
| ✅ APIs externas | No depender de servicios de terceros |
| ✅ Bases de datos | Tests más rápidos, datos predecibles |
| ✅ Servicios de email | No enviar spam en tests |
| ✅ Funciones aleatorias | Tests determinísticos |
| ✅ Reloj/fecha | Testear comportamiento temporal |
| ❌ No uses para todo | Algunos 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
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
- 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
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
- 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:
- Preparan datos/entorno automáticamente
- Usan
yieldpara setup/teardown - Scopes: function, class, module, session
conftest.pypara compartir- Pueden depender de otras fixtures
- Un test, múltiples casos
- Sintaxis:
@pytest.mark.parametrize - Evita código duplicado
- Cada caso aparece individualmente
- Producto cartesiano con múltiples
- Aíslan código de dependencias
Mock()para objetos simulados@patchpara reemplazarreturn_valueyside_effect- Verificación de llamadas
- Testea APIs sin servidor
- Basado en httpx
- Métodos HTTP completos
- Soporte para headers/auth
- Compatible con mocks