Módulo 2: Unit Testing LLM Applications

3. Prompt Contract Tests

Descripción

Un prompt contract es la especificación explícita de qué debe cumplir el output: estructura, tipos, constraints y formato. Esta cápsula enseña a pasar de contratos implícitos (en tu cabeza) a contratos explícitos y testeables. Un contract vago ("debe ser bueno") no es testeable; uno específico ("JSON con keys X, Y, Z, con X entre 10-500 chars y Y en rango 0-1") sí lo es.


El problema: contratos implícitos

Todos los developers que usan LLMs tienen contratos implícitos. El problema es que viven en tu cabeza:

"Mi prompt de resumen debería devolver... algo útil. Un resumen. Con sus partes importantes."

Cuando algo falla, no sabes exactamente qué: ¿faltó una key? ¿el JSON malformó? ¿el confidence está fuera de rango? ¿el summary es demasiado largo?

La solución: Hacer el contrato explícito y convertirlo en código.


Anatomía de un contrato completo

Un buen prompt contract tiene cuatro partes:

1. Estructura (¿Qué forma tiene el output?)

El output es un objeto JSON (dict) con las siguientes keys obligatorias:
- summary
- confidence
- sources

2. Tipos (¿Qué tipo de dato es cada parte?)

- summary: string
- confidence: float (o int como caso especial)
- sources: lista de strings

3. Constraints (¿Cuáles son los límites y restricciones?)

- summary: entre 10 y 500 caracteres
- confidence: entre 0.0 y 1.0 (inclusive)
- sources: puede estar vacía; cada elemento es un string no vacío

4. Invariantes de error (¿Qué pasa en edge cases?)

- Si el input está vacío: summary = "", confidence = 0.0, sources = []
- Si el texto es muy corto: summary puede igualar al input original
- Si el LLM falla: ??? (definir comportamiento esperado)

De contrato a código: paso a paso

Paso 1: Escribir el contrato en prosa

Prompt de análisis de sentimiento:
DADO: un texto de entrada (string, no vacío)
RETORNA: un JSON con:
  - "sentiment": uno de ["positivo", "negativo", "neutral"]
  - "score": float entre 0.0 y 1.0
  - "explanation": string no vacío, máximo 200 caracteres
  - "keywords": lista de strings, entre 1 y 5 elementos
EDGE CASES:
  - Input muy corto (1 palabra): debe funcionar igual
  - Input en otro idioma: debe retornar la misma estructura

Paso 2: Traducir a assertions

def test_sentiment_prompt_contract(mock_openai_client):
    """
    El prompt de análisis de sentimiento cumple el contrato definido.
    
    CONTRATO:
    - Output es dict con keys: sentiment, score, explanation, keywords
    - sentiment: uno de ["positivo", "negativo", "neutral"]
    - score: float 0.0 - 1.0
    - explanation: string no vacío, máximo 200 chars
    - keywords: lista de 1-5 strings
    """
    # Arrange
    texto = "Me encanta este producto, es exactamente lo que necesitaba."
    
    # Act
    result = analyze_sentiment(texto, client=mock_openai_client)
    
    # Assert: estructura
    assert isinstance(result, dict), \
        f"El resultado debe ser dict, es {type(result)}"
    assert "sentiment" in result, "Falta la key 'sentiment'"
    assert "score" in result, "Falta la key 'score'"
    assert "explanation" in result, "Falta la key 'explanation'"
    assert "keywords" in result, "Falta la key 'keywords'"
    
    # Assert: tipos
    assert isinstance(result["sentiment"], str), \
        f"sentiment debe ser string, es {type(result['sentiment'])}"
    assert isinstance(result["score"], (int, float)), \
        f"score debe ser numérico, es {type(result['score'])}"
    assert isinstance(result["explanation"], str), \
        f"explanation debe ser string, es {type(result['explanation'])}"
    assert isinstance(result["keywords"], list), \
        f"keywords debe ser list, es {type(result['keywords'])}"
    
    # Assert: constraints
    assert result["sentiment"] in ["positivo", "negativo", "neutral"], \
        f"sentiment debe ser uno de [positivo, negativo, neutral], es '{result['sentiment']}'"
    assert 0.0 <= result["score"] <= 1.0, \
        f"score debe ser 0-1, es {result['score']}"
    assert len(result["explanation"]) > 0, \
        "explanation no puede estar vacía"
    assert len(result["explanation"]) <= 200, \
        f"explanation debe tener máximo 200 chars, tiene {len(result['explanation'])}"
    assert 1 <= len(result["keywords"]) <= 5, \
        f"keywords debe tener 1-5 elementos, tiene {len(result['keywords'])}"
    assert all(isinstance(k, str) for k in result["keywords"]), \
        "Todos los keywords deben ser strings"

