Módulo 1: Testing Fundamentals para AI

6. Taxonomía de Tests: Smoke, Contract, Behavioral, Regression

Descripción

No todos los tests son iguales, y no todos valen el mismo tiempo de escritura. Sin una taxonomía clara, los equipos tienden a escribir tests al azar, duplicar esfuerzo, y nunca tener cobertura donde más importa. Esta cápsula define cuatro tipos de tests específicos para apps AI — smoke, contract, behavioral, y regression — con criterios claros de qué escribe cada uno y cuándo ejecutarlo.

La taxonomía no es teoría académica. Es un framework de decisión: cuando tienes 2 horas para escribir tests antes de un deploy, ¿qué escribes primero? Cuándo un bug llega a producción, ¿qué test lo habría detectado? Cuándo el LLM cambia de versión, ¿qué ejecutas para validar que nada se rompió?

Al terminar tendrás claridad sobre qué tipo de test corresponde a cada situación, cómo organizarlos con markers de pytest, y cuál es la prioridad de escritura para maximizar el valor con el menor tiempo invertido.


Los cuatro tipos de tests para AI

┌─────────────────────────────────────────────────────────────────┐
│                    PIRÁMIDE DE TESTS PARA AI                    │
│                                                                  │
│          ┌───────────────────────────────┐                      │
│          │      REGRESSION TESTS         │  → Añadir cuando     │
│          │  "¿Volvió algún bug?"         │    encuentras bugs   │
│          └───────────────────────────────┘                      │
│        ┌─────────────────────────────────────┐                  │
│        │       BEHAVIORAL TESTS              │  → Después de    │
│        │  "¿El output tiene las propiedades  │    contracts     │
│        │   esperadas?"                       │                  │
│        └─────────────────────────────────────┘                  │
│      ┌─────────────────────────────────────────────┐            │
│      │           CONTRACT TESTS                    │  → Segundo │
│      │  "¿El output cumple la estructura esperada?"│    paso    │
│      └─────────────────────────────────────────────┘            │
│    ┌─────────────────────────────────────────────────────┐      │
│    │                  SMOKE TESTS                        │      │
│    │  "¿El sistema arranca y responde?"                  │  → Primero │
│    └─────────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────┘

Tipo 1: Smoke Tests

Definición

Los smoke tests verifican que el sistema está vivo y responde. No prueban que la lógica es correcta — solo que el código arranca, los módulos se importan sin errores, y los endpoints principales responden.

El nombre viene de hardware: "¿Le sale humo al encenderlo?" Si el sistema humea (crashea al arrancar), nada más importa.

Características

  • Velocidad: Muy rápidos (<1 segundo por test)
  • Costo: $0 (no llaman al LLM real)
  • Determinísticos: 100%
  • Cuándo correr: Siempre — en cada commit, en cada PR, en local antes de empezar a trabajar

Ejemplos

# tests/unit/test_smoke.py
import pytest
from app import llm, parsers, sentiment


@pytest.mark.smoke
def test_app_modules_import_without_errors():
    """El paquete app y sus módulos se importan sin errores."""
    # Si cualquier import falla (NameError, ImportError, SyntaxError),
    # este test falla y indica que hay un problema crítico en el código
    assert llm is not None
    assert parsers is not None
    assert sentiment is not None


@pytest.mark.smoke
def test_main_functions_are_callable():
    """Las funciones principales del sistema son callable."""
    from app.sentiment import analyze_sentiment
    from app.summarizer import summarize
    from app.parsers import parse_json_from_llm_output

    assert callable(analyze_sentiment)
    assert callable(summarize)
    assert callable(parse_json_from_llm_output)


@pytest.mark.smoke
def test_config_loads_without_errors():
    """La configuración del sistema carga correctamente."""
    from app.config import settings
    # Solo verifica que existe, no que los valores son correctos
    assert settings is not None
    assert hasattr(settings, "model_name")
    assert hasattr(settings, "max_tokens")


