Patrones de Pruebas en Python
Estrategias integrales de pruebas para aplicaciones Python usando pytest, metodología TDD y mejores prácticas.
Cuándo Activar
- Escribir código nuevo en Python (seguir TDD: rojo, verde, refactorizar)
- Diseñar conjuntos de pruebas para proyectos Python
- Revisar cobertura de pruebas en Python
- Configurar infraestructura de pruebas
Filosofía Central de Pruebas
Desarrollo Guiado por Pruebas (TDD)
Siempre sigue el ciclo TDD:
- ROJO: Escribe una prueba fallida para el comportamiento deseado
- VERDE: Escribe el código mínimo para que la prueba pase
- REFACTORIZAR: Mejora el código mientras mantienes las pruebas en verde
# Paso 1: Escribir prueba fallida (ROJO)
def test_sumar_numeros():
resultado = sumar(2, 3)
assert resultado == 5
# Paso 2: Escribir implementación mínima (VERDE)
def sumar(a, b):
return a + b
# Paso 3: Refactorizar si es necesario (REFACTORIZAR)
Requisitos de Cobertura
- Objetivo: 80%+ de cobertura de código
- Rutas críticas: Se requiere cobertura del 100%
- Usa
pytest --covpara medir la cobertura
pytest --cov=mipaquete --cov-report=term-missing --cov-report=html
Fundamentos de pytest
Estructura Básica de Pruebas
import pytest
def test_suma():
"""Prueba suma básica."""
assert 2 + 2 == 4
def test_mayusculas():
"""Prueba conversión a mayúsculas."""
texto = "hola"
assert texto.upper() == "HOLA"
def test_agregar_lista():
"""Prueba agregar a lista."""
items = [1, 2, 3]
items.append(4)
assert 4 in items
assert len(items) == 4
Aserciones
# Igualdad
assert resultado == esperado
# Desigualdad
assert resultado != inesperado
# Veracidad
assert resultado # Verdadero
assert not resultado # Falso
assert result is True # Exactamente True
assert result is False # Exactamente False
assert result is None # Exactamente None
# Pertenencia
assert elemento in colección
assert elemento not in colección
# Comparaciones
assert resultado > 0
assert 0 <= resultado <= 100
# Verificación de tipo
assert isinstance(resultado, str)
# Prueba de excepciones (enfoque preferido)
with pytest.raises(ValueError):
raise ValueError("mensaje de error")
# Verificar mensaje de excepción
with pytest.raises(ValueError, match="entrada inválida"):
raise ValueError("entrada inválida proporcionada")
# Verificar atributos de excepción
with pytest.raises(ValueError) as exc_info:
raise ValueError("mensaje de error")
assert str(exc_info.value) == "mensaje de error"
Fixtures
Uso Básico de Fixtures
import pytest
@pytest.fixture
def datos_muestra():
"""Fixture que proporciona datos de muestra."""
return {"nombre": "Alice", "edad": 30}
def test_datos_muestra(datos_muestra):
"""Prueba usando el fixture."""
assert datos_muestra["nombre"] == "Alice"
assert datos_muestra["edad"] == 30
Fixture con Configuración/Limpieza
@pytest.fixture
def base_datos():
"""Fixture con configuración y limpieza."""
# Configuración
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # Proporcionar a la prueba
# Limpieza
db.close()
def test_consulta_base_datos(base_datos):
"""Prueba operaciones de base de datos."""
resultado = base_datos.query("SELECT * FROM usuarios")
assert len(resultado) > 0
Alcances de Fixtures
# Alcance de función (predeterminado) - se ejecuta para cada prueba
@pytest.fixture
def archivo_temporal():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# Alcance de módulo - se ejecuta una vez por módulo
@pytest.fixture(scope="module")
def db_modulo():
db = Database(":memory:")
db.create_tables()
yield db
db.close()
# Alcance de sesión - se ejecuta una vez por sesión de prueba
@pytest.fixture(scope="session")
def recurso_compartido():
recurso = RecursoCostoso()
yield recurso
recurso.limpiar()
Fixture con Parámetros
@pytest.fixture(params=[1, 2, 3])
def numero(request):
"""Fixture parametrizado."""
return request.param
def test_numeros(numero):
"""Prueba se ejecuta 3 veces, una para cada parámetro."""
assert numero > 0
Usando Múltiples Fixtures
@pytest.fixture
def usuario():
return User(id=1, nombre="Alice")
@pytest.fixture
def admin():
return User(id=2, nombre="Admin", rol="admin")
def test_interaccion_usuario_admin(usuario, admin):
"""Prueba usando múltiples fixtures."""
assert admin.puede_gestionar(usuario)
Fixtures Automáticos
@pytest.fixture(autouse=True)
def restablecer_config():
"""Se ejecuta automáticamente antes de cada prueba."""
Config.restablecer()
yield
Config.limpiar()
def test_sin_llamar_fixture():
# restablecer_config se ejecuta automáticamente
assert Config.obtener_config("debug") is False
Conftest.py para Fixtures Compartidos
# tests/conftest.py
import pytest
@pytest.fixture
def cliente():
"""Fixture compartido para todas las pruebas."""
app = crear_app(pruebas=True)
with app.test_client() as client:
yield client
@pytest.fixture
def encabezados_auth(cliente):
"""Genera encabezados de autenticación para pruebas de API."""
respuesta = cliente.post("/api/login", json={
"usuario": "test",
"contraseña": "test"
})
token = respuesta.json["token"]
return {"Authorization": f"Bearer {token}"}
Parametrización
Parametrización Básica
@pytest.mark.parametrize("entrada,esperado", [
("hola", "HOLA"),
("mundo", "MUNDO"),
("PyThOn", "PYTHON"),
])
def test_mayusculas(entrada, esperado):
"""Prueba se ejecuta 3 veces con diferentes entradas."""
assert entrada.upper() == esperado
Múltiples Parámetros
@pytest.mark.parametrize("a,b,esperado", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_sumar(a, b, esperado):
"""Prueba suma con múltiples entradas."""
assert sumar(a, b) == esperado
Parametrización con IDs
@pytest.mark.parametrize("entrada,esperado", [
("[email protected]", True),
("invalido", False),
("@sin-dominio.com", False),
], ids=["email-valido", "sin-arroba", "sin-dominio"])
def test_validacion_email(entrada, esperado):
"""Prueba validación de email con IDs legibles."""
assert es_email_valido(entrada) is esperado
Fixtures Parametrizados
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""Prueba contra múltiples motores de base de datos."""
if request.param == "sqlite":
return Database(":memory:")
elif request.param == "postgresql":
return Database("postgresql://localhost/test")
elif request.param == "mysql":
return Database("mysql://localhost/test")
def test_operaciones_base_datos(db):
"""Prueba se ejecuta 3 veces, una para cada base de datos."""
resultado = db.query("SELECT 1")
assert resultado is not None
Marcadores y Selección de Pruebas
Marcadores Personalizados
# Marcar pruebas lentas
@pytest.mark.lenta
def test_operacion_lenta():
time.sleep(5)
# Marcar pruebas de integración
@pytest.mark.integracion
def test_integracion_api():
response = requests.get("https://api.example.com")
assert response.status_code == 200
# Marcar pruebas unitarias
@pytest.mark.unitaria
def test_logica_unitaria():
assert calcular(2, 3) == 5
Ejecutar Pruebas Específicas
# Ejecutar solo pruebas rápidas
pytest -m "not lenta"
# Ejecutar solo pruebas de integración
pytest -m integracion
# Ejecutar pruebas de integración o lentas
pytest -m "integracion or lenta"
# Ejecutar pruebas marcadas como unitarias pero no lentas
pytest -m "unitaria and not lenta"
Configurar Marcadores en pytest.ini
[pytest]
markers =
lenta: marca las pruebas como lentas
integracion: marca las pruebas como pruebas de integración
unitaria: marca las pruebas como pruebas unitarias
django: marca las pruebas como que requieren Django
Mocking y Patching
Mocking de Funciones
from unittest.mock import patch, Mock
@patch("mypackage.llamada_api_externa")
def test_con_mock(llamada_api_mock):
"""Prueba con API externa simulada."""
llamada_api_mock.return_value = {"estado": "éxito"}
resultado = mi_funcion()
llamada_api_mock.assert_called_once()
assert resultado["estado"] == "éxito"
Mocking de Valores de Retorno
@patch("mypackage.Database.conectar")
def test_conexion_base_datos(conectar_mock):
"""Prueba con conexión de base de datos simulada."""
conectar_mock.return_value = MockConexion()
db = Database()
db.conectar()
conectar_mock.assert_called_once_with("localhost")
Mocking de Excepciones
@patch("mypackage.llamada_api")
def test_manejo_error_api(llamada_api_mock):
"""Prueba manejo de errores con excepción simulada."""
llamada_api_mock.side_effect = ConnectionError("Error de red")
with pytest.raises(ConnectionError):
llamada_api()
llamada_api_mock.assert_called_once()
Mocking de Administradores de Contexto
@patch("builtins.open", new_callable=mock_open)
def test_lectura_archivo(archivo_mock):
"""Prueba lectura de archivo con open simulado."""
archivo_mock.return_value.read.return_value = "contenido del archivo"
resultado = leer_archivo("test.txt")
archivo_mock.assert_called_once_with("test.txt", "r")
assert resultado == "contenido del archivo"
Usando Autospec
@patch("mypackage.DBConnection", autospec=True)
def test_autospec(db_mock):
"""Prueba con autospec para detectar mal uso de API."""
db = db_mock.return_value
db.query("SELECT * FROM usuarios")
# Esto fallaría si DBConnection no tiene el método query
db_mock.assert_called_once()
Mock de Instancias de Clase
class TestUserService:
@patch("mypackage.UserRepository")
def test_crear_usuario(self, repo_mock):
"""Prueba creación de usuario con repositorio simulado."""
repo_mock.return_value.guardar.return_value = User(id=1, nombre="Alice")
servicio = UserService(repo_mock.return_value)
usuario = servicio.crear_usuario(nombre="Alice")
assert usuario.nombre == "Alice"
repo_mock.return_value.guardar.assert_called_once()
Mock de Propiedades
@pytest.fixture
def config_mock():
"""Crear un mock con una propiedad."""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="test-key")
return config
def test_con_config_mock(config_mock):
"""Prueba con propiedades de configuración simuladas."""
assert config_mock.debug is True
assert config_mock.api_key == "test-key"
Pruebas de Código Asíncrono
Pruebas Asíncronas con pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_funcion_asincrona():
"""Prueba función asíncrona."""
resultado = await sumar_asincrono(2, 3)
assert resultado == 5
@pytest.mark.asyncio
async def test_asincrono_con_fixture(cliente_asincrono):
"""Prueba asíncrona con fixture asíncrono."""
respuesta = await cliente_asincrono.get("/api/usuarios")
assert respuesta.status_code == 200
Fixture Asíncrono
@pytest.fixture
async def cliente_asincrono():
"""Fixture asíncrono que proporciona un cliente de prueba asíncrono."""
app = crear_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_endpoint_api(cliente_asincrono):
"""Prueba usando fixture asíncrono."""
respuesta = await cliente_asincrono.get("/api/datos")
assert respuesta.status_code == 200
Mocking de Funciones Asíncronas
@pytest.mark.asyncio
@patch("mypackage.llamada_api_asincrona")
async def test_mock_asincrono(llamada_api_mock):
"""Prueba función asíncrona con mock."""
llamada_api_mock.return_value = {"estado": "ok"}
resultado = await mi_funcion_asincrona()
llamada_api_mock.assert_awaited_once()
assert resultado["estado"] == "ok"
Pruebas de Excepciones
Pruebas de Excepciones Esperadas
def test_dividir_por_cero():
"""Prueba que dividir por cero lanza ZeroDivisionError."""
with pytest.raises(ZeroDivisionError):
dividir(10, 0)
def test_excepcion_personalizada():
"""Prueba excepción personalizada con mensaje."""
with pytest.raises(ValueError, match="entrada inválida"):
validar_entrada("inválido")
Pruebas de Atributos de Excepción
def test_excepcion_con_detalles():
"""Prueba excepción con atributos personalizados."""
with pytest.raises(ErrorPersonalizado) as exc_info:
raise ErrorPersonalizado("error", codigo=400)
assert exc_info.value.codigo == 400
assert "error" in str(exc_info.value)
Pruebas de Efectos Secundarios
Pruebas de Operaciones con Archivos
import tempfile
import os
def test_procesamiento_archivo():
"""Prueba procesamiento de archivo con archivo temporal."""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("contenido de prueba")
ruta_temp = f.name
try:
resultado = procesar_archivo(ruta_temp)
assert resultado == "procesado: contenido de prueba"
finally:
os.unlink(ruta_temp)
Pruebas con Fixture tmp_path de pytest
def test_con_ruta_temporal(tmp_path):
"""Prueba usando el fixture de ruta temporal incorporado de pytest."""
archivo_prueba = tmp_path / "test.txt"
archivo_prueba.write_text("hola mundo")
resultado = procesar_archivo(str(archivo_prueba))
assert resultado == "hola mundo"
# tmp_path se limpia automáticamente
Pruebas con Fixture tmpdir
def test_con_tmpdir(tmpdir):
"""Prueba usando el fixture tmpdir de pytest."""
archivo_prueba = tmpdir.join("test.txt")
archivo_prueba.write("datos")
resultado = procesar_archivo(str(archivo_prueba))
assert resultado == "datos"
Organización de Pruebas
Estructura de Directorios
tests/
├── conftest.py # Fixtures compartidos
├── __init__.py
├── unit/ # Pruebas unitarias
│ ├── __init__.py
│ ├── test_modelos.py
│ ├── test_utilidades.py
│ └── test_servicios.py
├── integration/ # Pruebas de integración
│ ├── __init__.py
│ ├── test_api.py
│ └── test_base_datos.py
└── e2e/ # Pruebas de extremo a extremo
├── __init__.py
└── test_flujo_usuario.py
Clases de Prueba
class TestUserService:
"""Agrupa pruebas relacionadas en una clase."""
@pytest.fixture(autouse=True)
def configurar(self):
"""Configuración que se ejecuta antes de cada prueba en esta clase."""
self.servicio = UserService()
def test_crear_usuario(self):
"""Prueba creación de usuario."""
usuario = self.servicio.crear_usuario("Alice")
assert usuario.nombre == "Alice"
def test_eliminar_usuario(self):
"""Prueba eliminación de usuario."""
usuario = self.servicio.crear_usuario("Bob")
self.servicio.eliminar_usuario(usuario.id)
assert self.servicio.obtener_usuario(usuario.id) is None
Mejores Prácticas
Nomenclatura de Pruebas
# Nombres descriptivos de funciones de prueba
def test_calcular_impuesto_con_ingreso_positivo():
"""Prueba con nombre descriptivo."""
pass
def test_validar_email_con_formato_valido():
"""Prueba con nombre descriptivo."""
pass
def test_manejar_excepcion_base_datos():
"""Prueba con nombre descriptivo."""
pass
Aislar Pruebas
# Cada prueba debe ser independiente
class TestOperaciones:
def test_agregar_elemento(self):
lista = []
lista.append(1)
assert len(lista) == 1
def test_eliminar_elemento(self):
lista = [] # Comenzar fresco
assert len(lista) == 0
Fixture Única Responsabilidad
@pytest.fixture
def conexion_memoria():
"""Fixture para conexión de base de datos en memoria."""
return Database(":memory:")
@pytest.fixture
def datos_prueba(conexion_memoria):
"""Fixture para datos de prueba usando conexión en memoria."""
conexion_memoria.insertar_datos_prueba()
return conexion_memoria
def test_consulta(datos_prueba):
resultado = datos_prueba.query("SELECT * FROM usuarios")
assert len(resultado) == 10
Configuración de pytest
pytest.ini
[pytest]
# Configuración de proyecto
pythonpath = src
# Marcadores
markers =
lenta: marca las pruebas como lentas
integracion: marca las pruebas como pruebas de integración
# Opciones por defecto
addopts = -v --tb=short --strict-markers
# Directorio de pruebas
testpaths = tests
pyproject.toml (Alternativa Moderna)
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-v --tb=short --strict-markers"
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"lenta: marca las pruebas como lentas",
"integracion: marca las pruebas como pruebas de integración",
]
Herramientas Adicionales
Cobertura de Código
# Ejecutar pruebas con cobertura
pytest --cov=src/
# Mostrar líneas faltantes
pytest --cov=src/ --cov-report=term-missing
# Generar informe HTML
pytest --cov=src/ --cov-report=html
# Establecer umbral de cobertura
pytest --cov=src/ --cov-fail-under=80
Perfilado de Pruebas lentas
# Listar pruebas más lentas
pytest --durations=10
# Mostrar todas las duraciones
pytest --durations=0
Ejecución en Paralelo
# Ejecutar pruebas en paralelo (requiere pytest-xdist)
pytest -n auto
# Ejecutar con 4 CPUs
pytest -n 4
Patrones Comunes
Pruebas de Fábrica
class TestModeloUsuario:
def test_crear_usuario_valido(self, fábrica_usuario):
usuario = fábrica_usuario(nombre="Alice")
assert usuario.nombre == "Alice"
assert usuario.email is not None
def test_crear_usuario_con_email_personalizado(self, fábrica_usuario):
usuario = fábrica_usuario(email="[email protected]")
assert usuario.email == "[email protected]"
def test_usuario_tiene_rol_predeterminado(self, fábrica_usuario):
usuario = fábrica_usuario()
assert usuario.rol == "usuario"
@pytest.fixture
def fábrica_usuario():
def crear_usuario(**kwargs):
datos_predeterminados = {
"nombre": "Usuario Predeterminado",
"email": "[email protected]",
"rol": "usuario",
}
datos_predeterminados.update(kwargs)
return User(**datos_predeterminados)
return crear_usuario