Módulo 1: Testing Fundamentals para AI

2. Por qué AI Necesita Testing Diferente

Descripción

Las aplicaciones LLM tienen cuatro particularidades que hacen que el testing tradicional sea insuficiente: non-determinism en outputs, prompt fragility, model drift, y costo por test. Esta cápsula explora cada una con ejemplos concretos de cómo se manifiesta en producción y qué estrategia de testing le corresponde.

La conclusión no es "no puedes testear AI." La conclusión es: el 70-80% de tu app AI es completamente determinístico y testeable con asserts normales. Para el 20-30% restante hay estrategias específicas que aprenderás en módulos 2 y 3. Esta cápsula te da el mapa completo.

Al terminar entenderás exactamente qué partes de tu app puedes testear hoy (con herramientas estándar), qué partes requieren mocking, y qué partes requieren estrategias más avanzadas.


Problema 1: Non-determinism

Qué es

El mismo prompt con el mismo input puede producir outputs diferentes en cada llamada:

# tests/demos/non_determinism_demo.py
import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI()

def call_llm(prompt: str, temperature: float = 0.7) -> str:
    """Llamada directa al LLM para demostración."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=temperature,
    )
    return response.choices[0].message.content

# Mismo prompt, múltiples respuestas
prompt = "Describe Python en exactamente 2 palabras."

for i in range(3):
    result = call_llm(prompt)
    print(f"Run {i+1}: {result!r}")

# Output posible:
# Run 1: 'Lenguaje versátil.'
# Run 2: 'Simple poderoso.'
# Run 3: 'Multipropósito flexible.'

Ejecutar este demo: python tests/demos/non_determinism_demo.py

Por qué esto rompe tests tradicionales

# ESTO FALLA INTERMITENTEMENTE — NO HAGAS ESTO
def test_llm_describes_python():
    result = call_llm("Describe Python en exactamente 2 palabras.")
    assert result == "Lenguaje versátil."  # ❌ Falla 2 de cada 3 veces

El test pasa a veces y falla otras. Esto se llama flaky test y es el peor tipo de test: te hace perder confianza en toda tu suite porque nunca sabes si un fallo es real o aleatorio.

La solución correcta

# ESTO ES ROBUSTO — VERIFICA PROPIEDADES, NO VALOR EXACTO
def test_llm_describes_python_structure():
    result = call_llm("Describe Python en exactamente 2 palabras.")
    # Verifica propiedades invariantes, no el valor exacto
    words = result.strip().rstrip(".").split()
    assert len(words) == 2, f"Expected 2 words, got: {result!r}"
    assert all(word.isalpha() for word in words), f"Expected words only: {result!r}"

Pero incluso esto puede ser frágil. La solución definitiva para unit tests es mockear el LLM (módulo 2), de modo que el output es 100% predecible.

La regla del 70/30

Tu app LLM típica:

┌─────────────────────────────────────────┐
│ 70-80% DETERMINÍSTICO                   │
│                                         │
│  - validate_input()     → assert exacto │
│  - build_prompt()       → assert exacto │
│  - parse_llm_output()   → assert exacto │
│  - validate_schema()    → assert exacto │
│  - format_response()    → assert exacto │
│  - config loading       → assert exacto │
│  - chain routing logic  → mock + assert │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 20-30% NO-DETERMINÍSTICO                │
│                                         │
│  - call_llm()           → mockear       │
│  - streaming output     → mockear       │
│                                         │
│ Para tests que SÍ necesitan LLM real:   │
│  - semantic assertions (M3)             │
│  - property-based testing (M3)          │
└─────────────────────────────────────────┘

Problema 2: Prompt Fragility

Qué es

Los prompts son código frágil. Un cambio mínimo — añadir una palabra, cambiar puntuación, reformular una frase — puede cambiar completamente el comportamiento del LLM.

Ejemplo concreto: cambios que rompen el sistema

# src/app/prompts.py

# VERSION 1: Funciona perfectamente
PROMPT_V1 = """Analyze the sentiment of the following text.
Return ONLY a JSON object with this exact structure:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}

Text: {text}"""

# VERSION 2: Añadimos una instrucción útil... que rompe todo
PROMPT_V2 = """Analyze the sentiment of the following text.
Return ONLY a JSON object with this exact structure:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}
If the text is in Spanish, analyze accordingly.