# Para apps con FastAPI:
@pytest.mark.smoke
def test_api_health_check_returns_200(test_client):
    """El endpoint /health responde con 200."""
    response = test_client.get("/health")
    assert response.status_code == 200


@pytest.mark.smoke
def test_api_docs_accessible(test_client):
    """La documentación de Swagger está accesible."""
    response = test_client.get("/docs")
    assert response.status_code == 200


@pytest.mark.smoke
def test_main_endpoint_accepts_valid_request(test_client):
    """El endpoint principal acepta un request válido (no 404, no 422)."""
    response = test_client.post("/analyze", json={"text": "Test input"})
    # No verificamos el resultado — solo que el endpoint existe y acepta el formato
    assert response.status_code != 404, "Endpoint /analyze no existe"
    assert response.status_code != 422, "Endpoint /analyze rechaza el formato del request"

Cuántos smoke tests necesitas

App LLM mínima: 3-5 smoke tests
├── test_modules_import           ← SIEMPRE
├── test_main_function_callable   ← SIEMPRE
└── test_config_loads             ← Si tienes config

App con FastAPI: 5-8 smoke tests
├── test_modules_import
├── test_health_check_200
├── test_main_endpoint_accepts_request
├── test_docs_accessible          ← Opcional
└── test_db_connection            ← Si tienes DB

Tipo 2: Contract Tests

Definición

Los contract tests verifican que un componente cumple su "contrato": la estructura y tipos del output que prometió producir. Para apps LLM, el contrato más importante es el del prompt: "este prompt siempre produce JSON con estas keys y estos tipos."

El nombre viene de Design by Contract: si la función promete retornar {"sentiment": str, "confidence": float}, el contrato test verifica exactamente eso.

Características

  • Velocidad: Rápidos con mocks (<100ms por test)
  • Costo: $0 con mocks
  • Determinísticos: 100% con mocks
  • Cuándo correr: Siempre — en cada commit y PR

Ejemplos

# tests/unit/test_contracts.py
import pytest
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment
from app.summarizer import summarize


def create_mock(content: str) -> MagicMock:
    mock = MagicMock()
    mock.choices[0].message.content = content
    return mock