Contrato con Pydantic: la versión ejecutable

Pydantic convierte el contrato en documentación ejecutable. Si el contrato vive en el modelo Pydantic, es imposible que el código produzca un output que lo viole (si usas el modelo correctamente).

# app/schemas.py
from pydantic import BaseModel, Field, field_validator
from typing import Literal
from enum import Enum

class SentimentEnum(str, Enum):
    POSITIVO = "positivo"
    NEGATIVO = "negativo"
    NEUTRAL = "neutral"

class SentimentOutput(BaseModel):
    """
    Contrato del prompt de análisis de sentimiento.
    
    Este modelo define exactamente qué debe retornar el LLM.
    Si el LLM retorna algo diferente, Pydantic lanza ValidationError.
    """
    sentiment: SentimentEnum = Field(
        description="Sentimiento detectado: positivo, negativo o neutral"
    )
    score: float = Field(
        ge=0.0, le=1.0,
        description="Confianza del modelo, de 0.0 (baja) a 1.0 (alta)"
    )
    explanation: str = Field(
        min_length=1, max_length=200,
        description="Explicación breve del sentimiento detectado"
    )
    keywords: list[str] = Field(
        min_length=1, max_length=5,
        description="Palabras clave que definen el sentimiento"
    )
    
    @field_validator("keywords")
    @classmethod
    def keywords_not_empty(cls, v: list[str]) -> list[str]:
        if any(not k.strip() for k in v):
            raise ValueError("Los keywords no pueden ser strings vacíos")
        return v
    
    @field_validator("explanation")
    @classmethod
    def explanation_not_empty_string(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("explanation no puede ser solo espacios")
        return v.strip()
# Cómo usar el modelo en la app:
from app.schemas import SentimentOutput

def analyze_sentiment(text: str, client) -> dict:
    response = client.chat.completions.create(...)
    raw = response.choices[0].message.content
    parsed = json.loads(raw)
    validated = SentimentOutput(**parsed)  # Lanza ValidationError si no cumple el contrato
    return validated.model_dump()
# Tests usando el modelo Pydantic:
from pydantic import ValidationError
from app.schemas import SentimentOutput

def test_contract_via_pydantic(mock_openai_client):
    """El output del LLM (mockeado) pasa la validación de Pydantic."""
    result = analyze_sentiment("Texto de prueba", client=mock_openai_client)
    
    # Si llega aquí, ya pasó la validación de Pydantic (se hace internamente)
    validated = SentimentOutput(**result)
    assert validated.sentiment in ["positivo", "negativo", "neutral"]
    assert 0 <= validated.score <= 1

def test_invalid_output_raises_validation_error():
    """Verifica que un output inválido lanza ValidationError."""
    invalid_output = {
        "sentiment": "muy_positivo",  # Valor no permitido
        "score": 1.5,                 # Fuera de rango
        "explanation": "",             # Vacío
        "keywords": []                # Lista vacía
    }
    
    with pytest.raises(ValidationError) as exc_info:
        SentimentOutput(**invalid_output)
    
    errors = exc_info.value.errors()
    error_fields = [e["loc"][0] for e in errors]
    
    # Verificar que los errores son de los campos esperados
    assert "sentiment" in error_fields
    assert "score" in error_fields

Múltiples contratos en la misma app

Una app real tiene múltiples prompts, cada uno con su contrato. La estrategia: parametrizar.

# Define todos los contratos en un diccionario
PROMPT_CONTRACTS = {
    "sentiment": {
        "required_keys": ["sentiment", "score", "explanation", "keywords"],
        "types": {
            "sentiment": str,
            "score": (int, float),
            "explanation": str,
            "keywords": list
        },
        "constraints": {
            "sentiment": lambda v: v in ["positivo", "negativo", "neutral"],
            "score": lambda v: 0 <= v <= 1,
            "explanation": lambda v: 0 < len(v) <= 200,
            "keywords": lambda v: 1 <= len(v) <= 5
        }
    },
    "summary": {
        "required_keys": ["summary", "confidence", "sources"],
        "types": {
            "summary": str,
            "confidence": (int, float),
            "sources": list
        },
        "constraints": {
            "summary": lambda v: 10 <= len(v) <= 500,
            "confidence": lambda v: 0 <= v <= 1,
            "sources": lambda v: isinstance(v, list)
        }
    },
    "classification": {
        "required_keys": ["category", "confidence", "tags"],
        "types": {
            "category": str,
            "confidence": (int, float),
            "tags": list
        },
        "constraints": {
            "category": lambda v: len(v) > 0,
            "confidence": lambda v: 0 <= v <= 1,
            "tags": lambda v: len(v) <= 10
        }
    }
}

def validate_contract(result: dict, contract: dict) -> None:
    """Valida que un resultado cumple un contrato."""
    # Estructura
    for key in contract["required_keys"]:
        assert key in result, f"Falta key obligatoria: '{key}'"
    
    # Tipos
    for field, expected_type in contract["types"].items():
        assert isinstance(result[field], expected_type), \
            f"'{field}' debe ser {expected_type}, es {type(result[field])}"
    
    # Constraints
    for field, constraint_fn in contract["constraints"].items():
        assert constraint_fn(result[field]), \
            f"'{field}' con valor '{result[field]}' viola el constraint"

# Test parametrizado para todos los prompts:
@pytest.mark.parametrize("prompt_name,input_text,mock_response", [
    (
        "sentiment",
        "Me encanta el producto",
        '{"sentiment": "positivo", "score": 0.95, "explanation": "Expresa satisfacción", "keywords": ["encanta", "producto"]}'
    ),
    (
        "summary",
        "Texto largo para resumir...",
        '{"summary": "Resumen del texto", "confidence": 0.85, "sources": ["párrafo 1"]}'
    ),
    (
        "classification",
        "Este es un artículo técnico",
        '{"category": "tecnología", "confidence": 0.9, "tags": ["técnico", "artículo"]}'
    ),
])
def test_all_prompts_fulfill_contract(
    mocker, prompt_name, input_text, mock_response
):
    """Todos los prompts cumplen sus contratos respectivos."""
    mock_create = mocker.patch(f"app.prompts.{prompt_name}.client.chat.completions.create")
    mock_create.return_value = create_openai_chat_response(mock_response)
    
    result = run_prompt(prompt_name, input_text)
    
    contract = PROMPT_CONTRACTS[prompt_name]
    validate_contract(result, contract)

Contratos para edge cases

Un buen contrato también define el comportamiento en casos extremos:

# Contrato para input vacío
def test_contract_empty_input(mock_openai_client):
    """El contrato se cumple incluso con input vacío."""
    # Mock para respuesta de input vacío
    mock_openai_client.chat.completions.create.return_value = create_openai_chat_response(
        '{"sentiment": "neutral", "score": 0.0, "explanation": "Input vacío", "keywords": ["vacío"]}'
    )
    
    result = analyze_sentiment("", client=mock_openai_client)
    
    # El contrato debe cumplirse igual
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]
    assert 0 <= result["score"] <= 1
    assert len(result["explanation"]) > 0