Text: {text}"""

# VERSION 3: Pequeño cambio de formato
PROMPT_V3 = """Analyze the sentiment. Return JSON:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}

Text: {text}"""

Cómo se manifiestan estos cambios en producción:

# tests/unit/test_prompt_fragility_demo.py
import pytest
from unittest.mock import MagicMock, patch

def parse_sentiment_response(raw: str) -> dict:
    """Parser que asume JSON limpio."""
    import json
    return json.loads(raw.strip())

# ❌ V2 podría producir esto → rompe el parser:
problematic_outputs = [
    # El LLM añade nota al final
    '{"sentiment": "positive", "confidence": 0.9}\nNote: Text appears to be in English.',
    # El LLM añade markdown
    '```json\n{"sentiment": "positive", "confidence": 0.9}\n```',
    # El LLM añade texto introductorio
    'Here is the analysis: {"sentiment": "positive", "confidence": 0.9}',
]

@pytest.mark.parametrize("output", problematic_outputs)
def test_parser_handles_problematic_outputs(output):
    """Verifica que el parser maneja outputs problemáticos."""
    try:
        result = parse_sentiment_response(output)
        # Si llega aquí sin excepción, verificamos estructura
        assert "sentiment" in result
        assert "confidence" in result
    except Exception as e:
        # Si falla, el test documenta el tipo de output que no se maneja
        pytest.fail(f"Parser failed on output: {output!r}\nError: {e}")

Prompt Contract Tests

La solución es tratar los prompts como contratos de comportamiento y testearlos:

# tests/unit/test_prompt_contracts.py
import pytest
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment  # Función que construye prompt y llama al LLM

def create_mock_response(content: str):
    """Factory para respuestas mock realistas."""
    mock = MagicMock()
    mock.choices = [MagicMock()]
    mock.choices[0].message.content = content
    return mock

class TestSentimentPromptContract:
    """El prompt de sentiment SIEMPRE debe producir esta estructura."""

    @patch("app.sentiment.client.chat.completions.create")
    def test_returns_dict(self, mock_create):
        """Contrato: el resultado es un dict."""
        mock_create.return_value = create_mock_response(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("I love this!")
        assert isinstance(result, dict)

    @patch("app.sentiment.client.chat.completions.create")
    def test_has_required_keys(self, mock_create):
        """Contrato: el resultado tiene las keys requeridas."""
        mock_create.return_value = create_mock_response(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("I love this!")
        assert "sentiment" in result
        assert "confidence" in result

    @patch("app.sentiment.client.chat.completions.create")
    def test_sentiment_is_valid_value(self, mock_create):
        """Contrato: sentiment es uno de los valores válidos."""
        mock_create.return_value = create_mock_response(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("I love this!")
        assert result["sentiment"] in ["positive", "negative", "neutral"]

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_is_valid_range(self, mock_create):
        """Contrato: confidence está en rango [0.0, 1.0]."""
        mock_create.return_value = create_mock_response(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("I love this!")
        assert 0.0 <= result["confidence"] <= 1.0

Estos tests no verifican si el sentiment detectado es correcto (eso es evaluation). Verifican que el contrato estructural del prompt se cumple.


Problema 3: Model Drift

Qué es

Los proveedores de LLMs actualizan sus modelos constantemente. gpt-4o-mini de enero 2025 y gpt-4o-mini de julio 2025 pueden tener comportamientos ligeramente diferentes para el mismo prompt.

Casos reales de model drift

Escenario 1: Actualización silenciosa del modelo
- Tu app usa "gpt-3.5-turbo" (alias que siempre apunta al latest)
- OpenAI actualiza qué modelo es "latest"
- Tu prompt funcionaba con el modelo anterior
- El nuevo modelo produce formato JSON levemente diferente
- Tu parser falla silenciosamente
- Usuarios reciben errores durante 6 horas antes de que alguien se dé cuenta

Escenario 2: Migración de modelo
- Decides migrar de gpt-3.5-turbo a gpt-4o-mini por costo
- Aparentemente es un drop-in replacement
- 3 prompts de 12 producen output con formato diferente
- Sin tests de regression no sabes cuáles son los 3 hasta producción

Regression tests para model drift

# tests/regression/test_model_regression.py
"""
Tests de regresión para detectar model drift.
Se ejecutan semanalmente o antes de migrar modelo.
Son más lentos (llaman a API real) y cuestan dinero.
"""
import pytest
import json

# Solo correr en CI semanalmente o con flag especial
pytestmark = pytest.mark.regression

@pytest.fixture
def real_sentiment_app():
    """App real con LLM, para regression tests."""
    from app.sentiment import SentimentAnalyzer
    return SentimentAnalyzer()  # Usa API key real

class TestSentimentRegression:
    """
    Estos tests verifican que el modelo actual sigue comportándose
    igual que cuando configuramos los prompts.
    """

    @pytest.mark.parametrize("text,expected_sentiment", [
        ("I absolutely love this product!", "positive"),
        ("This is terrible, worst purchase ever.", "negative"),
        ("The package arrived on Tuesday.", "neutral"),
    ])
    def test_sentiment_matches_golden_set(self, real_sentiment_app, text, expected_sentiment):
        """
        Golden set: casos donde el sentimiento es inequívoco.
        Si el LLM falla en estos, hay model drift significativo.
        """
        result = real_sentiment_app.analyze(text)
        assert result["sentiment"] == expected_sentiment, (
            f"Model drift detectado para texto: {text!r}\n"
            f"Expected: {expected_sentiment}, Got: {result['sentiment']}"
        )

Ejecutar regression tests:

# Semanalmente (GitHub Actions cron)
pytest -m regression -v --tb=short

# Antes de migrar modelo
pytest -m regression -v

Problema 4: Cost Per Test

El cálculo real

# Ejemplo: app de análisis de documentos
# Modelo: gpt-4o-mini
# Tokens por test: ~500 input + ~200 output

# Precios actuales (verificar en platform.openai.com/pricing):
INPUT_PRICE_PER_1M = 0.15   # $0.15 por 1M tokens input
OUTPUT_PRICE_PER_1M = 0.60  # $0.60 por 1M tokens output

TOKENS_INPUT = 500
TOKENS_OUTPUT = 200

cost_per_test = (
    (TOKENS_INPUT / 1_000_000) * INPUT_PRICE_PER_1M +
    (TOKENS_OUTPUT / 1_000_000) * OUTPUT_PRICE_PER_1M
)
print(f"Costo por test: ${cost_per_test:.6f}")  # $0.000195

# Suite de 100 tests
cost_100_tests = cost_per_test * 100
print(f"100 tests: ${cost_100_tests:.4f}")  # $0.0195

# Si haces 20 PR por día, cada uno corre la suite:
daily_cost = cost_100_tests * 20
print(f"Costo diario: ${daily_cost:.2f}")  # $0.39/día

# Mensual:
monthly_cost = daily_cost * 22  # días laborables
print(f"Costo mensual: ${monthly_cost:.2f}")  # $8.58/mes

Parece poco. Pero considera:

  • Suite crece a 500 tests = $43/mes
  • Modelo más costoso (gpt-4o): ~30x más caro = ~$1,290/mes

Estrategia de 3 niveles

Nivel 1: Unit tests con mocks (rápidos, baratos)
├── Costo: $0 (no llaman a API)
├── Velocidad: <1 segundo por test
├── Cuándo correr: en cada save (watch mode) y cada PR
└── Cubren: 80% de la funcionalidad

Nivel 2: Integration tests con LLM real (lentos, tienen costo)
├── Costo: $X por run (depende de suite)
├── Velocidad: 2-10 segundos por test
├── Cuándo correr: en cada PR (con budget limit) o nightly
└── Cubren: flujos end-to-end críticos

Nivel 3: Regression tests (lentos, tienen costo)
├── Costo: $XX por run
├── Velocidad: variable
├── Cuándo correr: semanalmente o pre-deploy
└── Cubren: golden set para detectar model drift
# pytest.ini — configuración de la estrategia de 3 niveles
"""
[pytest]
testpaths = tests
markers =
    unit: Tests con mocks. Rápidos, sin costo. (default en CI)
    integration: Tests con LLM real. Tienen costo. Corren en PR.
    regression: Tests de regresión. Costosos. Corren semanalmente.
    smoke: Smoke tests. Verifican que el sistema levanta.