@pytest.mark.contract
class TestSentimentPromptContract:
    """
    El prompt de análisis de sentimiento SIEMPRE debe producir:
    - Un dict
    - Con keys "sentiment" y "confidence"
    - "sentiment" es uno de ["positive", "negative", "neutral"]
    - "confidence" es float en [0.0, 1.0]
    """

    @patch("app.sentiment.client.chat.completions.create")
    def test_returns_dict(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        assert isinstance(result, dict), f"Expected dict, got {type(result)}"

    @patch("app.sentiment.client.chat.completions.create")
    def test_has_sentiment_key(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        assert "sentiment" in result, f"Missing 'sentiment' key. Got: {result}"

    @patch("app.sentiment.client.chat.completions.create")
    def test_has_confidence_key(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        assert "confidence" in result, f"Missing 'confidence' key. Got: {result}"

    @patch("app.sentiment.client.chat.completions.create")
    def test_sentiment_is_valid_value(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        valid_sentiments = {"positive", "negative", "neutral"}
        assert result["sentiment"] in valid_sentiments, (
            f"sentiment debe ser uno de {valid_sentiments}, got: {result['sentiment']!r}"
        )

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_is_numeric(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        assert isinstance(result["confidence"], (int, float)), (
            f"confidence debe ser numérico, got: {type(result['confidence'])}"
        )

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_in_valid_range(self, mock_create):
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("texto")
        assert 0.0 <= result["confidence"] <= 1.0, (
            f"confidence fuera de rango [0,1]: {result['confidence']}"
        )


@pytest.mark.contract
class TestSummarizerPromptContract:
    """
    El prompt de summarización SIEMPRE debe producir:
    - Un dict con key "points"
    - "points" es una lista de exactamente 3 strings no vacíos
    """

    @patch("app.summarizer.client.chat.completions.create")
    def test_returns_dict_with_points(self, mock_create):
        mock_create.return_value = create_mock(
            '{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
        )
        result = summarize("texto largo")
        assert isinstance(result, dict)
        assert "points" in result
        assert isinstance(result["points"], list)

    @patch("app.summarizer.client.chat.completions.create")
    def test_returns_exactly_three_points(self, mock_create):
        mock_create.return_value = create_mock(
            '{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
        )
        result = summarize("texto largo")
        assert len(result["points"]) == 3, (
            f"Se esperaban 3 puntos, got {len(result['points'])}: {result['points']}"
        )

    @patch("app.summarizer.client.chat.completions.create")
    def test_each_point_is_non_empty_string(self, mock_create):
        mock_create.return_value = create_mock(
            '{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
        )
        result = summarize("texto largo")
        for i, point in enumerate(result["points"]):
            assert isinstance(point, str), f"Punto {i} debe ser str: {point!r}"
            assert len(point.strip()) > 0, f"Punto {i} no puede estar vacío"

Contract tests para parsers

@pytest.mark.contract
class TestJsonParserContract:
    """
    parse_json_from_llm_output SIEMPRE debe:
    - Retornar un dict para JSON válido (en cualquier formato)
    - Lanzar ValueError para input sin JSON
    - Lanzar JSONDecodeError para JSON malformado
    """

    @pytest.mark.parametrize("raw_json,expected", [
        ('{"x": 1}', {"x": 1}),
        ('```json\n{"x": 1}\n```', {"x": 1}),
        ('Result: {"x": 1}', {"x": 1}),
    ])
    def test_returns_dict_for_valid_json(self, raw_json, expected):
        result = parse_json_from_llm_output(raw_json)
        assert result == expected

    def test_raises_for_no_json(self):
        with pytest.raises(ValueError):
            parse_json_from_llm_output("No JSON aquí")

    def test_raises_for_malformed_json(self):
        import json
        with pytest.raises(json.JSONDecodeError):
            parse_json_from_llm_output('{"incomplete":')

Tipo 3: Behavioral Tests

Definición

Los behavioral tests verifican que el output tiene las propiedades esperadas sin comparar el valor exacto. Son más flexibles que los contract tests (que verifican estructura) y se usan cuando hay algo que no puede ser completamente determinístico o cuando la especificación es "dentro de un rango" en vez de "exactamente X".

Características

  • Velocidad: Rápidos con mocks; lentos con LLM real
  • Costo: $0 con mocks; $X con LLM real
  • Determinísticos: Con mocks sí; con LLM real, parcialmente
  • Cuándo correr: Siempre con mocks; solo en PR/nightly con LLM real

Ejemplos

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


@pytest.mark.behavioral
class TestSentimentBehavior:
    """Tests de comportamiento — propiedades del output, no valores exactos."""

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_reflects_certainty(self, mock_create):
        """
        Para input inequívoco (claramente positivo o negativo),
        la confidence debe ser alta (>0.7).
        Este test usa LLM mock con respuesta apropiada para texto fuerte.
        """
        mock_create.return_value = create_mock(
            '{"sentiment": "positive", "confidence": 0.95}'
        )
        result = analyze_sentiment("I absolutely LOVE this! Best product EVER!")
        # Behavioral: confidence debe ser alta para texto fuerte
        assert result["confidence"] > 0.7, (
            f"Para texto muy positivo esperamos confidence alta. Got: {result['confidence']}"
        )

    @patch("app.sentiment.client.chat.completions.create")
    def test_response_is_deterministic_for_same_input(self, mock_create):
        """Con mocks, el mismo input siempre produce el mismo output."""
        mock_response = create_mock('{"sentiment": "positive", "confidence": 0.9}')
        mock_create.return_value = mock_response

        result1 = analyze_sentiment("Same text")
        result2 = analyze_sentiment("Same text")

        assert result1 == result2


@pytest.mark.behavioral
class TestSummarizerBehavior:
    """Tests de comportamiento del summarizador."""

    @patch("app.summarizer.client.chat.completions.create")
    def test_summary_points_are_different_from_each_other(self, mock_create):
        """Los puntos del resumen no deben ser duplicados."""
        mock_create.return_value = create_mock(
            '{"points": ["Python es versátil", "Python domina AI", "Python tiene gran ecosistema"]}'
        )
        result = summarize("Texto largo sobre Python y AI en la industria")

        # Behavioral: los puntos no deben ser duplicados
        points = result["points"]
        unique_points = set(points)
        assert len(unique_points) == len(points), (
            f"Los puntos del resumen contienen duplicados: {points}"
        )

    @patch("app.summarizer.client.chat.completions.create")
    def test_summary_points_have_minimum_length(self, mock_create):
        """Cada punto del resumen debe tener una longitud mínima."""
        mock_create.return_value = create_mock(
            '{"points": ["Punto con suficiente información para ser útil", '
            '"Otro punto con contenido significativo", '
            '"El tercer punto también tiene contenido relevante"]}'
        )
        result = summarize("Texto largo")

        for i, point in enumerate(result["points"]):
            assert len(point) >= 10, (
                f"Punto {i} demasiado corto ({len(point)} chars): {point!r}"
            )


# Integration behavioral tests (con LLM real)
@pytest.mark.integration
@pytest.mark.behavioral
class TestSentimentBehaviorIntegration:
    """Tests de comportamiento con LLM real — tienen costo."""

    def test_positive_text_detected_correctly(self):
        """Para texto claramente positivo, el sentiment debe ser positive."""
        result = analyze_sentiment("I absolutely love this product! Amazing quality!")
        # Behavioral: para texto inequívocamente positivo, el LLM debe detectar positive
        assert result["sentiment"] == "positive", (
            f"Expected positive sentiment for clearly positive text. Got: {result['sentiment']}"
        )

    def test_negative_text_detected_correctly(self):
        """Para texto claramente negativo, el sentiment debe ser negative."""
        result = analyze_sentiment("Terrible experience. Worst product ever. Never again.")
        assert result["sentiment"] == "negative"

Tipo 4: Regression Tests

Definición

Los regression tests verifican que bugs conocidos no vuelvan a aparecer. Se crean DESPUÉS de encontrar y corregir un bug: cuando corriges el bug, añades un test con el input exacto que lo causó. Si alguien inadvertidamente introduce el mismo bug de nuevo, el test falla.

Características

  • Velocidad: Variable (depende del bug)
  • Costo: $0 si el bug era en parsers; $X si era en la respuesta del LLM
  • Determinísticos: Sí (con mocks para bugs de LLM)
  • Cuándo correr: Siempre — en cada commit y PR

Proceso de crear un regression test

Bug encontrado en producción:
"Input con emojis causa UnicodeDecodeError en el parser"

1. Reproducir el bug:
   >>> parse_json_from_llm_output('{"text": "I love 🎉 this!"}')
   UnicodeDecodeError: ...  ← Confirmado

2. Crear el regression test ANTES del fix:
   def test_parser_regression_handles_emoji_in_json():
       raw = '{"text": "I love 🎉 this!"}'
       result = parse_json_from_llm_output(raw)  ← Falla (expected)
       assert result["text"] == "I love 🎉 this!"

3. Implementar el fix

4. Verificar que el regression test pasa:
   pytest tests/regression/test_parser_regression.py -v  ← Ahora pasa ✅

5. El test queda en la suite permanentemente

Ejemplos

# tests/regression/test_parser_regression.py
"""
Tests de regresión: bugs que se han encontrado y corregido.
NO se deben borrar estos tests — previenen que los bugs vuelvan.
Cada test debe tener un comentario con la fecha y descripción del bug.
"""
import pytest
from app.parsers import parse_json_from_llm_output


@pytest.mark.regression
class TestParserRegressions:

    def test_handles_emojis_in_json_content(self):
        """
        Regresión [2026-01-15]: El parser lanzaba UnicodeDecodeError
        cuando el JSON contenía emojis. Fix: usar encoding='utf-8' en json.loads.
        """
        raw = '{"text": "I love 🎉 this product! Amazing! 🚀"}'
        result = parse_json_from_llm_output(raw)
        assert "🎉" in result["text"]
        assert "🚀" in result["text"]

    def test_handles_unicode_characters(self):
        """
        Regresión [2026-01-20]: Parser fallaba con caracteres españoles ñ, ü, é.
        Bug relacionado con el de emojis.
        """
        raw = '{"resumen": "El niño aprendió inglés y matemáticas"}'
        result = parse_json_from_llm_output(raw)
        assert "ñ" in result["resumen"]
        assert "é" in result["resumen"]

    def test_handles_nested_quotes_in_json(self):
        """
        Regresión [2026-02-03]: El parser fallaba cuando el contenido del JSON
        tenía comillas dobles escapadas (\\"). El LLM a veces produce esto.
        """
        raw = '{"quote": "She said \\"hello\\" to me"}'
        result = parse_json_from_llm_output(raw)
        assert 'hello' in result["quote"]

    def test_handles_newlines_in_json_values(self):
        """
        Regresión [2026-02-10]: El parser fallaba cuando los values del JSON
        contenían saltos de línea literales (no \\n escapados).
        """
        # El LLM a veces produce JSON con newlines literales en los valores
        raw = '{"text": "First line\\nSecond line\\nThird line"}'
        result = parse_json_from_llm_output(raw)
        assert "\n" in result["text"]


@pytest.mark.regression
class TestSentimentRegressions:

    @patch("app.sentiment.client.chat.completions.create")
    def test_handles_very_long_input_without_timeout(self, mock_create):
        """
        Regresión [2026-01-25]: La función se "colgaba" con inputs muy largos
        porque no había límite de tokens. Fix: truncar input a 5000 chars.
        """
        mock_create.return_value = create_mock(
            '{"sentiment": "neutral", "confidence": 0.5}'
        )
        very_long_text = "palabra " * 10000  # 80,000 caracteres

        # Debe completar en tiempo razonable (no timeout)
        result = analyze_sentiment(very_long_text)
        assert result["sentiment"] in ["positive", "negative", "neutral"]

    @patch("app.sentiment.client.chat.completions.create")
    def test_does_not_leak_api_key_in_error_message(self, mock_create):
        """
        Regresión [2026-02-01]: Un error en la gestión de excepciones
        incluía el API key en el mensaje de error. Fix: sanitizar mensajes de error.
        """
        mock_create.side_effect = Exception("Error with key sk-proj-abc123xyz")

        with pytest.raises(Exception) as exc_info:
            analyze_sentiment("texto")

        # El mensaje de error NO debe contener credenciales
        error_message = str(exc_info.value)
        assert "sk-proj" not in error_message, (
            "El mensaje de error no debe contener el API key"
        )

Cuándo escribir cada tipo

Framework de decisión

Situación → Tipo de test a escribir

"Voy a hacer deploy en 1 hora y no hay tests"
→ Smoke tests primero (5 min), luego contract tests para el flujo crítico

"Acabo de cambiar el prompt principal"
→ Contract tests para verificar que la estructura del output no cambió

"El LLM produce outputs con diferentes formatos según el día"
→ Behavioral tests sobre propiedades invariantes (longitud, rango, tipo)
→ Contract tests con mocks que cubran los formatos posibles

"Encontré un bug en producción"
→ Regression test con el input exacto que causó el bug
→ Fix → regression test pasa → integrar a la suite

"Voy a migrar de gpt-3.5-turbo a gpt-4o-mini"
→ Regression tests con golden set (los behaviors más importantes)
→ Ejecutar antes y después de la migración

"Quiero saber si mi refactoring no rompió nada"
→ Todos los tests existentes (smoke + contract + behavioral + regression)

Prioridad con tiempo limitado

Tiempo disponibleQué escribir
30 minutos2 smoke tests + 1 contract test del flujo más crítico
2 horasSmoke completo + contract tests para todos los prompts
1 díaTodo lo anterior + behavioral tests + regression para bugs conocidos
1 semanaSuite completa con todos los tipos para todos los componentes

Markers de pytest para cada tipo

Configuración en pytest.ini

[pytest]
testpaths = tests
markers =
    smoke: Tests de humo. Sin costo. Verifican que el sistema arranca y responde.
    contract: Tests de contrato. Sin costo. Verifican estructura y tipos del output.
    behavioral: Tests de comportamiento. Variable costo. Verifican propiedades del output.
    regression: Tests de regresión. Sin costo (mocks). Previenen bugs conocidos.
    unit: Tests con mocks. Sin costo. Rápidos.
    integration: Tests con LLM real. Tienen costo. Lentos.
addopts = -v --tb=short

Estrategia de ejecución por contexto

# Desarrollo local — siempre correr:
pytest -m "smoke or contract or regression"

# Pre-commit — tests rápidos:
pytest -m "smoke or unit" --no-header -q

# CI en cada PR — sin costo:
pytest -m "not integration" -v

# CI en PR para rama main — incluir integration:
pytest -m "not regression" -v  # Integration sí, regression no (costosos)

# CI semanal / pre-release — todo:
pytest --all -v

# Verificar después de cambio de prompt:
pytest -m "contract" -v

# Verificar después de migración de modelo:
pytest -m "regression or behavioral" -v

Organización de archivos

tests/
├── conftest.py                     # Fixtures globales
├── smoke/
│   └── test_smoke.py               # Todos los smoke tests
├── unit/
│   ├── contracts/
│   │   ├── test_sentiment_contract.py
│   │   ├── test_summarizer_contract.py
│   │   └── test_parser_contract.py
│   ├── behavioral/
│   │   └── test_behavioral.py
│   └── regression/
│       ├── test_parser_regression.py
│       └── test_sentiment_regression.py
└── integration/
    └── test_e2e.py

Comparación completa de los cuatro tipos

AspectoSmokeContractBehavioralRegression
Pregunta¿Arranca?¿Estructura correcta?¿Propiedades OK?¿Volvió bug X?
Cuándo escribirPrimeroSegundoTerceroAl encontrar bug
Usa mock LLMNo (no hay LLM)Sí/no
VelocidadMuy rápidoRápidoVariableVariable
Costo$0$0$0 (mock)$0 (mock)
Determinist.100%100%100% (mock)100%
Cuándo correrSiempreSiempreSiempreSiempre
Número típico3-85-205-15Crece con el tiempo

Troubleshooting

Problema: No sé si un test es "contract" o "behavioral". Solución: Contract = verifica estructura exacta (claves, tipos, valores válidos). Behavioral = verifica propiedad (rango, longitud, relación entre valores). Si comparas con == → contract. Si comparas con >, <, in, isinstance → behavioral.

Problema: Tengo muchos regression tests y algunos son lentos. Solución: Los regression tests de parsers/validators son rapidísimos (sin LLM). Los que requieren LLM real deben marcarse con @pytest.mark.integration además de @pytest.mark.regression para poder excluirlos del CI rápido.

Problema: Un behavioral test con LLM real falla intermitentemente. Solución: Si el behavioral test usa LLM real y verifica algo como "el sentiment es positive para texto positivo", puede fallar si el LLM cambia de comportamiento. Dos opciones: (1) convertirlo a unit test con mock (más robusto), o (2) aceptar que puede ser flaky y ejecutarlo solo en regression semanal.

Problema: No sé cómo manejar un smoke test que requiere conexión real. Solución: Los smoke tests deben ser lo más rápidos y baratos posible. Si el smoke test requiere LLM real, crea dos versiones: smoke con mock (siempre corre) y un integration smoke (solo en PR).

Problema: ¿Cómo organizar cuando un test parece ser de múltiples tipos? Solución: Aplica múltiples markers. @pytest.mark.regression @pytest.mark.behavioral es perfectamente válido. El test es un regression porque surgió de un bug, y behavioral porque verifica propiedades del output.


Ejercicios

Ejercicio 1: Clasificar tests

Clasifica estos tests como smoke, contract, behavioral o regression:

a) test_app_imports_without_errors()
b) test_summarize_returns_dict_with_points_key()
c) test_summary_length_is_at_least_50_chars()
d) test_parser_handles_emoji_input()
e) test_health_endpoint_returns_200()
f) test_confidence_is_between_0_and_1()
Ver solución
a) test_app_imports_without_errors()          → SMOKE
   "El sistema arranca" — básico, antes de todo.