# Contrato para input muy largo
def test_contract_very_long_input(mock_openai_client):
    """El contrato se cumple con inputs muy largos."""
    long_text = "texto " * 5000  # 30,000 caracteres
    
    result = analyze_sentiment(long_text, client=mock_openai_client)
    
    # Mismas garantías del contrato
    assert isinstance(result["sentiment"], str)
    assert 0 <= result["score"] <= 1

# Contrato para input en otro idioma
@pytest.mark.parametrize("language,input_text", [
    ("español", "Este producto es fantástico"),
    ("inglés", "This product is fantastic"),
    ("francés", "Ce produit est fantastique"),
])
def test_contract_multiple_languages(mocker, language, input_text):
    """El contrato se mantiene independientemente del idioma del input."""
    mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
    mock_create.return_value = create_openai_chat_response(
        '{"sentiment": "positivo", "score": 0.9, "explanation": "Texto positivo", "keywords": ["fantástico"]}'
    )
    
    result = analyze_sentiment(input_text)
    
    # La estructura del contrato debe cumplirse para cualquier idioma
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]
    assert 0 <= result["score"] <= 1

Contrato con campos opcionales

Algunos prompts tienen campos opcionales. El contrato debe especificarlo:

class AnalysisOutput(BaseModel):
    """
    Contrato con campos opcionales.
    
    required: sentiment, score
    optional: explanation (None si el LLM no lo provee), metadata (dict adicional)
    """
    sentiment: SentimentEnum
    score: float = Field(ge=0.0, le=1.0)
    
    # Campos opcionales
    explanation: str | None = Field(
        default=None,
        max_length=200,
        description="Explicación opcional"
    )
    metadata: dict | None = Field(
        default=None,
        description="Metadatos adicionales"
    )