addopts = -v --tb=short
"""
# CI en cada commit: solo unit + smoke (sin costo)
pytest -m "unit or smoke"

# CI en cada PR: unit + smoke + integration (con budget limit)
pytest -m "not regression" --max-time=120

# Cron semanal: todo incluyendo regression
pytest --all

Por qué no testear cuesta más que testear

La siguiente tabla resume el costo real de no tener tests:

EventoSin testsCon tests
Cambio de prompt rompe el parseoBug vive en producción 2-6 horas, X usuarios afectadosCI detecta en 30 segundos antes del merge
Actualización de modelo cambia formatoBug silencioso, días hasta detectarRegression test falla en próximo run semanal
Refactoring rompe un parserNo hay forma de verificar, manual testing de 2 horaspytest -m unit en 10 segundos
Bug crítico en fin de semanaDebugging a las 3amAlerta de CI con línea exacta del fallo

El costo mensual de una suite de tests bien diseñada (unit con mocks gratuitos + integration controlados) es menor que una hora de debugging de un ingeniero senior.


Mapa completo: qué testear y cómo

# Clasificación de estrategia de testing por componente

TESTING_MAP = {
    # DETERMINÍSTICO → assert exacto, sin costo, rápido
    "input_validator": {
        "strategy": "assert exacto",
        "mock_llm": False,
        "example": "assert validate_input('') raises ValueError"
    },
    "prompt_builder": {
        "strategy": "assert exacto en string",
        "mock_llm": False,
        "example": "assert '{text}' in build_prompt(text='hello')"
    },
    "output_parser": {
        "strategy": "assert exacto con inputs fijos",
        "mock_llm": False,
        "example": "assert parse_json_output('{\"x\": 1}') == {'x': 1}"
    },
    "output_validator": {
        "strategy": "assert exacto con schema",
        "mock_llm": False,
        "example": "assert validate_schema({'sentiment': 'positive'}) is True"
    },

    # CHAIN/PIPELINE → mock LLM, assert flujo
    "llm_chain": {
        "strategy": "mock LLM + assert resultado",
        "mock_llm": True,
        "example": "mock llm returns JSON, assert chain returns parsed dict"
    },
    "orchestrator": {
        "strategy": "mock servicios externos + assert flujo",
        "mock_llm": True,
        "example": "mock llm + db, assert orchestrator calls in right order"
    },

    # NO-DETERMINÍSTICO → semantic assertions (módulo 3)
    "llm_output_quality": {
        "strategy": "semantic similarity / property-based (ver M3)",
        "mock_llm": False,
        "example": "assert len(response) > 50 and 'Python' in response"
    },
}

Ejemplo integrado: app de summarización

Para anclar todo lo anterior, aquí está el mapa completo de una app real:

# src/app/summarizer.py
import json
from openai import OpenAI

client = OpenAI()

# COMPONENTE 1: Determinístico (testeable con assert exacto)
def validate_input(text: str, max_chars: int = 10000) -> str:
    """Valida y limpia el input antes de enviarlo al LLM."""
    if not text or not text.strip():
        raise ValueError("Input no puede estar vacío")
    if len(text) > max_chars:
        raise ValueError(f"Input excede {max_chars} caracteres")
    return text.strip()

# COMPONENTE 2: Determinístico (testeable con assert exacto)
def build_summary_prompt(text: str, language: str = "es") -> str:
    """Construye el prompt para resumir."""
    return f"""Resume el siguiente texto en 3 puntos clave.