b) test_summarize_returns_dict_with_points_key()  → CONTRACT
   Verifica estructura exacta: ¿existe la key "points"?

c) test_summary_length_is_at_least_50_chars()  → BEHAVIORAL
   Verifica propiedad (longitud mínima), no valor exacto.

d) test_parser_handles_emoji_input()           → REGRESSION
   "handles" + caso específico (emoji) → Surgió de un bug específico.

e) test_health_endpoint_returns_200()          → SMOKE
   Verifica que el endpoint existe y responde — básico.

f) test_confidence_is_between_0_and_1()        → CONTRACT
   Verifica rango válido para un tipo específico — parte del contrato del output.
   (Podría ser behavioral si verificas una propiedad de comportamiento semántico)

Ejercicio 2: Crear contract test desde especificación

El endpoint /classify debe retornar:

{
  "category": "technology|sports|politics|entertainment",
  "subcategory": "string (optional)",
  "confidence": "float between 0 and 1"
}

Escribe un contrato test completo.

Ver solución
@pytest.mark.contract
@patch("app.classifier.client.chat.completions.create")
def test_classify_endpoint_contract(mock_create):
    """
    Contrato del endpoint /classify:
    - Retorna dict con key 'category'
    - 'category' es uno de los valores válidos
    - 'confidence' está en [0.0, 1.0]
    - 'subcategory' es opcional pero si existe es string
    """
    mock_create.return_value = create_mock(
        '{"category": "technology", "subcategory": "AI", "confidence": 0.92}'
    )

    result = classify_text("OpenAI releases new model with enhanced reasoning capabilities.")

    # 1. Es un dict
    assert isinstance(result, dict)

    # 2. Tiene key 'category'
    assert "category" in result, f"Falta 'category'. Got: {result}"

    # 3. 'category' tiene valor válido
    valid_categories = {"technology", "sports", "politics", "entertainment"}
    assert result["category"] in valid_categories, (
        f"Categoría inválida: {result['category']!r}. Válidas: {valid_categories}"
    )

    # 4. Tiene key 'confidence'
    assert "confidence" in result

    # 5. 'confidence' es float en rango válido
    assert isinstance(result["confidence"], float)
    assert 0.0 <= result["confidence"] <= 1.0

    # 6. Si tiene 'subcategory', debe ser string
    if "subcategory" in result and result["subcategory"] is not None:
        assert isinstance(result["subcategory"], str)