def test_contract_with_optional_fields(mock_openai_client):
    """Los campos opcionales no deben fallar si faltan."""
    # Mock que omite los campos opcionales
    mock_openai_client.chat.completions.create.return_value = create_openai_chat_response(
        '{"sentiment": "positivo", "score": 0.9}'  # Sin explanation ni metadata
    )
    
    result = analyze_sentiment_flexible("texto", client=mock_openai_client)
    
    # Los campos obligatorios deben estar
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]
    assert 0 <= result["score"] <= 1
    
    # Los opcionales pueden ser None
    assert result.get("explanation") is None or isinstance(result["explanation"], str)
    assert result.get("metadata") is None or isinstance(result["metadata"], dict)

Comparación: Contract Test vs Assertion sobre calidad

Esta es la confusión más común. Ejemplos concretos:

# ✅ CONTRACT TEST — verifica estructura y constraints
def test_contract_correct():
    result = analyze_sentiment("texto", client=mock_client)
    
    assert isinstance(result["sentiment"], str)           # tipo
    assert result["sentiment"] in VALID_SENTIMENTS        # valores permitidos
    assert 0 <= result["score"] <= 1                      # rango numérico
    assert len(result["explanation"]) <= 200              # longitud máxima
    assert isinstance(result["keywords"], list)           # tipo

# ❌ EVALUACIÓN (no es un contract test)
def test_quality_incorrect_as_contract():
    result = analyze_sentiment("Odio este producto", client=REAL_CLIENT)
    
    assert result["sentiment"] == "negativo"  # ← Esto requiere el LLM real + juicio
    assert result["score"] > 0.8              # ← Requiere que el LLM sea preciso
    assert "odio" in result["keywords"]       # ← Requiere que el LLM extraiga bien

# ✅ LO CORRECTO: separa contract de evaluation
def test_contract_structure_only(mock_client):
    result = analyze_sentiment("Odio este producto", client=mock_client)
    # Solo verifica estructura con mock
    assert result["sentiment"] in VALID_SENTIMENTS
    assert 0 <= result["score"] <= 1

# Evaluation test separado (en module-03 o en evaluation guide)
@pytest.mark.integration
@pytest.mark.evaluation
def test_negative_sentiment_quality():
    result = analyze_sentiment("Odio este producto", client=REAL_CLIENT)
    # Aquí sí verificamos calidad con el LLM real
    assert result["sentiment"] == "negativo"

Workflow: de prompt a contrato a test

Paso 1: Escribe el prompt
   "Analiza el sentimiento del texto. Responde JSON: {...}"
         ↓
Paso 2: Define el contrato (en prosa primero)
   "Output: JSON con sentiment (positivo/negativo/neutral),
    score (0-1), explanation (string, max 200 chars)"
         ↓
Paso 3: Crea el modelo Pydantic (contrato ejecutable)
   class SentimentOutput(BaseModel): ...
         ↓