Responde SOLO con JSON: {{"points": ["punto1", "punto2", "punto3"]}}
Idioma de respuesta: {language}

Texto: {text}"""

# COMPONENTE 3: No-determinístico (mockear en unit tests)
def call_llm(prompt: str) -> str:
    """Llama al LLM. No-determinístico."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,  # Temperatura baja reduce variabilidad
    )
    return response.choices[0].message.content

# COMPONENTE 4: Determinístico (testeable con assert exacto)
def parse_summary_response(raw: str) -> dict:
    """Parsea la respuesta JSON del LLM."""
    # Extraer JSON aunque haya texto alrededor
    start = raw.find("{")
    end = raw.rfind("}") + 1
    if start == -1 or end == 0:
        raise ValueError(f"No se encontró JSON en: {raw!r}")
    return json.loads(raw[start:end])

# COMPONENTE 5: Determinístico (testeable con assert exacto)
def validate_summary(parsed: dict) -> dict:
    """Valida que el resumen tiene la estructura correcta."""
    if "points" not in parsed:
        raise ValueError("Falta key 'points' en respuesta")
    if not isinstance(parsed["points"], list):
        raise ValueError("'points' debe ser una lista")
    if len(parsed["points"]) != 3:
        raise ValueError(f"Se esperaban 3 puntos, got {len(parsed['points'])}")
    return parsed