Ejercicio 3: De bug a regression test

Describes este bug: "Cuando el input tiene comillas simples ('), el parseo del JSON falla con JSONDecodeError porque el LLM las interpreta como delimitadores de string."

Escribe el regression test para este bug.

Ver solución
@pytest.mark.regression
def test_parser_regression_handles_single_quotes_in_input():
    """
    Regresión [2026-02-15]: La función lanzaba JSONDecodeError cuando el
    input del usuario contenía comillas simples.
    
    Causa: El prompt construía el JSON con f-string y las comillas simples
    en el input se escapaban incorrectamente.
    
    Fix: Usar json.dumps() para serializar el texto del usuario en el prompt.
    """
    # Input que causó el bug
    problematic_input = "I'm really happy with this product! It's amazing!"

    # Debe procesar correctamente, sin excepción
    mock_response_content = '{"sentiment": "positive", "confidence": 0.95}'

    with patch("app.sentiment.client.chat.completions.create") as mock_create:
        mock_create.return_value = create_mock(mock_response_content)
        result = analyze_sentiment(problematic_input)

    # No debe haber lanzado excepción
    assert result is not None
    assert "sentiment" in result
    assert result["sentiment"] == "positive"

Ejercicio 4: Behavioral test para propiedad de distribución

Escribe un behavioral test que verifique que, dado un texto largo (>500 palabras), el resumen sea significativamente más corto (menos del 50% de la longitud original).