Paso 4: Escribe el contract test (con mock)
   def test_sentiment_contract(mock_client): ...
         ↓
Paso 5: Corre el test
   pytest -m contract -v
         ↓
Paso 6: Si falla → actualiza prompt o parser
   Si el LLM real produce algo diferente → ajusta el contract o el prompt

Ejercicios

Ejercicio 1: Escribir contrato completo

Para un prompt que extrae información de CVs (currículums), escribe el contrato en prosa y luego como modelo Pydantic:

El prompt: "Extrae la información principal del CV: nombre, habilidades técnicas, años de experiencia, último cargo."

Ver solución

Contrato en prosa:

Output es JSON con:
- name: string no vacío
- skills: lista de strings (puede estar vacía si no se detectan)
- years_experience: int >= 0 (puede ser 0 si es junior o no hay info)
- last_position: string (puede ser None si no hay historial)

Modelo Pydantic:

from pydantic import BaseModel, Field

class CVExtraction(BaseModel):
    name: str = Field(min_length=1, description="Nombre completo del candidato")
    skills: list[str] = Field(
        default_factory=list,
        description="Habilidades técnicas extraídas"
    )
    years_experience: int = Field(
        ge=0,
        description="Años totales de experiencia (0 si no hay información)"
    )
    last_position: str | None = Field(
        default=None,
        description="Último cargo o posición, None si no hay historial"
    )

def test_cv_extraction_contract(mock_client):
    cv_text = "Juan García, 5 años de experiencia en Python y FastAPI. Último cargo: Senior Developer."
    
    mock_client.chat.completions.create.return_value = create_openai_chat_response(
        '{"name": "Juan García", "skills": ["Python", "FastAPI"], "years_experience": 5, "last_position": "Senior Developer"}'
    )
    
    result = extract_cv(cv_text, client=mock_client)
    validated = CVExtraction(**result)
    
    assert validated.name == "Juan García"
    assert len(validated.skills) == 2
    assert validated.years_experience == 5
    assert validated.last_position == "Senior Developer"

Ejercicio 2: Identificar contratos vagos

Identifica los problemas con estos "contratos" y reescríbelos correctamente:

  1. "El output debe ser útil y claro"
  2. "El JSON debe tener la información relevante"
  3. "El score debe ser alto para textos positivos"
Ver solución

Problema 1: "útil y claro" no es testeable. Solución: "El output es una string no vacía, máximo 500 caracteres, sin XML ni caracteres de control."

Problema 2: "información relevante" es subjetivo. Solución: "El JSON tiene keys: title (string), tags (lista de strings, máximo 10), priority (uno de ['high', 'medium', 'low'])."

Problema 3: "alto para textos positivos" requiere LLM real + evaluación. Solución (contract): "El score es un float entre 0.0 y 1.0." Solución (evaluation, separada): "Para un conjunto de 20 textos clasificados manualmente como positivos, el score promedio debe ser >= 0.7."


Ejercicio 3: Contract para lista de items

Tu prompt retorna una lista de recomendaciones de productos. Escribe el modelo Pydantic y el test de contrato.

El prompt retorna algo así:

[
    {"product_id": "P001", "name": "Laptop", "score": 0.95, "reason": "Perfecto para trabajo remoto"},
    {"product_id": "P002", "name": "Mouse", "score": 0.87, "reason": "Complemento ideal"}
]
Ver solución
from pydantic import BaseModel, Field

class ProductRecommendation(BaseModel):
    product_id: str = Field(min_length=1)
    name: str = Field(min_length=1)
    score: float = Field(ge=0.0, le=1.0)
    reason: str = Field(min_length=1, max_length=300)

class RecommendationsOutput(BaseModel):
    recommendations: list[ProductRecommendation] = Field(
        min_length=1,
        max_length=10,
        description="Lista de 1 a 10 recomendaciones"
    )

def test_recommendations_contract(mock_client):
    mock_response = json.dumps([
        {"product_id": "P001", "name": "Laptop", "score": 0.95, "reason": "Perfecto para trabajo"},
        {"product_id": "P002", "name": "Mouse", "score": 0.87, "reason": "Complemento ideal"}
    ])
    mock_client.chat.completions.create.return_value = create_openai_chat_response(mock_response)
    
    raw_result = get_recommendations("laptop para trabajo", client=mock_client)
    output = RecommendationsOutput(recommendations=raw_result)
    
    assert len(output.recommendations) >= 1
    for rec in output.recommendations:
        assert 0 <= rec.score <= 1
        assert len(rec.reason) > 0