# FUNCIÓN PRINCIPAL: Orquesta todo (testeable con mock del LLM)
def summarize(text: str, language: str = "es") -> dict:
    """Función principal: valida → construye prompt → llama LLM → parsea → valida."""
    validated_text = validate_input(text)
    prompt = build_summary_prompt(validated_text, language)
    raw_response = call_llm(prompt)  # ← único punto no-determinístico
    parsed = parse_summary_response(raw_response)
    return validate_summary(parsed)

Tests para cada componente:

# tests/unit/test_summarizer.py
import pytest
from unittest.mock import patch, MagicMock
from app.summarizer import (
    validate_input, build_summary_prompt,
    parse_summary_response, validate_summary, summarize
)

# Tests de validate_input — completamente determinísticos
class TestValidateInput:
    def test_valid_input(self):
        assert validate_input("Hello world") == "Hello world"

    def test_strips_whitespace(self):
        assert validate_input("  Hello  ") == "Hello"

    def test_empty_raises(self):
        with pytest.raises(ValueError, match="vacío"):
            validate_input("")

    def test_too_long_raises(self):
        with pytest.raises(ValueError, match="excede"):
            validate_input("x" * 10001)

# Tests de build_summary_prompt — completamente determinísticos
class TestBuildSummaryPrompt:
    def test_includes_text(self):
        prompt = build_summary_prompt("mi texto")
        assert "mi texto" in prompt

    def test_includes_language(self):
        prompt = build_summary_prompt("texto", language="en")
        assert "en" in prompt

    def test_includes_json_structure(self):
        prompt = build_summary_prompt("texto")
        assert '"points"' in prompt

# Tests de parse_summary_response — completamente determinísticos
class TestParseSummaryResponse:
    def test_valid_json(self):
        result = parse_summary_response('{"points": ["a", "b", "c"]}')
        assert result == {"points": ["a", "b", "c"]}

    def test_json_with_surrounding_text(self):
        raw = 'Aquí está: {"points": ["a", "b", "c"]} fin.'
        result = parse_summary_response(raw)
        assert result["points"] == ["a", "b", "c"]

    def test_no_json_raises(self):
        with pytest.raises(ValueError, match="No se encontró JSON"):
            parse_summary_response("No hay JSON aquí")

# Test de summarize — mockea el LLM
class TestSummarize:
    @patch("app.summarizer.client.chat.completions.create")
    def test_summarize_returns_valid_structure(self, mock_create):
        """Con mock del LLM, verifica que la función orquesta correctamente."""
        mock_response = MagicMock()
        mock_response.choices[0].message.content = (
            '{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
        )
        mock_create.return_value = mock_response

        result = summarize("Un texto largo sobre Python y AI.")

        assert isinstance(result, dict)
        assert "points" in result
        assert len(result["points"]) == 3
        mock_create.assert_called_once()  # Verificar que llamó al LLM exactamente una vez

Comparación de estrategias de testing

EstrategiaCostoVelocidadDeterminist.Cuándo usar
Assert exacto (sin mock)$0<1ms100%Parsers, validators, builders
Mock LLM + assert$0<10ms100%Chain logic, orquestación
LLM real + assert exacto$$2-5sNo❌ Flaky tests — evitar
LLM real + semantic assert$$2-5s~90%Integration tests críticos
LLM real + golden dataset$$$lento~80%Regression tests semanales

Troubleshooting

Problema: Mi test falla intermitentemente con el mismo código. Solución: Es un flaky test — estás llamando al LLM real en un test que debería mockear. Identifica la llamada no-mockeada con --tb=long y agrega @patch.

Problema: Mockeo el LLM pero el test igual llama a la API (veo cobros). Solución: El path del patch está mal. El patch debe apuntar a donde el objeto se usa, no donde se define. Si app.summarizer hace from openai import OpenAI; client = OpenAI(), el patch correcto es @patch("app.summarizer.client.chat.completions.create").