Ver solución
@pytest.mark.behavioral
@patch("app.summarizer.client.chat.completions.create")
def test_summarizer_compresses_long_text(mock_create):
    """
    Propiedad de comportamiento: el resumen debe ser más corto que el original.
    Para texto >500 palabras, el resumen debe ser <50% de la longitud.
    """
    # ─── ARRANGE ───
    long_text = " ".join(["palabra"] * 600)  # 600 palabras, ~3600 chars
    mock_create.return_value = create_mock(
        '{"points": ["Punto resumido 1", "Punto resumido 2", "Punto resumido 3"]}'
    )

    # ─── ACT ───
    result = summarize(long_text)

    # ─── ASSERT (behavioral) ───
    # La suma de puntos no debe exceder 50% del original
    summary_length = sum(len(p) for p in result["points"])
    original_length = len(long_text)

    assert summary_length < original_length * 0.5, (
        f"El resumen ({summary_length} chars) no es suficientemente corto. "
        f"Original: {original_length} chars. "
        f"Ratio: {summary_length/original_length:.1%} (esperado <50%)"
    )

Ejercicio 5: Priorización de tests

Tienes 4 horas antes de un deploy importante. Tu app tiene:

  • 3 prompts distintos
  • 5 parsers de output
  • 1 endpoint FastAPI principal
  • 2 bugs conocidos que ya fueron corregidos

