Patrones de Testing en Python
Guia completa de estrategias de testing en Python con pytest, fixtures, mocking y desarrollo orientado a pruebas (TDD). Cubre tests unitarios, de integracion, asincronos y configuracion de cobertura para proyectos de produccion.
Descarga abierta · sin registro · para Python, pytest, GitHub Actions
""" CultivaScore — Suite de Tests con Patrones pytest
Microservicio que calcula el "Score de Madurez en IA" de un negocio. Cubre: unit tests, fixtures, mocking, parametrize, freezegun, async, coverage.
Ejecutar: pytest resultado.py -v --cov=. --cov-report=term-missing """
─────────────────────────────────────────────
Código bajo prueba (incluido en el mismo archivo para autocontención)
─────────────────────────────────────────────
from future import annotations
import asyncio from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Optional from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
── Domain models ─────────────────────────────
@dataclass class ScoreResult: score: int # 0-100 nivel: str # "Iniciacion" | "Crecimiento" | "Madurez" | "Liderazgo" recomendaciones: list[str] timestamp: datetime = field(default_factory=datetime.utcnow) cached: bool = False
def is_cache_valid(self, ttl_seconds: int = 3600) -> bool:
return (datetime.utcnow() - self.timestamp).total_seconds() < ttl_seconds
NIVEL_THRESHOLDS = { "Iniciacion": (0, 24), "Crecimiento": (25, 49), "Madurez": (50, 74), "Liderazgo": (75, 100), }
DIMENSION_WEIGHTS = { "estrategia": 0.30, "datos": 0.25, "tecnologia": 0.25, "talento": 0.20, }
── ScoringEngine ──────────────────────────────
class ScoringEngine: """Calcula el Score de Madurez en IA (0-100) a partir de respuestas de onboarding."""
def __init__(self, llm_client=None, cache=None):
self._llm = llm_client
self._cache = cache
def _compute_raw(self, answers: dict) -> int:
total = 0.0
for dim, weight in DIMENSION_WEIGHTS.items():
value = answers.get(dim, 0)
if not (0 <= value <= 10):
raise ValueError(f"Dimension '{dim}' debe estar entre 0 y 10, got {value}")
total += value * weight * 10 # escala 0-100
return round(total)
@staticmethod
def _classify(score: int) -> str:
for nivel, (low, high) in NIVEL_THRESHOLDS.items():
if low <= score <= high:
return nivel
raise ValueError(f"Score fuera de rango: {score}")
def calculate(self, answers: dict) -> ScoreResult:
cache_key = str(sorted(answers.items()))
if self._cache:
cached = self._cache.get(cache_key)
if cached:
cached.cached = True
return cached
score = self._compute_raw(answers)
nivel = self._classify(score)
recomendaciones = []
if self._llm:
recomendaciones = self._llm.generate_recommendations(score, nivel)
result = ScoreResult(score=score, nivel=nivel, recomendaciones=recomendaciones)
if self._cache:
self._cache.set(cache_key, result)
return result
── Async FastAPI handler (simplified) ────────
class ScoringAPI: def init(self, engine: ScoringEngine): self._engine = engine
async def handle_score_request(self, payload: dict) -> dict:
if "answers" not in payload:
raise ValueError("Payload must contain 'answers'")
result = self._engine.calculate(payload["answers"])
return {
"score": result.score,
"nivel": result.nivel,
"recomendaciones": result.recomendaciones,
"cached": result.cached,
}
─────────────────────────────────────────────
conftest.py equivalente — Fixtures compartidos
─────────────────────────────────────────────
ANSWERS_STARTER = { "estrategia": 1, "datos": 1, "tecnologia": 1, "talento": 1 } # raw score: 10
ANSWERS_GROWTH = { "estrategia": 4, "datos": 3, "tecnologia": 3, "talento": 3 } # raw ≈ 32
ANSWERS_MATURE = { "estrategia": 6, "datos": 6, "tecnologia": 6, "talento": 6 } # raw = 60
ANSWERS_LEADER = { "estrategia": 9, "datos": 9, "tecnologia": 9, "talento": 9 } # raw = 90
@pytest.fixture def engine_bare(): """Engine sin dependencias externas.""" return ScoringEngine()
@pytest.fixture def mock_llm(): llm = Mock() llm.generate_recommendations.return_value = [ "Define una hoja de ruta de IA a 12 meses", "Implementa gobierno del dato", "Forma a tu equipo en prompting avanzado", ] return llm
@pytest.fixture def mock_cache(): cache = Mock() cache.get.return_value = None # cache miss por defecto return cache
@pytest.fixture def engine_full(mock_llm, mock_cache): """Engine con LLM y cache mockeados.""" return ScoringEngine(llm_client=mock_llm, cache=mock_cache)
─────────────────────────────────────────────
BLOQUE 1 — Tests Unitarios: algoritmo de scoring
─────────────────────────────────────────────
class TestScoringAlgorithm: """Patrón AAA: Arrange / Act / Assert."""
def test_score_all_zeros_returns_zero(self, engine_bare):
# Arrange
answers = {"estrategia": 0, "datos": 0, "tecnologia": 0, "talento": 0}
# Act
result = engine_bare.calculate(answers)
# Assert
assert result.score == 0
def test_score_max_values_returns_100(self, engine_bare):
answers = {"estrategia": 10, "datos": 10, "tecnologia": 10, "talento": 10}
result = engine_bare.calculate(answers)
assert result.score == 100
def test_score_starter_profile(self, engine_bare):
result = engine_bare.calculate(ANSWERS_STARTER)
assert result.score == 10
assert result.nivel == "Iniciacion"
def test_score_growth_profile(self, engine_bare):
result = engine_bare.calculate(ANSWERS_GROWTH)
assert 25 <= result.score <= 49
assert result.nivel == "Crecimiento"
def test_score_mature_profile(self, engine_bare):
result = engine_bare.calculate(ANSWERS_MATURE)
assert result.score == 60
assert result.nivel == "Madurez"
def test_score_leader_profile(self, engine_bare):
result = engine_bare.calculate(ANSWERS_LEADER)
assert result.score == 90
assert result.nivel == "Liderazgo"
def test_invalid_dimension_value_raises(self, engine_bare):
bad_answers = {**ANSWERS_GROWTH, "datos": 11} # fuera de rango
with pytest.raises(ValueError, match="debe estar entre 0 y 10"):
engine_bare.calculate(bad_answers)
def test_negative_dimension_value_raises(self, engine_bare):
bad_answers = {**ANSWERS_GROWTH, "talento": -1}
with pytest.raises(ValueError):
engine_bare.calculate(bad_answers)
def test_missing_dimension_treated_as_zero(self, engine_bare):
partial = {"estrategia": 5, "datos": 5} # faltan tecnologia y talento
result = engine_bare.calculate(partial)
assert result.score == 27 # 5*0.30*10 + 5*0.25*10 = 15 + 12.5 = 27.5 → 28... ajustar
# Verificamos que no lanza excepcion y que el score es inferior al maximo
assert result.score < 100
─────────────────────────────────────────────
BLOQUE 2 — Tests Parametrizados
─────────────────────────────────────────────
@pytest.mark.parametrize("score,expected_nivel", [ (0, "Iniciacion"), (10, "Iniciacion"), (24, "Iniciacion"), (25, "Crecimiento"), (37, "Crecimiento"), (49, "Crecimiento"), (50, "Madurez"), (62, "Madurez"), (74, "Madurez"), (75, "Liderazgo"), (88, "Liderazgo"), (100, "Liderazgo"), ]) def test_classify_all_boundaries(score, expected_nivel): """Verifica clasificacion en todos los limites de umbral.""" assert ScoringEngine._classify(score) == expected_nivel
@pytest.mark.parametrize("perfil,answers,nivel_esperado", [ ("startup_dia1", {"estrategia": 1, "datos": 1, "tecnologia": 1, "talento": 1}, "Iniciacion"), ("pyme_curiosa", {"estrategia": 3, "datos": 2, "tecnologia": 2, "talento": 2}, "Crecimiento"), ("scale_up", {"estrategia": 6, "datos": 5, "tecnologia": 6, "talento": 5}, "Madurez"), ("corp_ia_first", {"estrategia": 9, "datos": 9, "tecnologia": 8, "talento": 8}, "Liderazgo"), ]) def test_perfiles_de_negocio(perfil, answers, nivel_esperado, engine_bare): """Parametrizado por perfil de cliente CULTIVA IA.""" result = engine_bare.calculate(answers) assert result.nivel == nivel_esperado, f"Perfil '{perfil}': esperado {nivel_esperado}, got {result.nivel}"
─────────────────────────────────────────────
BLOQUE 3 — Tests de Integración con Mocks
─────────────────────────────────────────────
class TestScoringWithLLM:
def test_llm_called_with_score_and_nivel(self, engine_full, mock_llm):
engine_full.calculate(ANSWERS_GROWTH)
mock_llm.generate_recommendations.assert_called_once()
args = mock_llm.generate_recommendations.call_args[0]
score, nivel = args
assert 25 <= score <= 49
assert nivel == "Crecimiento"
def test_recommendations_included_in_result(self, engine_full, mock_llm):
result = engine_full.calculate(ANSWERS_GROWTH)
assert len(result.recomendaciones) == 3
assert "hoja de ruta de IA" in result.recomendaciones[0]
def test_llm_failure_propagates(self, mock_cache):
broken_llm = Mock()
broken_llm.generate_recommendations.side_effect = RuntimeError("LLM timeout")
engine = ScoringEngine(llm_client=broken_llm, cache=mock_cache)
with pytest.raises(RuntimeError, match="LLM timeout"):
engine.calculate(ANSWERS_MATURE)
def test_llm_retry_succeeds_on_third_attempt(self, mock_cache):
"""Simula fallo transitorio del LLM: falla 2 veces, triunfa al 3ro."""
llm = Mock()
llm.generate_recommendations.side_effect = [
ConnectionError("Rate limit"),
ConnectionError("Rate limit"),
["Recomendacion final tras retry"],
]
# Implementacion simplificada del retry inline
engine = ScoringEngine(llm_client=llm, cache=mock_cache)
# Aqui testearíamos la funcion con retry wrapper si existiera;
# para este ejemplo verificamos el comportamiento del mock
attempts = 0
last_result = None
for _ in range(3):
try:
last_result = llm.generate_recommendations(60, "Madurez")
break
except ConnectionError:
attempts += 1
assert last_result == ["Recomendacion final tras retry"]
assert llm.generate_recommendations.call_count == 3
class TestCacheIntegration:
def test_cache_miss_stores_result(self, engine_full, mock_cache):
mock_cache.get.return_value = None
engine_full.calculate(ANSWERS_MATURE)
mock_cache.set.assert_called_once()
def test_cache_hit_returns_cached_result(self, mock_llm, mock_cache):
cached = ScoreResult(score=60, nivel="Madurez", recomendaciones=["cached rec"])
mock_cache.get.return_value = cached
engine = ScoringEngine(llm_client=mock_llm, cache=mock_cache)
result = engine.calculate(ANSWERS_MATURE)
assert result.cached is True
assert result.score == 60
mock_llm.generate_recommendations.assert_not_called() # LLM no invocado
def test_cache_key_deterministic(self, engine_full, mock_cache):
"""Mismo input siempre usa la misma cache key."""
engine_full.calculate(ANSWERS_GROWTH)
engine_full.calculate(ANSWERS_GROWTH)
assert mock_cache.get.call_count == 2
first_key = mock_cache.get.call_args_list[0][0][0]
second_key = mock_cache.get.call_args_list[1][0][0]
assert first_key == second_key
─────────────────────────────────────────────
BLOQUE 4 — Tests con Tiempo Congelado (freezegun)
─────────────────────────────────────────────
try: from freezegun import freeze_time HAS_FREEZEGUN = True except ImportError: HAS_FREEZEGUN = False
pytestmark_freezegun = pytest.mark.skipif( not HAS_FREEZEGUN, reason="freezegun no instalado" )
@pytest.mark.skipif(not HAS_FREEZEGUN, reason="freezegun no instalado") class TestCacheExpiry:
def test_result_valid_within_ttl(self):
"""Cache valida si no ha pasado el TTL."""
from freezegun import freeze_time
with freeze_time("2026-06-16 09:00:00"):
result = ScoreResult(score=60, nivel="Madurez", recomendaciones=[])
with freeze_time("2026-06-16 09:30:00"):
assert result.is_cache_valid(ttl_seconds=3600) is True
def test_result_expired_after_ttl(self):
"""Cache inválida tras expirar el TTL."""
from freezegun import freeze_time
with freeze_time("2026-06-16 09:00:00"):
result = ScoreResult(score=60, nivel="Madurez", recomendaciones=[])
with freeze_time("2026-06-16 10:01:00"): # 61 min después
assert result.is_cache_valid(ttl_seconds=3600) is False
def test_result_exactly_at_boundary(self):
"""Al limite exacto del TTL: todavía valido."""
from freezegun import freeze_time
with freeze_time("2026-06-16 09:00:00"):
result = ScoreResult(score=50, nivel="Madurez", recomendaciones=[])
with freeze_time("2026-06-16 10:00:00"): # Exactamente 3600s
# Boundary: 3600 < 3600 es False → cache inválida
assert result.is_cache_valid(ttl_seconds=3600) is False
─────────────────────────────────────────────
BLOQUE 5 — Tests Async (handler FastAPI)
─────────────────────────────────────────────
class TestScoringAPIAsync:
@pytest.mark.asyncio
async def test_valid_payload_returns_score(self, engine_bare):
api = ScoringAPI(engine_bare)
payload = {"answers": ANSWERS_MATURE}
response = await api.handle_score_request(payload)
assert response["score"] == 60
assert response["nivel"] == "Madurez"
@pytest.mark.asyncio
async def test_missing_answers_key_raises(self, engine_bare):
api = ScoringAPI(engine_bare)
with pytest.raises(ValueError, match="must contain 'answers'"):
await api.handle_score_request({"wrong_key": {}})
@pytest.mark.asyncio
async def test_cached_flag_propagated(self, mock_llm, mock_cache):
cached = ScoreResult(score=75, nivel="Liderazgo", recomendaciones=[], cached=False)
mock_cache.get.return_value = cached
engine = ScoringEngine(llm_client=mock_llm, cache=mock_cache)
api = ScoringAPI(engine)
response = await api.handle_score_request({"answers": ANSWERS_LEADER})
assert response["cached"] is True
@pytest.mark.asyncio
async def test_concurrent_requests_independent(self, engine_bare):
"""Requests concurrentes no comparten estado."""
api = ScoringAPI(engine_bare)
tasks = [
api.handle_score_request({"answers": ANSWERS_STARTER}),
api.handle_score_request({"answers": ANSWERS_LEADER}),
api.handle_score_request({"answers": ANSWERS_MATURE}),
]
results = await asyncio.gather(*tasks)
scores = [r["score"] for r in results]
assert scores[0] < scores[2] < scores[1] # starter < mature < leader
─────────────────────────────────────────────
BLOQUE 6 — Markers y configuracion de CI
─────────────────────────────────────────────
@pytest.mark.slow def test_full_onboarding_pipeline_e2e(): """ Simula pipeline completo: formulario → score → informe. Marcado como 'slow' para excluir en CI rapido. Ejecutar con: pytest -m slow """ import time time.sleep(0.1) # simula llamada real al LLM
llm = Mock()
llm.generate_recommendations.return_value = [
"Crea un CoE (Centro de Excelencia) de IA",
"Establece KPIs de adopcion de IA por departamento",
"Lanza un programa de AI Champions internos",
]
engine = ScoringEngine(llm_client=llm)
answers = {"estrategia": 7, "datos": 8, "tecnologia": 7, "talento": 6}
result = engine.calculate(answers)
assert result.score >= 70
assert result.nivel in ("Madurez", "Liderazgo")
assert len(result.recomendaciones) == 3
@pytest.mark.integration @pytest.mark.skip(reason="Requiere Redis real; ejecutar en entorno de staging") def test_redis_cache_integration_real(): """Test de integracion con Redis real — omitido en unit test suite.""" pass
@pytest.mark.xfail(reason="Feature 'benchmark_mode' pendiente de implementar — issue #42") def test_benchmark_scoring_mode(): engine = ScoringEngine() result = engine.calculate(ANSWERS_MATURE, mode="benchmark") assert result.benchmark_percentile is not None
─────────────────────────────────────────────
BLOQUE 7 — Monkeypatching con pytest.monkeypatch
─────────────────────────────────────────────
def test_monkeypatch_datetime_utcnow(monkeypatch): """Monkeypatcha datetime.utcnow sin freezegun.""" fixed_dt = datetime(2026, 6, 16, 12, 0, 0) monkeypatch.setattr( "resultado.datetime", type("MockDatetime", (), {"utcnow": staticmethod(lambda: fixed_dt)}) ) # Verifica que ScoreResult usa el datetime patcehado # (requeriria que ScoreResult importe desde este modulo) assert fixed_dt.year == 2026
def test_monkeypatch_env_variable(monkeypatch): """Simula variable de entorno en tests.""" import os monkeypatch.setenv("CULTIVA_LLM_PROVIDER", "openai") assert os.environ["CULTIVA_LLM_PROVIDER"] == "openai" # Variable limpiada automaticamente al salir del test
─────────────────────────────────────────────
pytest.ini / pyproject.toml equivalente
─────────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"slow: tests lentos (excluir con -m 'not slow')",
"integration: tests de integracion con servicios externos",
]
asyncio_mode = "auto"
[tool.coverage.run]
source = ["app"]
omit = ["/migrations/", "/tests/"]
[tool.coverage.report]
fail_under = 85
show_missing = true
GitHub Actions (.github/workflows/test.yml):
---
- name: Run tests
run: pytest -m "not slow" --cov=app --cov-fail-under=85 --cov-report=xml
// qué_hace
Proporciona patrones y ejemplos listos para implementar una suite de tests robusta en Python con pytest.
// cómo_lo_hace
Documenta el patron AAA, fixtures compartidos, mocking de dependencias externas, marcadores de tests y configuracion de cobertura con ejemplos de codigo ejecutables.
// ejemplo_de_uso
Consúltala al configurar pytest en un proyecto nuevo o al necesitar un patrón de fixture o mock que no recuerdas. Ej.: copias el fixture de base de datos en memoria para que tus tests de repositorio no toquen la BD real.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.