Problema: Los tests de regression son muy lentos y costosos. Solución: Usa un golden set pequeño (10-20 casos inequívocos) en vez de testear todo. El objetivo es detectar drift significativo, no perfección.

Problema: No sé cómo diferenciar un bug de non-determinism vs un bug real. Solución: Corre el test 3 veces. Si falla consistentemente → bug real. Si falla 1 de 3 → flaky test o non-determinism. La solución siempre es mockear el LLM en unit tests.

Problema: El LLM produce JSON válido pero con keys diferentes según el día. Solución: Tu prompt no especifica suficientemente el schema. Añade un ejemplo de JSON explícito en el prompt y/o usa structured outputs de OpenAI (función calling o response_format={"type": "json_object"}).


Ejercicios

Ejercicio 1: Clasificar componentes de tu app

Toma tu app LLM y clasifica cada función en: (a) determinístico puro, (b) requiere mock LLM, (c) requiere semantic assertions.

Ver guía

Criterio de clasificación:

  • Determinístico puro: La función no llama al LLM directamente ni usa su output. Ejemplos: parsers, validators, builders de prompt, formatters.
  • Requiere mock LLM: La función llama al LLM o usa su output, pero la lógica alrededor es determinística (if/else, routing, etc.)
  • Requiere semantic assertions: El test necesita verificar calidad semántica del output (no solo estructura). Ejemplos: test de que el resumen es coherente, test de que el sentiment detectado es correcto.

Ejemplo de clasificación:

# (a) Determinístico puro
def build_prompt(context: str) -> str: ...
def parse_json_response(raw: str) -> dict: ...
def validate_schema(data: dict) -> bool: ...

# (b) Requiere mock LLM
def generate_summary(text: str) -> dict:
    prompt = build_prompt(text)
    raw = call_llm(prompt)  # ← punto de mock
    return parse_json_response(raw)

# (c) Requiere semantic assertions
# Tests que verifican que el resumen es "correcto"
# → Módulo 3

Ejercicio 2: Calcular el costo de tu suite actual (o propuesta)

Estima el costo mensual si tu suite de 50 tests llamara a la API real en cada PR (20 PRs/día laborable).

Ver solución
# Cálculo
tests = 50
tokens_input = 500
tokens_output = 200
prs_per_day = 20
days_per_month = 22

# gpt-4o-mini (verificar precios actuales)
cost_per_input_token = 0.15 / 1_000_000
cost_per_output_token = 0.60 / 1_000_000

cost_per_test = (
    tokens_input * cost_per_input_token +
    tokens_output * cost_per_output_token
)
cost_per_run = cost_per_test * tests
cost_monthly = cost_per_run * prs_per_day * days_per_month

print(f"Costo por test: ${cost_per_test:.6f}")
print(f"Costo por run: ${cost_per_run:.4f}")
print(f"Costo mensual: ${cost_monthly:.2f}")

# Con mocks: $0 para unit tests
# Solo tests de integración (5-10% del total) usan API real

Conclusión: Incluso con gpt-4o-mini, 50 tests que llaman a la API real en cada PR costarían ~$10-20/mes. Con 500 tests o modelos más costosos, el costo es prohibitivo. Los mocks no son solo conveniencia — son necesidad económica.


Ejercicio 3: Escribir un contract test

Para la función summarize() del ejemplo de esta cápsula, escribe un contract test que verifique que el output tiene exactamente 3 puntos y cada uno es un string no vacío.

Ver solución
# tests/unit/test_summarize_contract.py
import pytest
from unittest.mock import patch, MagicMock
from app.summarizer import summarize

@patch("app.summarizer.client.chat.completions.create")
def test_summarize_three_non_empty_points(mock_create):
    """
    Contrato: summarize() siempre retorna exactamente 3 puntos no vacíos.
    Este test verifica la estructura, no la calidad del contenido.
    """
    # Arrange
    mock_response = MagicMock()
    mock_response.choices[0].message.content = (
        '{"points": ["Python es versátil", "Python es popular en AI", "Python tiene gran ecosistema"]}'
    )
    mock_create.return_value = mock_response

    # Act
    result = summarize("Texto sobre Python para demostración.")

    # Assert — contrato estructural
    assert "points" in result
    assert len(result["points"]) == 3
    for point in result["points"]:
        assert isinstance(point, str), f"Punto debe ser string: {point!r}"
        assert len(point.strip()) > 0, f"Punto no puede estar vacío: {point!r}"

