Módulo 2: Unit Testing LLM Applications
7. Proyecto: Prompt Contract Tests
Descripción
Este es el proyecto práctico del Módulo 2. Construirás una suite completa de prompt contract tests para la app del Módulo 1. Al terminar, tendrás tests que validan todos los prompts de la app sin hacer ni una sola llamada al LLM real: 0 llamadas a API, ejecución en <10 segundos, coverage significativa.
Punto de partida: la app del Módulo 1
Trabajarás sobre la app de análisis de sentimiento del Módulo 1. Si no la tienes, aquí está la versión de referencia:
src/
├── app/
│ ├── __init__.py
│ ├── config.py ← Configuración del LLM
│ ├── parsers.py ← Parser del output JSON del LLM
│ ├── processors.py ← Output processor (normalización)
│ ├── sentiment.py ← Lógica de análisis de sentimiento
│ └── main.py ← FastAPI app
tests/
├── conftest.py ← Fixtures compartidas (del Módulo 1)
├── helpers.py ← create_openai_chat_response
├── smoke/
│ └── test_smoke.py ← Smoke tests (del Módulo 1)
└── unit/
├── contracts/
│ └── test_contracts.py
├── parsers/
│ └── test_parsers.py
└── regression/
└── test_regression.py
pytest.ini
requirements.txt
La app de referencia: código completo
src/app/__init__.py
# Vacío
src/app/config.py
import os
from dataclasses import dataclass
@dataclass
class Config:
openai_api_key: str
model: str = "gpt-4o-mini"
temperature: float = 0.0
max_tokens: int = 500
def get_config() -> Config:
api_key = os.getenv("OPENAI_API_KEY", "test-key-placeholder")
return Config(openai_api_key=api_key)
src/app/parsers.py
import json
import re
from typing import Any
def parse_json_response(raw: str) -> dict:
"""
Extrae y parsea JSON del output del LLM.
Soporta:
- JSON directo: '{"key": "value"}'
- JSON en markdown: '```json\\n{...}\\n```'
- JSON con texto previo: 'Resultado: {...}'
Raises:
ValueError: Si no se encuentra JSON válido en el string
"""
if not raw or not raw.strip():
raise ValueError("La respuesta del LLM está vacía")
# Intento 1: parsear directamente
try:
return json.loads(raw.strip())
except json.JSONDecodeError:
pass
# Intento 2: extraer de markdown code block
markdown_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
if markdown_match:
try:
return json.loads(markdown_match.group(1).strip())
except json.JSONDecodeError:
pass
# Intento 3: buscar cualquier objeto JSON en el texto
json_matches = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', raw, re.DOTALL)
for match in json_matches:
try:
return json.loads(match)
except json.JSONDecodeError:
continue
raise ValueError(f"No se encontró JSON válido en: {raw[:100]!r}")
src/app/processors.py
VALID_SENTIMENTS = {"positivo", "negativo", "neutral"}
def process_sentiment_output(raw_dict: dict) -> dict:
"""
Normaliza y valida el output del parser de sentimiento.
Garantías:
- sentiment siempre en VALID_SENTIMENTS
- score siempre float 0.0-1.0
- keywords siempre list (puede ser vacía)
- explanation siempre string (puede ser vacío)
"""
# Sentiment con normalización y fallback
raw_sentiment = str(raw_dict.get("sentiment", "")).strip().lower()
sentiment = raw_sentiment if raw_sentiment in VALID_SENTIMENTS else "neutral"
# Score con clamping
try:
score = float(raw_dict.get("score", 0.5))
score = max(0.0, min(1.0, score))
except (ValueError, TypeError):
score = 0.5
# Keywords: normalizar a lista limpia
raw_keywords = raw_dict.get("keywords", [])
if isinstance(raw_keywords, str):
keywords = [k.strip() for k in raw_keywords.split(",") if k.strip()]
elif isinstance(raw_keywords, list):
keywords = [str(k).strip() for k in raw_keywords if k and str(k).strip()]
else:
keywords = []
# Explanation: string normalizado
explanation = str(raw_dict.get("explanation", "")).strip()[:500]
return {
"sentiment": sentiment,
"score": score,
"keywords": keywords,
"explanation": explanation
}
src/app/sentiment.py
import openai
from app.config import get_config
from app.parsers import parse_json_response
from app.processors import process_sentiment_output
SENTIMENT_PROMPT = """Analiza el sentimiento del siguiente texto.
Responde ÚNICAMENTE con un JSON válido con esta estructura exacta:
{{
"sentiment": "<positivo|negativo|neutral>",
"score": <número entre 0.0 y 1.0>,
"explanation": "<explicación breve en máximo 200 caracteres>",
"keywords": ["<palabra1>", "<palabra2>"]
}}
Texto a analizar:
{text}"""
def analyze_sentiment(text: str, client=None) -> dict:
"""
Analiza el sentimiento de un texto usando LLM.
Args:
text: Texto a analizar (no vacío)
client: Cliente OpenAI (si None, crea uno con la configuración)
Returns:
dict con: sentiment, score, keywords, explanation
Raises:
ValueError: Si text está vacío
"""
if not text or not text.strip():
raise ValueError("El texto no puede estar vacío")
if client is None:
config = get_config()
client = openai.OpenAI(api_key=config.openai_api_key)
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Eres un analizador de sentimiento. Siempre responde con JSON válido."
},
{
"role": "user",
"content": SENTIMENT_PROMPT.format(text=text)
}
],
temperature=0.0,
max_tokens=500,
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
parsed = parse_json_response(raw)
return process_sentiment_output(parsed)
except Exception as e:
return {
"sentiment": "unknown",
"score": 0.0,
"keywords": [],
"explanation": "",
"error": str(e)
}
src/app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from app.sentiment import analyze_sentiment
app = FastAPI(
title="Sentiment Analysis API",
description="Analiza el sentimiento de textos usando LLM",
version="1.0.0"
)
class AnalyzeRequest(BaseModel):
text: str = Field(min_length=1, max_length=5000)
class SentimentResponse(BaseModel):
sentiment: str
score: float
keywords: list[str]
explanation: str
@app.post("/analyze", response_model=SentimentResponse)
def analyze_endpoint(request: AnalyzeRequest):
result = analyze_sentiment(request.text)
if "error" in result:
raise HTTPException(status_code=500, detail="Error al analizar el texto")
return result
@app.get("/health")
def health_check():
return {"status": "ok", "service": "sentiment-analysis"}
Paso 1: Verificar la estructura
Antes de escribir tests, asegúrate de que la estructura existe:
# Crear estructura si no existe
mkdir -p tests/unit/contracts
mkdir -p tests/unit/parsers
mkdir -p tests/unit/regression
touch tests/unit/contracts/__init__.py
touch tests/unit/parsers/__init__.py
touch tests/unit/regression/__init__.py
# Verificar que pytest puede importar la app
python -c "from app.sentiment import analyze_sentiment; print('Import OK')"
# Verificar que pytest colecta los tests
pytest --collect-only -q
Paso 2: Actualizar tests/helpers.py
# tests/helpers.py
from unittest.mock import MagicMock
import json
def create_openai_chat_response(
content: str,
model: str = "gpt-4o-mini",
prompt_tokens: int = 45,
completion_tokens: int = 25,
finish_reason: str = "stop"
) -> MagicMock:
"""
Crea un mock que replica exactamente la estructura de openai.ChatCompletion.
IMPORTANTE: El content debe ser un JSON string si la app espera JSON.
"""
mock_message = MagicMock()
mock_message.role = "assistant"
mock_message.content = content
mock_message.tool_calls = None
mock_choice = MagicMock()
mock_choice.index = 0
mock_choice.message = mock_message
mock_choice.finish_reason = finish_reason
mock_usage = MagicMock()
mock_usage.prompt_tokens = prompt_tokens
mock_usage.completion_tokens = completion_tokens
mock_usage.total_tokens = prompt_tokens + completion_tokens
mock_response = MagicMock()
mock_response.id = "chatcmpl-mock-test"
mock_response.model = model
mock_response.choices = [mock_choice]
mock_response.usage = mock_usage
return mock_response
def create_sentiment_response(
sentiment: str = "neutral",
score: float = 0.5,
explanation: str = "Análisis de prueba",
keywords: list = None
) -> MagicMock:
"""Helper especializado para respuestas de sentimiento."""
if keywords is None:
keywords = []
content = json.dumps({
"sentiment": sentiment,
"score": score,
"explanation": explanation,
"keywords": keywords
})
return create_openai_chat_response(content)
Paso 3: Actualizar tests/conftest.py
# tests/conftest.py
import pytest
import json
from unittest.mock import MagicMock, AsyncMock
from tests.helpers import create_openai_chat_response, create_sentiment_response
# =============================================
# FIXTURES ESTÁTICAS: casos comunes
# =============================================
@pytest.fixture
def mock_sentiment_client_positivo():
"""Mock client que retorna sentimiento positivo (caso estándar)."""
client = MagicMock()
client.chat.completions.create.return_value = create_sentiment_response(
sentiment="positivo",
score=0.92,
explanation="El texto usa lenguaje claramente positivo.",
keywords=["excelente", "fantástico"]
)
return client
@pytest.fixture
def mock_sentiment_client_negativo():
"""Mock client que retorna sentimiento negativo."""
client = MagicMock()
client.chat.completions.create.return_value = create_sentiment_response(
sentiment="negativo",
score=0.08,
explanation="El texto usa lenguaje claramente negativo.",
keywords=["terrible", "horrible"]
)
return client
@pytest.fixture
def mock_sentiment_client_neutral():
"""Mock client que retorna sentimiento neutral."""
client = MagicMock()
client.chat.completions.create.return_value = create_sentiment_response(
sentiment="neutral",
score=0.5,
explanation="El texto no expresa sentimiento claro.",
keywords=[]
)
return client
# =============================================
# FIXTURE FACTORIES: variaciones dinámicas
# =============================================
@pytest.fixture
def make_sentiment_client():
"""
Factory para crear mock clients con respuestas de sentimiento configurables.
Uso:
def test_x(make_sentiment_client):
client = make_sentiment_client(sentiment="positivo", score=0.9)
"""
def _create(
sentiment: str = "neutral",
score: float = 0.5,
explanation: str = "Análisis de prueba",
keywords: list = None
) -> MagicMock:
if keywords is None:
keywords = []
client = MagicMock()
client.chat.completions.create.return_value = create_sentiment_response(
sentiment=sentiment,
score=score,
explanation=explanation,
keywords=keywords
)
return client
return _create
@pytest.fixture
def make_error_client():
"""
Factory para crear mock clients que simulan errores.
Tipos de error:
"rate_limit", "timeout", "connection", "empty_response"
"""
import openai
def _create(error_type: str = "generic") -> MagicMock:
client = MagicMock()
if error_type == "rate_limit":
client.chat.completions.create.side_effect = openai.RateLimitError(
message="Rate limit exceeded",
response=MagicMock(status_code=429),
body={}
)
elif error_type == "timeout":
client.chat.completions.create.side_effect = openai.APITimeoutError(
request=MagicMock()
)
elif error_type == "connection":
client.chat.completions.create.side_effect = openai.APIConnectionError(
request=MagicMock()
)
elif error_type == "empty_response":
client.chat.completions.create.return_value = create_openai_chat_response("")
elif error_type == "malformed_json":
client.chat.completions.create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.9' # JSON incompleto
)
else:
client.chat.completions.create.side_effect = Exception(f"Error genérico: {error_type}")
return client
return _create
Paso 4: Tests del contrato del prompt
# tests/unit/contracts/test_contracts.py
import pytest
from app.sentiment import analyze_sentiment
VALID_SENTIMENTS = ["positivo", "negativo", "neutral"]
class TestSentimentPromptContract:
"""Tests del contrato del prompt de análisis de sentimiento."""
# CONTRATO DEFINIDO:
# - Output es dict con: sentiment, score, keywords, explanation
# - sentiment: uno de ["positivo", "negativo", "neutral"]
# - score: float entre 0.0 y 1.0
# - keywords: lista de strings (puede estar vacía)
# - explanation: string (puede estar vacío)
def test_contract_structure(self, make_sentiment_client):
"""El output tiene todas las keys obligatorias del contrato."""
client = make_sentiment_client()
result = analyze_sentiment("texto de prueba", client=client)
assert isinstance(result, dict), f"Se esperaba dict, se obtuvo {type(result)}"
assert "sentiment" in result, "Falta key 'sentiment'"
assert "score" in result, "Falta key 'score'"
assert "keywords" in result, "Falta key 'keywords'"
assert "explanation" in result, "Falta key 'explanation'"
def test_contract_types(self, make_sentiment_client):
"""Cada campo del contrato tiene el tipo correcto."""
client = make_sentiment_client(
sentiment="positivo", score=0.85, keywords=["bien"], explanation="Positivo"
)
result = analyze_sentiment("texto", client=client)
assert isinstance(result["sentiment"], str)
assert isinstance(result["score"], (int, float))
assert isinstance(result["keywords"], list)
assert isinstance(result["explanation"], str)
def test_contract_sentiment_values(self, make_sentiment_client):
"""El campo sentiment solo puede ser uno de los tres valores permitidos."""
client = make_sentiment_client(sentiment="positivo")
result = analyze_sentiment("texto positivo", client=client)
assert result["sentiment"] in VALID_SENTIMENTS, \
f"sentiment '{result['sentiment']}' no está en {VALID_SENTIMENTS}"
def test_contract_score_range(self, make_sentiment_client):
"""El score siempre está en el rango [0, 1]."""
client = make_sentiment_client(score=0.75)
result = analyze_sentiment("texto", client=client)
assert 0.0 <= result["score"] <= 1.0, \
f"score {result['score']} fuera del rango [0, 1]"
def test_contract_keywords_is_list(self, make_sentiment_client):
"""keywords siempre es una lista (puede estar vacía)."""
client = make_sentiment_client(keywords=["palabra1", "palabra2"])
result = analyze_sentiment("texto", client=client)
assert isinstance(result["keywords"], list)
def test_contract_all_keywords_are_strings(self, make_sentiment_client):
"""Todos los elementos de keywords son strings."""
client = make_sentiment_client(keywords=["bien", "excelente", "fantástico"])
result = analyze_sentiment("texto", client=client)
assert all(isinstance(k, str) for k in result["keywords"]), \
f"Algunos keywords no son strings: {result['keywords']}"
class TestSentimentContractVariations:
"""Tests del contrato para diferentes tipos de input."""
@pytest.mark.parametrize("sentiment,score", [
("positivo", 0.95),
("negativo", 0.05),
("neutral", 0.5),
("positivo", 0.6), # Positivo con confianza media
("negativo", 0.4), # Negativo con confianza media
])
def test_contract_multiple_sentiments(self, make_sentiment_client, sentiment, score):
"""El contrato se cumple para todos los tipos de sentimiento."""
client = make_sentiment_client(sentiment=sentiment, score=score)
result = analyze_sentiment("texto de prueba", client=client)
assert result["sentiment"] in VALID_SENTIMENTS
assert 0 <= result["score"] <= 1
assert isinstance(result["keywords"], list)
def test_contract_with_empty_keywords(self, make_sentiment_client):
"""El contrato se cumple cuando el LLM no devuelve keywords."""
client = make_sentiment_client(keywords=[])
result = analyze_sentiment("texto breve", client=client)
assert result["keywords"] == []
def test_contract_with_many_keywords(self, make_sentiment_client):
"""El contrato se cumple con muchos keywords."""
many_keywords = ["palabra1", "palabra2", "palabra3", "palabra4", "palabra5"]
client = make_sentiment_client(keywords=many_keywords)
result = analyze_sentiment("texto largo con muchas palabras", client=client)
assert isinstance(result["keywords"], list)
assert all(isinstance(k, str) for k in result["keywords"])
Paso 5: Tests del parser
# tests/unit/parsers/test_parsers.py
import pytest
import json
from app.parsers import parse_json_response
class TestParseJsonResponse:
"""Tests del parser de JSON del LLM."""
@pytest.mark.parametrize("raw_input,expected", [
(
'{"sentiment": "positivo", "score": 0.9}',
{"sentiment": "positivo", "score": 0.9},
),
(
'```json\n{"sentiment": "negativo", "score": 0.1}\n```',
{"sentiment": "negativo", "score": 0.1},
),
(
'```\n{"sentiment": "neutral"}\n```',
{"sentiment": "neutral"},
),
(
'El análisis es: {"sentiment": "positivo", "score": 0.85}',
{"sentiment": "positivo", "score": 0.85},
),
(
'\n\n{"sentiment": "neutral", "score": 0.5}\n\n',
{"sentiment": "neutral", "score": 0.5},
),
])
def test_valid_formats(self, raw_input, expected):
"""El parser maneja todos los formatos válidos del LLM."""
assert parse_json_response(raw_input) == expected
def test_empty_raises_value_error(self):
with pytest.raises(ValueError, match="vacía"):
parse_json_response("")
def test_whitespace_only_raises_value_error(self):
with pytest.raises(ValueError):
parse_json_response(" \n\t ")
def test_no_json_raises_value_error(self):
with pytest.raises(ValueError):
parse_json_response("Este texto no contiene JSON")
def test_malformed_json_raises(self):
with pytest.raises((json.JSONDecodeError, ValueError)):
parse_json_response('{"sentiment": "positivo", "score": 0.9')
def test_unicode_handled_correctly(self):
raw = '{"sentiment": "positivo", "keywords": ["fantástico", "excelente"]}'
result = parse_json_response(raw)
assert "fantástico" in result["keywords"]
def test_nested_json_parsed(self):
raw = '{"result": {"sentiment": "positivo"}, "score": 0.9}'
result = parse_json_response(raw)
assert result["result"]["sentiment"] == "positivo"
class TestProcessSentimentOutput:
"""Tests del output processor de sentimiento."""
def test_normalizes_uppercase_sentiment(self):
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": "POSITIVO", "score": 0.9})
assert result["sentiment"] == "positivo"
def test_invalid_sentiment_defaults_to_neutral(self):
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": "muy_positivo", "score": 0.9})
assert result["sentiment"] == "neutral"
def test_clamps_score_above_one(self):
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": "positivo", "score": 1.5})
assert result["score"] == 1.0
def test_clamps_score_below_zero(self):
from app.processors import process_sentiment_output
result = process_sentiment_output({"sentiment": "negativo", "score": -0.1})
assert result["score"] == 0.0
def test_missing_fields_use_defaults(self):
from app.processors import process_sentiment_output
result = process_sentiment_output({})
assert result["sentiment"] == "neutral"
assert result["score"] == 0.5
assert result["keywords"] == []
assert result["explanation"] == ""
Paso 6: Tests de error handling
# tests/unit/contracts/test_error_handling.py
import pytest
import openai
from app.sentiment import analyze_sentiment
class TestSentimentErrorHandling:
"""Tests que verifican el comportamiento en casos de error."""
def test_rate_limit_returns_error_response(self, make_error_client):
"""La app maneja rate limit gracefully."""
client = make_error_client("rate_limit")
result = analyze_sentiment("texto", client=client)
assert result is not None, "La app no debe retornar None"
assert "error" in result or result.get("sentiment") == "unknown"
def test_timeout_returns_error_response(self, make_error_client):
"""La app maneja timeout gracefully."""
client = make_error_client("timeout")
result = analyze_sentiment("texto", client=client)
assert result is not None
assert "error" in result or result.get("sentiment") == "unknown"
def test_empty_response_handled_gracefully(self, make_error_client):
"""La app maneja respuesta vacía del LLM sin crashear."""
client = make_error_client("empty_response")
result = analyze_sentiment("texto", client=client)
assert result is not None
# No debe lanzar excepción — debe retornar algo
def test_malformed_json_handled_gracefully(self, make_error_client):
"""La app maneja JSON malformado del LLM."""
client = make_error_client("malformed_json")
result = analyze_sentiment("texto", client=client)
assert result is not None
def test_empty_text_raises_value_error(self, make_sentiment_client):
"""Input vacío lanza ValueError antes de llamar al LLM."""
client = make_sentiment_client()
with pytest.raises(ValueError, match="vacío"):
analyze_sentiment("", client=client)
def test_empty_text_does_not_call_llm(self, make_sentiment_client):
"""Con input vacío, el LLM no debe ser llamado."""
client = make_sentiment_client()
with pytest.raises(ValueError):
analyze_sentiment("", client=client)
# Verificar que el LLM no fue llamado
client.chat.completions.create.assert_not_called()
Paso 7: Tests de regresión
# tests/unit/regression/test_regression.py
import pytest
import json
# Snapshot manual de comportamientos conocidos
KNOWN_GOOD_CASES = [
{
"id": "r001",
"input": "Este producto es absolutamente fantástico",
"mock_response": {
"sentiment": "positivo",
"score": 0.97,
"explanation": "Uso de superlativo con connotación positiva muy fuerte.",
"keywords": ["fantástico", "absolutamente"]
},
"expected": {
"sentiment": "positivo",
"score_min": 0.8,
"score_max": 1.0,
"has_keywords": True
}
},
{
"id": "r002",
"input": "Horrible experiencia, nunca volvería",
"mock_response": {
"sentiment": "negativo",
"score": 0.03,
"explanation": "Vocabulario muy negativo con intención de no repetición.",
"keywords": ["horrible", "nunca"]
},
"expected": {
"sentiment": "negativo",
"score_min": 0.0,
"score_max": 0.2,
"has_keywords": True
}
},
{
"id": "r003",
"input": "El producto llegó el martes",
"mock_response": {
"sentiment": "neutral",
"score": 0.5,
"explanation": "Descripción factual sin carga emocional.",
"keywords": []
},
"expected": {
"sentiment": "neutral",
"score_min": 0.3,
"score_max": 0.7,
"has_keywords": False
}
}
]
@pytest.mark.parametrize("case", KNOWN_GOOD_CASES, ids=lambda c: c["id"])
def test_regression_known_cases(make_sentiment_client, case):
"""
Tests de regresión: comportamientos conocidos que no deben cambiar.
Si estos tests fallan, significa que algo cambió en el pipeline.
Revisar antes de actualizar los casos de regresión.
"""
from app.sentiment import analyze_sentiment
client = make_sentiment_client(**case["mock_response"])
result = analyze_sentiment(case["input"], client=client)
expected = case["expected"]
assert result["sentiment"] == expected["sentiment"], \
f"Regresión [{case['id']}]: sentiment cambió de '{expected['sentiment']}' a '{result['sentiment']}'"
assert expected["score_min"] <= result["score"] <= expected["score_max"], \
f"Regresión [{case['id']}]: score {result['score']} fuera de [{expected['score_min']}, {expected['score_max']}]"
if expected["has_keywords"]:
assert len(result["keywords"]) > 0, \
f"Regresión [{case['id']}]: se esperaban keywords pero la lista está vacía"
Paso 8: Verificación final
# Ejecutar todos los tests del módulo
pytest tests/unit/ -v --tb=short
# Resultado esperado:
# tests/unit/contracts/test_contracts.py::TestSentimentPromptContract::test_contract_structure PASSED
# tests/unit/contracts/test_contracts.py::TestSentimentPromptContract::test_contract_types PASSED
# ... (todos los contract tests)
# tests/unit/parsers/test_parsers.py::TestParseJsonResponse::test_valid_formats[json_directo] PASSED
# ... (todos los parser tests)
# tests/unit/regression/test_regression.py::test_regression_known_cases[r001] PASSED
# ... (todos los regression tests)
# Ejecutar solo contract tests
pytest -m contract -v
# Medir cobertura del módulo
pytest tests/unit/ --cov=app --cov-report=term-missing
# Verificar velocidad (debe ser <10s)
time pytest tests/unit/ -q
# Verificar 0 llamadas a API real (si tienes el flag configurado)
pytest tests/unit/ -v --no-header
# En la salida NO debe aparecer ninguna llamada HTTP a api.openai.com
Checklist de entrega
Antes de dar el módulo por terminado, verifica:
-
pytest tests/unit/pasa al 100% - Todos los prompts de la app tienen al menos un contract test
- El parser está testeado con 5+ formatos de input distintos
- Los edge cases de error están cubiertos (rate limit, timeout, empty response)
- Los tests de regresión cubren los 3 casos principales (positivo, negativo, neutral)
-
pytest -m unittermina en menos de 10 segundos -
pytest --cov=appmuestra >80% de cobertura enparsers.pyyprocessors.py - 0 llamadas reales a OpenAI API (verificar con
pytest -s— no debe imprimir API calls)
Extensiones opcionales
Si completaste lo básico y quieres ir más lejos:
Extensión 1: Tests de API con FastAPI TestClient
# tests/unit/api/test_api.py
from fastapi.testclient import TestClient
from app.main import app
def test_analyze_endpoint_contract(make_sentiment_client, mocker):
"""El endpoint /analyze cumple el contrato HTTP."""
mocker.patch("app.sentiment.client", make_sentiment_client(sentiment="positivo", score=0.9))
client = TestClient(app)
response = client.post("/analyze", json={"text": "Texto de prueba"})
assert response.status_code == 200
data = response.json()
assert "sentiment" in data
assert "score" in data
assert data["sentiment"] in ["positivo", "negativo", "neutral"]
Extensión 2: Pre-commit hooks
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: unit-tests
name: Run unit tests
entry: pytest tests/unit/ -q --tb=short
language: system
pass_filenames: false
Ejercicios finales del proyecto
Ejercicio 1: Añadir un prompt nuevo
Añade un segundo prompt a la app: summarize_text(text, client) que devuelve {summary: str, confidence: float}. Escribe el contrato y los tests correspondientes.
Ver guía
- Crear
src/app/summarizer.pycon la función y el prompt - Definir el contrato:
summaryes string no vacío, max 500 chars;confidencees float 0-1 - Crear
tests/unit/contracts/test_summary_contracts.py - Usar
make_summary_clientfactory consummary=..., confidence=...
Ejercicio 2: Aumentar la cobertura del parser
Ejecuta pytest tests/unit/parsers/ --cov=app.parsers --cov-report=term-missing. Identifica los branches no cubiertos y añade tests para cubrirlos.
Ver guía
Típicamente faltan:
- JSON con arrays como root (no objeto):
[{"x": 1}, {"x": 2}] - JSON con caracteres de escape:
{"text": "línea1\nlínea2"} - Múltiples objetos JSON en el texto (solo parsea el primero)
Ejercicio 3: Medir el tiempo de ejecución
Añade un test que verifica que los unit tests de la app corren en <10 segundos totales. Describe cómo lo implementarías.
Ver guía
# Forma simple: con time
import time
def test_unit_suite_speed():
"""Verificar que el suite de unit tests es rápido."""
import subprocess
start = time.time()
result = subprocess.run(
["pytest", "tests/unit/", "-q", "--tb=no"],
capture_output=True
)
elapsed = time.time() - start
assert elapsed < 10, f"Los unit tests tardaron {elapsed:.1f}s (máximo 10s)"
assert result.returncode == 0, "Los unit tests fallaron"
Nota: Este test es meta — testea el tiempo del test suite. En la práctica, es mejor configurar un timeout en pytest.ini:
[pytest]
timeout = 30 # Máximo 30s por test individual
Resumen del proyecto
Al terminar este proyecto tienes:
| Componente | Tests creados | Coverage |
|---|---|---|
app/parsers.py | 15+ tests | ~95% |
app/processors.py | 10+ tests | ~90% |
app/sentiment.py (contratos) | 12+ tests | ~80% |
| Error handling | 5+ tests | ~85% |
| Regresión | 3+ casos conocidos | N/A |
| Total | 45+ tests | >80% |
Tiempo de ejecución: <10 segundos Llamadas a API real: 0 Costo: $0.00
Este es el poder de los prompt contract tests: cobertura significativa sin gastar un solo centavo en API calls.
Recursos adicionales
- pytest — Getting Started — Base de pytest
- FastAPI TestClient — Para tests de API
- pytest-cov — Para medir cobertura
- Pydantic v2 BaseModel — Para definir contratos
- Python unittest.mock — Reference completo del mocking
- pytest markers — Para organizar tests por categoría
- Módulo 3: Integration Testing — El siguiente paso