Introducción a Pytest Avanzado
Pytest se ha convertido en el estándar de facto para pruebas en Python, potenciando suites de pruebas en organizaciones desde startups hasta compañías Fortune 500. Aunque el uso básico de pytest (como se discute en TestComplete Commercial Tool: ROI Analysis and Enterprise Test Automation) es sencillo, dominar sus características avanzadas puede transformar tu estrategia de automatización de pruebas, permitiendo suites más mantenibles, escalables y poderosas.
Esta guía completa explora técnicas avanzadas de pytest que separan a los escritores de pruebas novatos de los ingenieros expertos en automatización. Cubriremos fixtures, parametrización, markers, organización de conftest.py, el ecosistema de plugins y mejores prácticas de la industria.
Entendiendo los Fixtures de Pytest
Los fixtures son el sistema de inyección de dependencias de pytest, proporcionando un mecanismo poderoso para la configuración y desmontaje de pruebas. A diferencia de los métodos tradicionales setUp/tearDown estilo xUnit, los fixtures ofrecen mayor flexibilidad y reutilización.
Patrones Básicos de Fixtures
import pytest
from selenium import webdriver
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture
def browser():
"""Proporciona una instancia de navegador configurada."""
driver = webdriver.Chrome()
driver.implicitly_wait(10)
yield driver
driver.quit()
@pytest.fixture
def database_session() (como se discute en [Cucumber BDD Automation: Complete Guide to Behavior-Driven Development Testing](/blog/cucumber-bdd-automation)):
"""Proporciona una sesión de base de datos con rollback automático."""
engine = create_engine('postgresql://localhost/testdb')
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixture
def authenticated_api_client(api_client, test_user):
"""Proporciona un cliente API autenticado."""
api_client.login(test_user.username, test_user.password)
return api_client
Ámbitos de Fixtures y Optimización de Rendimiento
El ámbito del fixture determina cuándo ocurren la configuración y el desmontaje, impactando directamente el tiempo de ejecución de las pruebas:
@pytest.fixture(scope="function")
def function_fixture():
"""Se ejecuta antes de cada función de prueba (predeterminado)."""
return setup_resource()
@pytest.fixture(scope="class")
def class_fixture():
"""Se ejecuta una vez por clase de prueba."""
return setup_expensive_resource()
@pytest.fixture(scope="module")
def module_fixture():
"""Se ejecuta una vez por módulo de prueba."""
return setup_very_expensive_resource()
@pytest.fixture(scope="session")
def session_fixture():
"""Se ejecuta una vez por sesión de prueba."""
database = setup_database()
yield database
teardown_database(database)
Comparación de Ámbitos de Fixtures
Ámbito | Frecuencia de Ejecución | Caso de Uso | Impacto en Rendimiento |
---|---|---|---|
function | Cada prueba | Estado aislado necesario | Overhead alto |
class | Por clase de prueba | Estado compartido de clase | Overhead medio |
module | Por archivo de prueba | Configuración costosa | Overhead bajo |
session | Una vez por ejecución | Recursos globales | Overhead mínimo |
Técnicas Avanzadas de Parametrización
La parametrización habilita pruebas dirigidas por datos, permitiendo que funciones de prueba únicas validen múltiples escenarios.
Parametrización Básica
@pytest.mark.parametrize("username,password,expected", [
("admin", "admin123", True),
("user", "user123", True),
("invalid", "wrong", False),
("", "", False),
])
def test_login(username, password, expected, auth_service):
result = auth_service.login(username, password)
assert result.success == expected
Patrones Complejos de Parametrización
import pytest
from decimal import Decimal
# Múltiples conjuntos de parámetros
@pytest.mark.parametrize("quantity", [1, 5, 10, 100])
@pytest.mark.parametrize("discount", [0, 0.1, 0.25, 0.5])
def test_price_calculation(quantity, discount, pricing_engine):
"""Genera 16 combinaciones de prueba (4 x 4)."""
price = pricing_engine.calculate(quantity, discount)
assert price > 0
assert price <= quantity * pricing_engine.base_price
# Parametrizando fixtures
@pytest.fixture(params=["chrome", "firefox", "edge"])
def browser(request):
"""Ejecuta pruebas en múltiples navegadores."""
driver_name = request.param
driver = get_driver(driver_name)
yield driver
driver.quit()
# Usando pytest.param para IDs de prueba personalizados
@pytest.mark.parametrize("test_case", [
pytest.param({"input": "valid@email.com", "expected": True},
id="valid-email"),
pytest.param({"input": "invalid-email", "expected": False},
id="invalid-format"),
pytest.param({"input": "", "expected": False},
id="empty-input"),
])
def test_email_validation(test_case, validator):
result = validator.validate_email(test_case["input"])
assert result == test_case["expected"]
Parametrización Indirecta
@pytest.fixture
def user(request):
"""Crea usuario basado en parámetro."""
user_type = request.param
return UserFactory.create(user_type=user_type)
@pytest.mark.parametrize("user", ["admin", "moderator", "regular"],
indirect=True)
def test_permissions(user, resource):
"""La prueba se ejecuta tres veces con diferentes tipos de usuario."""
assert user.can_access(resource)
Markers: Organizando y Controlando Pruebas
Los markers proporcionan metadatos para organización, selección y ejecución condicional de pruebas.
Markers Integrados
import pytest
import sys
# Saltar pruebas condicionalmente
@pytest.mark.skip(reason="Característica no implementada")
def test_future_feature():
pass
@pytest.mark.skipif(sys.platform == "win32",
reason="Prueba solo para Linux")
def test_linux_specific():
pass
# Fallos esperados
@pytest.mark.xfail(reason="Bug conocido #1234")
def test_known_issue():
assert buggy_function() == expected_result
# Xfail condicional
@pytest.mark.xfail(sys.version_info < (3, 10),
reason="Requiere Python 3.10+")
def test_new_syntax():
pass
Markers Personalizados
# pytest.ini o pyproject.toml
"""
[tool.pytest.ini_options]
markers = [
"slow: marca pruebas como lentas (deseleccionar con '-m \"not slow\"')",
"integration: pruebas de integración que requieren servicios externos",
"smoke: pruebas de humo para validación rápida",
"security: pruebas relacionadas con seguridad",
"regression: suite de pruebas de regresión",
]
"""
# Usando markers personalizados
@pytest.mark.slow
@pytest.mark.integration
def test_full_system_integration():
pass
@pytest.mark.smoke
def test_critical_path():
pass
@pytest.mark.security
@pytest.mark.parametrize("payload", malicious_payloads)
def test_sql_injection(payload, database):
with pytest.raises(SecurityException):
database.execute(payload)
Comandos de Selección de Markers
# Ejecutar solo pruebas de humo
pytest -m smoke
# Excluir pruebas lentas
pytest -m "not slow"
# Ejecutar pruebas de humo O integración
pytest -m "smoke or integration"
# Ejecutar pruebas de seguridad Y regresión
pytest -m "security and regression"
# Selección compleja
pytest -m "smoke and not slow"
Dominando la Organización de conftest.py
El archivo conftest.py proporciona fixtures y configuración compartidos entre módulos de prueba.
Estructura Jerárquica de conftest.py
tests/
├── conftest.py # Fixtures a nivel de sesión
├── unit/
│ ├── conftest.py # Fixtures de pruebas unitarias
│ ├── test_models.py
│ └── test_utils.py
├── integration/
│ ├── conftest.py # Fixtures de integración
│ ├── test_api.py
│ └── test_database.py
└── e2e/
├── conftest.py # Fixtures E2E (navegadores, etc.)
└── test_user_flows.py
Ejemplo de conftest.py Raíz
# tests/conftest.py
import pytest
from pathlib import Path
def pytest_configure(config):
"""Registrar markers personalizados."""
config.addinivalue_line(
"markers", "slow: marca pruebas como lentas"
)
@pytest.fixture(scope="session")
def test_data_dir():
"""Proporciona ruta al directorio de datos de prueba."""
return Path(__file__).parent / "data"
@pytest.fixture(scope="session")
def config():
"""Carga configuración de prueba."""
return load_test_config()
@pytest.fixture(autouse=True)
def reset_database(database):
"""Reinicia automáticamente la base de datos antes de cada prueba."""
database.reset()
yield
# Limpieza si es necesario
def pytest_collection_modifyitems(config, items):
"""Marca automáticamente pruebas lentas."""
for item in items:
if "integration" in item.nodeid:
item.add_marker(pytest.mark.slow)
conftest.py de Nivel de Integración
# tests/integration/conftest.py
import pytest
import docker
@pytest.fixture(scope="module")
def docker_services():
"""Inicia contenedores Docker para pruebas de integración."""
client = docker.from_env()
# Iniciar PostgreSQL
postgres = client.containers.run(
"postgres:15",
environment={"POSTGRES_PASSWORD": "test"},
ports={"5432/tcp": 5432},
detach=True
)
# Iniciar Redis
redis = client.containers.run(
"redis:7",
ports={"6379/tcp": 6379},
detach=True
)
# Esperar a que los servicios estén listos
wait_for_postgres(postgres)
wait_for_redis(redis)
yield {"postgres": postgres, "redis": redis}
# Limpieza
postgres.stop()
redis.stop()
postgres.remove()
redis.remove()
@pytest.fixture
def api_client(docker_services, config):
"""Proporciona cliente API configurado."""
from myapp.client import APIClient
return APIClient(base_url=config.api_url)
Ecosistema de Plugins de Pytest
La arquitectura de plugins de pytest permite personalización e integración extensas.
Plugins Esenciales
# requirements-test.txt
pytest==7.4.3
pytest-cov==4.1.0 # Reportes de cobertura
pytest-xdist==3.5.0 # Ejecución paralela
pytest-timeout==2.2.0 # Timeouts de prueba
pytest-mock==3.12.0 # Utilidades de mocking
pytest-asyncio==0.23.2 # Soporte de pruebas async
pytest-django==4.7.0 # Integración Django
pytest-flask==1.3.0 # Integración Flask
pytest-bdd==7.0.1 # Soporte BDD
pytest-html==4.1.1 # Reportes HTML
pytest-json-report==1.5.0 # Reportes JSON
Usando pytest-xdist para Ejecución Paralela
# Ejecutar pruebas en 4 núcleos de CPU
pytest -n 4
# Auto-detectar conteo de CPU
pytest -n auto
# Distribuir por archivo (mejor para pruebas de integración)
pytest -n 4 --dist loadfile
# Distribuir por prueba (mejor para pruebas unitarias)
pytest -n 4 --dist loadscope
Cobertura con pytest-cov
# Generar reporte de cobertura
pytest --cov=myapp --cov-report=html
# Fallar si la cobertura está por debajo del umbral
pytest --cov=myapp --cov-fail-under=80
# Mostrar líneas faltantes
pytest --cov=myapp --cov-report=term-missing
Desarrollo de Plugin Personalizado
# conftest.py o custom_plugin.py
import pytest
import time
class PerformancePlugin:
"""Rastrea el tiempo de ejecución de pruebas."""
def __init__(self):
self.test_times = {}
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item):
"""Mide el tiempo de ejecución de la prueba."""
start = time.time()
outcome = yield
duration = time.time() - start
self.test_times[item.nodeid] = duration
if duration > 5.0:
print(f"\nPrueba lenta: {item.nodeid} ({duration:.2f}s)")
def pytest_terminal_summary(self, terminalreporter):
"""Mostrar pruebas más lentas."""
terminalreporter.section("Pruebas Más Lentas")
sorted_tests = sorted(
self.test_times.items(),
key=lambda x: x[1],
reverse=True
)[:10]
for test, duration in sorted_tests:
terminalreporter.write_line(
f"{test}: {duration:.2f}s"
)
def pytest_configure(config):
"""Registrar plugin."""
config.pluginmanager.register(PerformancePlugin())
Mejores Prácticas para Suites de Prueba en Producción
1. Estrategia de Organización de Pruebas
# Bien: Nombres descriptivos de pruebas
def test_user_registration_requires_valid_email():
pass
def test_user_registration_rejects_duplicate_email():
pass
# Mal: Nombres vagos de pruebas
def test_registration():
pass
def test_user_1():
pass
2. Principios de Diseño de Fixtures
# Bien: Fixtures de responsabilidad única
@pytest.fixture
def user():
return User(username="testuser")
@pytest.fixture
def authenticated_session(user):
return create_session(user)
# Mal: Fixture dios que hace todo
@pytest.fixture
def everything():
user = create_user()
session = login(user)
db = setup_database()
api = create_api_client()
return user, session, db, api
3. Mejores Prácticas de Aserciones
# Bien: Aserciones específicas con mensajes
def test_discount_calculation():
price = calculate_price(quantity=10, discount=0.2)
assert price == 80.0, f"Esperado 80.0, obtenido {price}"
assert isinstance(price, Decimal), "El precio debe ser Decimal"
# Bien: Múltiples aserciones enfocadas
def test_user_creation():
user = create_user(username="test")
assert user.username == "test"
assert user.is_active is True
assert user.created_at is not None
# Mal: Try-except ocultando fallos
def test_api_call():
try:
result = api.call()
assert result.status == 200
except Exception:
pass # ¡Nunca hacer esto!
4. Gestión de Datos de Prueba
# Bien: Patrón factory para datos de prueba
class UserFactory:
@staticmethod
def create(username=None, email=None, **kwargs):
return User(
username=username or f"user_{uuid.uuid4().hex[:8]}",
email=email or f"test_{uuid.uuid4().hex[:8]}@example.com",
**kwargs
)
@pytest.fixture
def user():
return UserFactory.create()
@pytest.fixture
def admin_user():
return UserFactory.create(role="admin")
# Bien: Usando faker para datos realistas
from faker import Faker
@pytest.fixture
def fake():
return Faker()
def test_user_profile(fake):
user = User(
name=fake.name(),
email=fake.email(),
address=fake.address()
)
assert user.save()
Estrategias de Optimización de Rendimiento
Comparación: Tiempos de Ejecución de Pruebas por Ámbito
Estrategia | 1000 Pruebas | Mejora |
---|---|---|
Sin optimización (ámbito function) | 450s | Línea base |
Fixtures con ámbito class | 280s | 38% más rápido |
Fixtures con ámbito module | 120s | 73% más rápido |
Ámbito session + paralelo (-n 4) | 35s | 92% más rápido |
Ámbito session + paralelo (-n auto) | 28s | 94% más rápido |
Ejemplo de Optimización
# Antes: Lento (crea nueva base de datos por prueba)
@pytest.fixture
def database():
db = create_database()
yield db
db.drop()
def test_query_1(database):
assert database.query("SELECT 1")
def test_query_2(database):
assert database.query("SELECT 2")
# Después: Rápido (reutiliza base de datos, reinicia estado)
@pytest.fixture(scope="session")
def database():
db = create_database()
yield db
db.drop()
@pytest.fixture(autouse=True)
def reset_db_state(database):
database.truncate_all_tables()
def test_query_1(database):
assert database.query("SELECT 1")
def test_query_2(database):
assert database.query("SELECT 2")
Caso de Uso Práctico: Suite de Pruebas de E-Commerce
# conftest.py
import pytest
from decimal import Decimal
@pytest.fixture(scope="session")
def database():
"""Base de datos con ámbito de sesión."""
db = create_test_database()
yield db
db.drop()
@pytest.fixture(autouse=True)
def clean_database(database):
"""Auto-limpieza entre pruebas."""
database.truncate_all()
@pytest.fixture
def product_factory(database):
"""Factory para crear productos de prueba."""
class ProductFactory:
@staticmethod
def create(name=None, price=None, stock=10):
return database.products.insert(
name=name or f"Product {uuid.uuid4().hex[:8]}",
price=price or Decimal("99.99"),
stock=stock
)
return ProductFactory
@pytest.fixture
def cart_service(database):
"""Servicio de carrito de compras."""
from myapp.services import CartService
return CartService(database)
# test_shopping_cart.py
@pytest.mark.parametrize("quantity,expected_total", [
(1, Decimal("99.99")),
(2, Decimal("199.98")),
(5, Decimal("499.95")),
])
def test_cart_total_calculation(quantity, expected_total,
cart_service, product_factory):
"""Verificar cálculo de total del carrito."""
product = product_factory.create(price=Decimal("99.99"))
cart = cart_service.create()
cart_service.add_item(cart.id, product.id, quantity)
assert cart.total == expected_total
@pytest.mark.integration
@pytest.mark.slow
def test_checkout_process(cart_service, product_factory,
payment_gateway):
"""Prueba de integración para flujo completo de checkout."""
# Configuración
product = product_factory.create(price=Decimal("50.00"), stock=10)
cart = cart_service.create()
cart_service.add_item(cart.id, product.id, 2)
# Ejecutar checkout
order = cart_service.checkout(
cart.id,
payment_method="credit_card",
shipping_address=test_address
)
# Verificar
assert order.status == "confirmed"
assert order.total == Decimal("100.00")
assert product.reload().stock == 8
assert payment_gateway.captured_amount == Decimal("100.00")
Conclusión
Dominar las técnicas avanzadas de pytest transforma la automatización de pruebas de una tarea pesada a una ventaja competitiva. Al aprovechar los fixtures efectivamente, usar parametrización para pruebas dirigidas por datos, organizar pruebas con markers, estructurar conftest.py jerárquicamente y utilizar el ecosistema de plugins, los equipos pueden construir suites de prueba mantenibles, rápidas y comprensivas.
La inversión en aprender estas características avanzadas paga dividendos en mantenimiento reducido de pruebas, ciclos de retroalimentación más rápidos y mayor confianza en la calidad del código. Ya sea que estés probando un microservicio, un monolito o un sistema distribuido, pytest proporciona las herramientas necesarias para automatización de pruebas de nivel profesional.
Puntos clave:
- Usar ámbitos de fixtures estratégicamente para optimizar rendimiento
- Aprovechar parametrización para cobertura completa de pruebas
- Organizar pruebas con markers para ejecución flexible
- Estructurar conftest.py jerárquicamente para mantenibilidad
- Explorar el ecosistema de plugins para funcionalidad extendida
- Seguir mejores prácticas para suites de prueba listas para producción
Al implementar estas técnicas, tu suite de pruebas escalará junto con tu aplicación, proporcionando validación automatizada confiable a lo largo del ciclo de vida de desarrollo.