Ejercicio 4: Identificar prompt fragility

El siguiente prompt tiene 2 formas de fallar silenciosamente. Identifícalas y propón soluciones.

prompt = """Analyze this customer feedback and provide insights.
Return your analysis as JSON.
Feedback: {text}"""
Ver solución

Problema 1: "Return your analysis as JSON" no especifica la estructura. El LLM puede retornar JSONs con keys variables: {"analysis": "..."}, {"insights": [...]}, {"summary": "...", "sentiment": "..."} — todo es "válido" según el prompt.

Solución:

prompt = """Analyze this customer feedback.
Return ONLY this JSON (no other text):
{{"sentiment": "positive|negative|neutral", "key_issue": "string", "recommendation": "string"}}

Feedback: {text}"""

Problema 2: "Return your analysis as JSON" no prohíbe texto adicional. El LLM puede escribir: "Here is my analysis: {...}" — el texto antes del JSON rompe json.loads().

Solución: Añadir "Return ONLY this JSON (no other text)" y/o usar response_format={"type": "json_object"} en OpenAI API.

Contract test para verificar:

@pytest.mark.parametrize("output", [
    '{"sentiment": "positive", "key_issue": "pricing", "recommendation": "add discount"}',
])
def test_feedback_prompt_contract(output, mocker):
    mocker.patch("app.feedback.call_llm", return_value=output)
    result = analyze_feedback("Great product but too expensive!")
    assert all(key in result for key in ["sentiment", "key_issue", "recommendation"])

Ejercicio 5: Diseñar la estrategia de testing para tu app

Para tu app LLM (o la de referencia del proyecto), define:

  1. ¿Cuántos unit tests (con mocks) necesitas? ¿Qué cubren?
  2. ¿Cuántos integration tests (LLM real) necesitas? ¿Cuándo se ejecutan?
  3. ¿Necesitas regression tests? ¿Con qué golden set?
Ver guía

Framework de decisión:

Unit tests (con mocks):
- Un test por función determinística (validators, parsers, builders)
- Un test por camino de ejecución en la función principal (happy path, error paths)
- Objetivo: cubrir el 80% del código sin llamadas a API
- Cuándo: en cada save, en cada PR

Integration tests (LLM real):
- 1-3 tests por flujo crítico de usuario
- Solo los flujos más usados en producción
- Objetivo: verificar que el sistema end-to-end funciona con LLM real
- Cuándo: en cada PR con budget limit ($0.50-1 por PR)

Regression tests:
- Solo si el modelo cambia frecuentemente o si tienes SLA
- Golden set pequeño (10-20 casos inequívocos)
- Cuándo: semanalmente o pre-deploy importante

Regla 80/20: 80% de los beneficios vienen de los unit tests con mocks. Los integration tests son el 20% extra que da confianza adicional. Si tienes que elegir, empieza con unit tests.


Resumen

  • Non-determinism solo afecta al output del LLM (~20-30% del código); el resto es determinístico y testeable con asserts normales
  • Prompt fragility: un cambio mínimo puede romper el parseo — los contract tests detectan esto antes del deploy
  • Model drift: los modelos cambian; los regression tests con golden sets detectan cambios de comportamiento
  • Costo por test: los unit tests deben usar mocks ($0); solo los integration tests usan LLM real (con budget controls)
  • La estrategia óptima son 3 niveles: unit (mocks, siempre), integration (LLM real, en PR), regression (LLM real, semanal)
  • El ROI de testing en AI es incluso mayor que en software tradicional por la naturaleza silenciosa de los failures

Recursos adicionales

  1. Testing ML Systems (Google) — Testing para sistemas de ML/AI en producción
  2. Non-Determinism in Testing (Martin Fowler) — Por qué los flaky tests son tan dañinos y cómo eliminarlos
  3. OpenAI Structured Outputsresponse_format para eliminar el problema de parseo de JSON
  4. pytest-mock — Integración más limpia de mocking con pytest
  5. Property-Based Testing with Hypothesis — Estrategia para manejar non-determinism (se profundiza en módulo 3)
  6. OpenAI API Pricing — Para calcular costos reales de tu suite de tests
  7. Evals for LLM Applications (Hamel Husain) — Framework mental para decidir qué evaluar vs qué testear