¿Qué tests priorizas? Justifica el orden.

Ver guía
Hora 1: Smoke tests (5 min) + Contract tests de los 3 prompts (45 min)
─────────────────────────────────────────────────────────────────
- 1 smoke test: endpoint principal responde sin errores
- 1 smoke test: módulos se importan correctamente
- 3 contract tests: uno por prompt (estructura del output)

Hora 2: Contract tests de los 5 parsers (60 min)
─────────────────────────────────────────────────
- 5 contract tests: uno por parser
- Cubren el happy path + 1 edge case cada uno
- Total: ~10 tests en 60 min

Hora 3: Regression tests para los 2 bugs conocidos (30 min)
────────────────────────────────────────────────────────────
- 2 regression tests exactos (los inputs que causaron los bugs)
- Son los más fáciles de escribir: ya conoces el input problemático

Hora 4: Behavioral tests para los comportamientos más importantes (60 min)
──────────────────────────────────────────────────────────────────────────
- 2-3 behavioral tests para propiedades críticas (longitud, rango, formato)

Resultado: ~18-20 tests en 4 horas
- Cobertura: todos los flujos críticos tienen tests
- Zero riesgos conocidos: smoke + contracts
- Bugs previos documentados: regression tests

Resumen

  • Smoke tests: "¿Arranca?" → Siempre primero, siempre corren, 3-8 tests
  • Contract tests: "¿Estructura correcta?" → Segundo paso, con mocks, uno por prompt/parser
  • Behavioral tests: "¿Propiedades esperadas?" → Tercero, assertions flexibles sobre propiedades invariantes
  • Regression tests: "¿Volvió el bug?" → Se crean al encontrar bugs, nunca se borran
  • Orden de escritura: smoke → contract → behavioral → regression (cuando hay bugs)
  • Cada tipo tiene su marker: pytest -m contract para verificar prompts, pytest -m smoke para sanity check rápido
  • Los contract tests son el corazón del testing de apps LLM: protegen contra prompt fragility y model drift

Recursos adicionales

  1. Test Pyramid — Martin Fowler — La pirámide de tests original y su relevancia para AI
  2. Consumer-Driven Contract Testing — Pact — Contract testing en sistemas distribuidos (concepto aplicable)
  3. pytest markers documentation — Cómo usar markers para organizar y ejecutar subsets
  4. Regression Testing — Wikipedia — Fundamentos de regression testing
  5. Testing ML Systems — Google — Estrategias de testing para sistemas ML en producción
  6. Property-Based Testing with Hypothesis — Para behavioral tests avanzados (se profundiza en módulo 3)