Patrones de Pruebas en Python
Estrategias integrales de prueba para aplicaciones Python usando pytest, metodología TDD y mejores prácticas.
Cuándo Activar
- Al escribir nuevo código Python (seguir TDD: rojo, verde, refactorizar)
- Al diseñar suites de pruebas para proyectos Python
- Al revisar la cobertura de pruebas Python
- Al configurar infraestructura de pruebas
Filosofía Central de Pruebas
Desarrollo Guiado por Pruebas (TDD)
Sigue siempre 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 = suma(2, 3)
assert resultado == 5
# Paso 2: Escribir implementación mínima (VERDE)
def suma(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 100% de cobertura
- Usa
pytest --covpara medir la cobertura
pytest --cov=mi_paquete --cov-report=term-missing --cov-report=html
Fundamentos de pytest
Estructura Básica de Prueba
import pytest
def test_suma():
"""Prueba suma básica."""
assert 2 + 2 == 4
def test_mayusculas_cadena():
"""Prueba conversión a mayúsculas."""
texto = "hola"
assert texto.upper() == "HOLA"
def test_agregar_lista():
"""Prueba agregar a lista."""
elementos = [1, 2, 3]
elementos.append(4)
assert 4 in elementos
assert len(elementos) == 4
Aserciones
# Igualdad
assert resultado == esperado
# Desigualdad
assert resultado != inesperado
# Veracidad
assert resultado # Verdadero
assert not resultado # Falso
assert resultado is True # Exactamente True
assert resultado 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 no válida"):
raise ValueError("entrada no vá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": "Alicia", "edad": 30}
def test_datos_muestra(datos_muestra):
"""Prueba usando el fixture."""
assert datos_muestra["nombre"] == "Alicia"
assert datos_muestra["edad"] == 30
Fixture con Configuración/Limpieza
@pytest.fixture
def base_datos():
"""Fixture con configuración y limpieza."""
# Configuración
db = BaseDatos(":memoria:")
db.crear_tablas()
db.insertar_datos_prueba()
yield db # Proporcionar a la prueba
# Limpieza
db.cerrar()
def test_consulta_base_datos(base_datos):
"""Prueba operaciones de base de datos."""
resultado = base_datos.consulta("SELECT * FROM usuarios")
assert len(resultado) > 0
Ámbitos de Fixture
# Ámbito 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")
# Ámbito de módulo - se ejecuta una vez por módulo
@pytest.fixture(scope="module")
def db_modulo():
db = BaseDatos(":memoria:")
db.crear_tablas()
yield db
db.cerrar()
# Ámbito 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):
"""La prueba se ejecuta 3 veces, una por cada parámetro."""
assert numero > 0
Usando Múltiples Fixtures
@pytest.fixture
def usuario():
return Usuario(id=1, nombre="Alicia")
@pytest.fixture
def administrador():
return Usuario(id=2, nombre="Admin", rol="admin")
def test_interaccion_usuario_admin(usuario, administrador):
"""Prueba usando múltiples fixtures."""
assert administrador.puede_gestionar(usuario)
Fixtures Autouse
@pytest.fixture(autouse=True)
def resetear_config():
"""Se ejecuta automáticamente antes de cada prueba."""
Config.reiniciar()
yield
Config.limpiar()
def test_sin_llamada_fixture():
# resetear_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(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def cabeceras_auth(cliente):
"""Genera cabeceras 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):
"""La 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
Parametrizar con IDs
@pytest.mark.parametrize("entrada,esperado", [
("[email protected]", True),
("invalido", False),
("@sin-dominio.com", False),
], ids=["email-valido", "falta-arroba", "falta-dominio"])
def test_validacion_email(entrada, esperado):
"""Prueba validación de email con IDs de prueba 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 BaseDatos(":memoria:")
elif request.param == "postgresql":
return BaseDatos("postgresql://localhost/test")
elif request.param == "mysql":
return BaseDatos("mysql://localhost/test")
def test_operaciones_base_datos(db):
"""La prueba se ejecuta 3 veces, una por cada base de datos."""
resultado = db.consulta("SELECT 1")
assert resultado is not None
Marcadores y Selección de Pruebas
Marcadores Personalizados
# Marcar pruebas lentas
@pytest.mark.lento
def test_operacion_lenta():
time.sleep(5)
# Marcar pruebas de integración
@pytest.mark.integracion
def test_integracion_api():
respuesta = requests.get("https://api.example.com")
assert respuesta.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 lento"
# Ejecutar solo pruebas de integración
pytest -m integracion
# Ejecutar pruebas de integración o lentas
pytest -m "integracion or lento"
# Ejecutar pruebas marcadas como unitarias pero no lentas
pytest -m "unitaria and not lento"
Configurar Marcadores en pytest.ini
[pytest]
markers =
lento: marca pruebas como lentas
integracion: marca pruebas como pruebas de integración
unitaria: marca pruebas como pruebas unitarias
django: marca pruebas que requieren Django
Mocking y Patching
Mocking de Funciones
from unittest.mock import patch, Mock
@patch("mipaquete.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("mipaquete.BaseDatos.conectar")
def test_conexion_base_datos(conectar_mock):
"""Prueba con conexión de base de datos simulada."""
conectar_mock.return_value = MockConexion()
db = BaseDatos()
db.conectar()
conectar_mock.assert_called_once_with("localhost")
Mocking de Excepciones
@patch("mipaquete.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("mipaquete.ConexionBD", autospec=True)
def test_autospec(db_mock):
"""Prueba con autospec para detectar mal uso de la API."""
db = db_mock.return_value
db.consulta("SELECT * FROM usuarios")
# Esto fallaría si ConexionBD no tiene el método consulta
db_mock.assert_called_once()
Mock de Instancias de Clase
class TestServicioUsuario:
@patch("mipaquete.RepositorioUsuario")
def test_crear_usuario(self, repo_mock):
"""Prueba creación de usuario con repositorio simulado."""
repo_mock.return_value.guardar.return_value = Usuario(id=1, nombre="Alicia")
servicio = ServicioUsuario(repo_mock.return_value)
usuario = servicio.crear_usuario(nombre="Alicia")
assert usuario.nombre == "Alicia"
repo_mock.return_value.guardar.assert_called_once()
Mock de Propiedad
@pytest.fixture
def mock_config():
"""Crea un mock con una propiedad."""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="clave-prueba")
return config
def test_con_mock_config(mock_config):
"""Prueba con propiedades de configuración simuladas."""
assert mock_config.debug is True
assert mock_config.api_key == "clave-prueba"
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 suma_asincrona(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("mipaquete.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
Prueba 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 no válida"):
validar_entrada("inválido")
Prueba 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
Prueba 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)
Prueba con el 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
Prueba con el Fixture tmpdir
def test_con_directorio_temporal(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_utils.py
│ └── test_servicios.py
├── integracion/ # 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 TestServicioUsuario:
"""Agrupar pruebas relacionadas en una clase."""
@pytest.fixture(autouse=True)
def setup(self):
"""Configuración se ejecuta antes de cada prueba en esta clase."""
self.servicio = ServicioUsuario()
def test_crear_usuario(self):
"""Prueba creación de usuario."""
usuario = self.servicio.crear_usuario("Alicia")
assert usuario.nombre == "Alicia"
def test_eliminar_usuario(self):
"""Prueba eliminación de usuario."""
usuario = self.servicio.crear_usuario("Bob")
self.servicio.eliminar_usuario(usuario.id)
assert not self.servicio.existe(usuario.id)
Fixtures Integradas de pytest
Uso de Fixtures Integradas
# tests/conftest.py
import pytest
@pytest.fixture
def datos_prueba():
return {"a": 1, "b": 2}
# tests/test_ejemplo.py
def test_con_fixture_integrada(tmp_path, datos_prueba):
"""Usar fixtures integradas y personalizadas juntas."""
archivo = tmp_path / "datos.json"
# ...
assert datos_prueba["a"] == 1
Fixtures Integradas Comunes
tmp_path: Directorio temporal único por función de pruebatmpdir: Similar a tmp_path pero como objeto py.path.localcapsys: Captura salida estándar y de errormonkeypatch: Parchea objetos/diccionarios de forma segurarecwarn: Verifica advertencias emitidas
Mejores Prácticas
Nombrado de Pruebas
- Prefijo
test_para funciones de prueba y archivos - Nombres descriptivos que expliquen la intención de la prueba
- Seguir el patrón
test_[unidad]_[comportamiento]_[resultado]
Estructura Arrange-Act-Assert (AAA)
def test_precio_total_con_impuesto():
"""Usar patrón AAA."""
# Arrange (Preparar)
carrito = Carrito()
carrito.agregar(Producto("libro", 100.0))
# Act (Actuar)
total = carrito.calcular_total(impuesto=0.1)
# Assert (Afirmar)
assert total == 110.0
Pruebas Independientes y Aisladas
- Cada prueba debe poder ejecutarse de forma independiente
- No compartir estado mutable entre pruebas
- Usar fixtures para configuración y limpieza
Consejos de Cobertura
- Apuntar a un 80%+ de cobertura general
- Cobertura del 100% para rutas críticas y manejo de errores
- Usar
# pragma: no covercon moderación para exclusiones justificadas
Referencia Rápida
Comandos Comunes de pytest
# Ejecutar todas las pruebas
pytest
# Ejecutar con salida detallada
pytest -v
# Ejecutar una prueba específica
pytest tests/test_modulo.py::test_funcion -v
# Detener en el primer fallo
pytest -x
# Ejecutar pruebas en paralelo
pytest -n auto
# Ejecutar pruebas que fallaron la última vez
pytest --lf
# Ejecutar pruebas que fallaron primero
pytest --ff
# Ejecutar pruebas por expresión
pytest -k "usuario and not lento"