Ejercicio 4: Manejo de ValidationError

Escribe un test que verifica que cuando el LLM devuelve un output que viola el contrato, tu app lo maneja gracefully (no expone la excepción al usuario):

Ver solución
def test_handles_contract_violation_gracefully(mock_openai_client):
    """Cuando el LLM viola el contrato, la app retorna un error controlado."""
    # Mock que retorna output inválido (score > 1)
    mock_openai_client.chat.completions.create.return_value = create_openai_chat_response(
        '{"sentiment": "muy_positivo", "score": 1.5, "explanation": "", "keywords": []}'
    )
    
    result = analyze_sentiment("texto", client=mock_openai_client)
    
    # La app no debe crashear — debe retornar un resultado de fallback
    # Opción A: retorna None y loguea el error
    assert result is None or "error" in result
    # Opción B: retorna valores defaults
    # assert result["sentiment"] == "unknown"
    # assert result["score"] == 0.0

Ejercicio 5: Contract vs Evaluation en tu proyecto

Para tu propio proyecto (o uno hipotético), escribe:

  1. Tres contract tests (estructura y constraints)
  2. Un ejemplo de evaluation test que NO debe ir en los contract tests
Ver guía

Contract tests (estructura y constraints):

# Para un chatbot que responde preguntas:

def test_chatbot_contract_structure(mock_client):
    """La respuesta tiene la estructura esperada."""
    result = chatbot_respond("¿Cómo funciona Python?", client=mock_client)
    assert "answer" in result
    assert "confidence" in result
    assert "sources" in result

def test_chatbot_contract_types(mock_client):
    """Los tipos son correctos."""
    result = chatbot_respond("¿Qué es FastAPI?", client=mock_client)
    assert isinstance(result["answer"], str)
    assert isinstance(result["confidence"], float)
    assert isinstance(result["sources"], list)

def test_chatbot_contract_constraints(mock_client):
    """Los valores cumplen los constraints."""
    result = chatbot_respond("¿Qué es un API?", client=mock_client)
    assert len(result["answer"]) > 0      # No vacío
    assert len(result["answer"]) <= 2000  # Máximo 2000 chars
    assert 0 <= result["confidence"] <= 1 # Rango válido

Evaluation test (NO va en contract tests):

# Esto requiere LLM real + juicio semántico:
@pytest.mark.evaluation
def test_chatbot_answer_quality():
    """La respuesta explica correctamente el concepto."""
    result = chatbot_respond("¿Qué es un API REST?", client=REAL_CLIENT)
    # Esto NO es un contract test — requiere que el LLM entienda el concepto
    assert "representational state transfer" in result["answer"].lower()
    assert result["confidence"] > 0.8  # Requiere que el LLM sea preciso

Resumen

  • Contrato explícito = testeable: de "debe ser bueno" a "JSON con keys X, Y, Z de tipos A, B, C"
  • Cuatro partes del contrato: estructura, tipos, constraints, invariantes de error
  • Pydantic convierte el contrato en documentación ejecutable y validación automática
  • Parametrize permite validar todos los prompts de la app con un solo test
  • Contract test ≠ Evaluation: contract verifica forma, evaluation verifica calidad semántica
  • Workflow: prompt → contrato en prosa → modelo Pydantic → contract test → corre con mock

Recursos adicionales

  1. Pydantic Documentation — Validators — Field validators con @field_validator
  2. Pydantic v2 Migration Guide — Si vienes de Pydantic v1
  3. OpenAI Structured Outputs — Forzar JSON estructurado desde el LLM
  4. Contract Testing (Pact) — Conceptos de consumer-driven contract testing (para microservicios)
  5. pytest parametrize — Para testear múltiples contratos
  6. Python Enum — Para definir valores permitidos en el contrato
  7. Testing Best Practices — Organización de tests en pytest