Módulo 3: Integration Testing & Estrategias No-Determinísticas

5. Property-Based Testing

Descripción

En vez de escribir casos de test concretos ("dado input X, espero output Y"), defines propiedades invariantes que deben cumplirse para CUALQUIER input válido. Hypothesis genera automáticamente cientos de casos de test y busca los que violan tu propiedad — a menudo encontrando edge cases que jamás habrías pensado. Para apps LLM con mocks determinísticos, es la estrategia más efectiva para encontrar bugs en parsers, processors y lógica de negocio.


El problema que resuelve

Con testing basado en ejemplos, el coverage es limitado por tu imaginación:

# Testing basado en ejemplos: tú defines cada caso
def test_parse_json_standard():
    assert parse_json_response('{"x": 1}') == {"x": 1}

def test_parse_json_markdown():
    assert parse_json_response('```json\n{"x": 1}\n```') == {"x": 1}

def test_parse_empty():
    with pytest.raises(ValueError):
        parse_json_response("")

# ¿Qué pasa con estos casos que no escribiste?
# '{"x": 1}  '  (trailing spaces)
# '{"x": 1}\n\n{"y": 2}'  (dos JSONs)
# '{"x": "línea1\nlínea2"}'  (newline en valor)
# '{"x": 1.23456789012345}'  (float de alta precisión)
# — Hypothesis los encontrará por ti
# Property-based testing: defines la propiedad, Hypothesis hace el resto
from hypothesis import given, settings
import hypothesis.strategies as st

@given(
    content=st.fixed_dictionaries({
        "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
        "score": st.floats(min_value=0.0, max_value=1.0)
    })
)
def test_parse_json_roundtrip(content):
    """Propiedad: cualquier dict válido, al convertirlo a JSON y parsearlo, reproduce el original."""
    raw = json.dumps(content)
    result = parse_json_response(raw)
    assert result == content
    # Hypothesis generará cientos de variaciones de content para verificar esto

Instalación

pip install hypothesis

Conceptos fundamentales de Hypothesis

Strategies: cómo generar datos

import hypothesis.strategies as st

# Textos
st.text()                          # Cualquier texto Unicode
st.text(min_size=10, max_size=500) # Con límites de longitud
st.from_regex(r'[a-z\s]+')        # Texto que coincide con regex

# Números
st.integers(min_value=0, max_value=100)
st.floats(min_value=0.0, max_value=1.0, allow_nan=False)

# Colecciones
st.lists(st.text(), min_size=1, max_size=10)
st.dictionaries(st.text(), st.integers())
st.sampled_from(["positivo", "negativo", "neutral"])  # De una lista

# Compuestos
st.fixed_dictionaries({
    "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
    "score": st.floats(0, 1),
    "keywords": st.lists(st.text(min_size=1), max_size=5)
})

# Opcionales (None o el tipo)
st.one_of(st.none(), st.text())
st.text() | st.none()  # Equivalente

El workflow de Hypothesis

1. Defines la propiedad con @given
2. Hypothesis genera ejemplos automáticamente
3. Si encuentra un input que viola la propiedad: SHRINKING
   → Hypothesis reduce el input al mínimo que reproduce el fallo
   → Reporta el caso mínimo reproducible
4. Tú arreglas el bug
5. Hypothesis guarda el caso fallido en database para regresión futura

Propiedades para lógica determinística (la aplicación más valiosa)

Los parsers y processors son candidatos perfectos para property-based testing — son 100% determinísticos y tienen propiedades claras:

Parser properties

from hypothesis import given, settings, assume
import hypothesis.strategies as st
import json
import pytest

# Propiedad 1: Roundtrip — json.dumps → parse_json_response
@given(
    content=st.fixed_dictionaries({
        "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
        "score": st.floats(
            min_value=0.0,
            max_value=1.0,
            allow_nan=False,
            allow_infinity=False
        ),
        "keywords": st.lists(
            st.text(min_size=1, max_size=50, alphabet=st.characters(
                whitelist_categories=("L", "N", "Zs")  # Letras, números, espacios
            )),
            max_size=5
        )
    })
)
def test_parser_roundtrip_property(content):
    """
    PROPIEDAD: Para cualquier dict válido de sentimiento:
    json.dumps(d) → parse_json_response → resultado == d original
    """
    raw_json = json.dumps(content, ensure_ascii=False)
    result = parse_json_response(raw_json)
    
    assert result["sentiment"] == content["sentiment"]
    assert abs(result["score"] - content["score"]) < 1e-10  # Floats con tolerancia
    assert result["keywords"] == content["keywords"]

# Propiedad 2: Markdown wrapping — JSON en code block se parsea igual
@given(
    content=st.fixed_dictionaries({
        "x": st.integers(),
        "y": st.text(min_size=1, max_size=50)
    })
)
def test_parser_markdown_unwrapping(content):
    """
    PROPIEDAD: JSON dentro de ```json ... ``` se parsea igual que JSON directo.
    """
    raw_direct = json.dumps(content)
    raw_markdown = f"```json\n{json.dumps(content)}\n```"
    
    result_direct = parse_json_response(raw_direct)
    result_markdown = parse_json_response(raw_markdown)
    
    assert result_direct == result_markdown

# Propiedad 3: Error invariante — input vacío siempre lanza ValueError
@given(text=st.one_of(
    st.just(""),
    st.just("   "),
    st.just("\n\n\t"),
    st.text(max_size=5, alphabet=" \t\n")  # Solo whitespace
))
def test_parser_empty_raises_value_error(text):
    """
    PROPIEDAD: Cualquier string vacío o solo whitespace lanza ValueError.
    """
    with pytest.raises(ValueError):
        parse_json_response(text)

Processor properties

# Propiedad 4: score clamping
@given(raw_score=st.floats(allow_nan=False, allow_infinity=False))
def test_processor_score_always_in_range(raw_score):
    """
    PROPIEDAD: Sin importar el score input, el output siempre está en [0, 1].
    """
    result = process_sentiment_output({"sentiment": "neutral", "score": raw_score})
    
    assert 0.0 <= result["score"] <= 1.0, \
        f"Score {raw_score}{result['score']} — fuera de rango [0, 1]"

# Propiedad 5: sentiment normalization
@given(sentiment=st.text(max_size=100))
def test_processor_sentiment_always_valid(sentiment):
    """
    PROPIEDAD: Sin importar el sentiment input, el output es siempre uno de los tres válidos.
    """
    result = process_sentiment_output({"sentiment": sentiment, "score": 0.5})
    
    assert result["sentiment"] in ["positivo", "negativo", "neutral"], \
        f"'{sentiment}' → '{result['sentiment']}' — no es uno de los valores válidos"

# Propiedad 6: keywords siempre list
@given(keywords=st.one_of(
    st.just(None),
    st.just(""),
    st.just([]),
    st.lists(st.text()),
    st.text(),
    st.integers()
))
def test_processor_keywords_always_list(keywords):
    """
    PROPIEDAD: Sin importar el tipo del input de keywords, el output siempre es una list.
    """
    result = process_sentiment_output({"sentiment": "neutral", "score": 0.5, "keywords": keywords})
    
    assert isinstance(result["keywords"], list), \
        f"keywords input {keywords!r} → tipo {type(result['keywords'])} — debe ser list"

Propiedades para LLM con mocks: la combinación poderosa

Con mocks determinísticos, property-based testing es especialmente potente:

@given(
    content=st.fixed_dictionaries({
        "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
        "score": st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
        "explanation": st.text(max_size=200),
        "keywords": st.lists(st.text(min_size=1, max_size=30), max_size=5)
    })
)
@settings(max_examples=50)  # Limitar porque cada ejemplo crea un mock
def test_pipeline_property_with_mock(content):
    """
    PROPIEDAD: Para cualquier JSON válido del LLM (mockeado),
    el pipeline completo produce un resultado que cumple el contrato.
    """
    import json
    from unittest.mock import MagicMock
    from tests.helpers import create_openai_chat_response
    
    # Crear mock con el content generado por Hypothesis
    mock_client = MagicMock()
    mock_client.chat.completions.create.return_value = create_openai_chat_response(
        json.dumps(content)
    )
    
    # Ejecutar el pipeline
    result = analyze_sentiment("texto de prueba", client=mock_client)
    
    # Verificar el contrato sobre el resultado
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]
    assert 0.0 <= result["score"] <= 1.0
    assert isinstance(result["keywords"], list)
    assert isinstance(result["explanation"], str)
    # El pipeline no debe crashear para ningún input válido del LLM

Propiedades de error handling

@given(error_content=st.one_of(
    st.just(""),
    st.just("{}"),
    st.just("{invalid json}"),
    st.text(max_size=200)  # Texto aleatorio
))
@settings(max_examples=30)
def test_pipeline_handles_invalid_llm_output(error_content):
    """
    PROPIEDAD: El pipeline NUNCA crashea — siempre retorna algo o lanza una excepción conocida.
    No debe lanzar excepciones inesperadas como KeyError, AttributeError, etc.
    """
    from unittest.mock import MagicMock
    from tests.helpers import create_openai_chat_response
    
    mock_client = MagicMock()
    mock_client.chat.completions.create.return_value = create_openai_chat_response(error_content)
    
    try:
        result = analyze_sentiment("texto", client=mock_client)
        # Si no lanza excepción, el resultado debe ser un dict con keys mínimas
        assert isinstance(result, dict)
    except ValueError:
        pass  # ValueError es aceptable (input inválido)
    except Exception as e:
        # Ninguna otra excepción es aceptable
        pytest.fail(
            f"analyze_sentiment lanzó excepción inesperada para content={error_content!r}: "
            f"{type(e).__name__}: {e}"
        )

Estrategias avanzadas

Estrategia para textos en español

# Letras del español (incluyendo tildes y ñ)
spanish_alphabet = st.characters(
    whitelist_categories=("L",),  # Solo letras
    whitelist_characters=" .,;:!?¿¡"  # Puntuación española
)

spanish_text = st.text(
    alphabet=spanish_alphabet,
    min_size=5,
    max_size=500
)

@given(text=spanish_text)
def test_pipeline_handles_spanish_text(text):
    """El pipeline maneja texto en español sin errores."""
    mock_client = make_sentiment_mock_for(text)
    result = analyze_sentiment(text, client=mock_client)
    assert isinstance(result, dict)

Estrategia para simular respuestas del LLM

# Estrategia que genera respuestas realistas del LLM (JSON bien formado)
valid_llm_response = st.fixed_dictionaries({
    "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
    "score": st.floats(0, 1, allow_nan=False),
    "keywords": st.lists(st.text(min_size=1, max_size=20), min_size=0, max_size=5),
    "explanation": st.text(min_size=0, max_size=200)
})

# Estrategia que genera respuestas "sucias" del LLM (con formato variable)
dirty_llm_response = st.one_of(
    valid_llm_response.map(json.dumps),                            # JSON directo
    valid_llm_response.map(lambda d: f"```json\n{json.dumps(d)}\n```"),  # Markdown
    valid_llm_response.map(lambda d: f"Respuesta: {json.dumps(d)}")  # Con prefijo
)

Configuración de max_examples

from hypothesis import given, settings, HealthCheck

# Default: 100 ejemplos
@given(content=st.text())
def test_default():
    ...

# Para tests rápidos (lógica simple):
@given(content=st.text())
@settings(max_examples=200)
def test_thorough():
    ...

# Para tests con mocks (un poco más lentos):
@given(content=st.fixed_dictionaries({"x": st.integers()}))
@settings(max_examples=50)
def test_with_mock():
    ...

# Para tests con LLM real (muy caros):
@given(content=st.sampled_from(PREDEFINED_CASES))
@settings(max_examples=5)
def test_with_real_llm():
    ...
    
# Suprimir health checks si necesario:
@settings(suppress_health_check=[HealthCheck.too_slow])

Comparación: ejemplo-based vs property-based

AspectoEjemplo-basedProperty-based
Cómo defines el test"X → Y""Para todo X, la propiedad P se cumple"
Número de casosLos que escribasCientos, automáticamente
Edge casesLos que imaginesHypothesis los busca sistemáticamente
LegibilidadAlta (intuitivo)Media (requiere pensar en propiedades)
MantenimientoBajo (salvo cambios)Bajo (las propiedades son estables)
Con LLM realPeligroso (100x llamadas)Solo con mock, o max_examples pequeño
Ideal paraComportamientos específicos conocidosInvariantes y lógica determinística

Ejercicios

Ejercicio 1: Tu primera propiedad

Identifica una propiedad invariante de tu función format_summary_result:

def format_summary_result(result: dict) -> str:
    """Formatea el resultado de resumen como string para el usuario."""
    return f"Resumen: {result['summary']} (confidence: {result['confidence']:.0%})"

Escribe el test con @given:

Ver solución
@given(
    summary=st.text(min_size=1, max_size=200),
    confidence=st.floats(min_value=0.0, max_value=1.0, allow_nan=False)
)
def test_format_summary_result_properties(summary, confidence):
    """
    PROPIEDADES:
    1. El resultado siempre contiene el summary
    2. El resultado siempre empieza con "Resumen:"
    3. El resultado siempre es un string no vacío
    """
    result_dict = {"summary": summary, "confidence": confidence}
    formatted = format_summary_result(result_dict)
    
    assert isinstance(formatted, str)
    assert len(formatted) > 0
    assert formatted.startswith("Resumen:")
    assert summary in formatted  # El summary original está en el resultado

Ejercicio 2: Propiedad de clamping

Escribe una propiedad para el score clamping del processor. El score siempre debe estar en [0, 1] independientemente del input:

Ver solución
@given(raw_score=st.one_of(
    st.floats(allow_nan=False, allow_infinity=False),
    st.integers(),
    st.text(),  # Tipo inválido
    st.none()
))
def test_score_clamping_property(raw_score):
    """
    PROPIEDAD: Para cualquier input de score (válido o inválido),
    el score en el output siempre está en [0.0, 1.0].
    """
    result = process_sentiment_output({"sentiment": "neutral", "score": raw_score})
    
    score = result["score"]
    assert isinstance(score, float), f"Score debe ser float, es {type(score)}"
    assert 0.0 <= score <= 1.0, f"Score {score} fuera de [0, 1]"
    assert not (score != score)  # No es NaN

Ejercicio 3: Shrinking en acción

El siguiente test tiene un bug. Identifica qué propiedad viola y explica cómo Hypothesis encontraría el caso mínimo:

def truncate_text(text: str, max_chars: int = 100) -> str:
    """Trunca el texto a max_chars caracteres."""
    if len(text) > max_chars:
        return text[:max_chars]
    return text

¿Qué propiedad podrías escribir que encontraría un edge case?

Ver solución
@given(
    text=st.text(max_size=500),
    max_chars=st.integers(min_value=0, max_value=1000)
)
def test_truncate_text_properties(text, max_chars):
    """
    PROPIEDADES:
    1. El resultado nunca es más largo que max_chars
    2. El resultado nunca es más largo que el input
    3. Si el input <= max_chars, el resultado es idéntico al input
    """
    result = truncate_text(text, max_chars)
    
    # Propiedad 1: longitud máxima respetada
    assert len(result) <= max_chars, f"len(result)={len(result)} > max_chars={max_chars}"
    
    # Propiedad 2: no añade caracteres
    assert len(result) <= len(text)
    
    # Propiedad 3: input corto no se modifica
    if len(text) <= max_chars:
        assert result == text, f"Input corto fue modificado: '{text}' → '{result}'"

# Bug que Hypothesis encontraría:
# max_chars = 0 → text[:0] = "" ← ¿Es este el comportamiento esperado para max_chars=0?
# Hypothesis encontrará el caso mínimo: text="a", max_chars=0
# Y reportará que text[:0]="" es el resultado — puedes decidir si es correcto

Ejercicio 4: Propiedad con mock

Escribe una propiedad que verifique que para cualquier respuesta válida del LLM (mockeada), el pipeline retorna un sentimiento en la lista permitida:

Ver solución
from hypothesis import given, settings
import hypothesis.strategies as st
from unittest.mock import MagicMock
from tests.helpers import create_openai_chat_response
import json

valid_sentiment_response = st.fixed_dictionaries({
    "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
    "score": st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
    "explanation": st.text(max_size=100),
    "keywords": st.lists(st.text(min_size=1, max_size=20), max_size=3)
})

@given(llm_output=valid_sentiment_response)
@settings(max_examples=50)
def test_pipeline_sentiment_always_valid(llm_output):
    """
    PROPIEDAD: Para cualquier respuesta válida del LLM,
    el pipeline produce un sentiment en la lista permitida.
    """
    mock_client = MagicMock()
    mock_client.chat.completions.create.return_value = create_openai_chat_response(
        json.dumps(llm_output)
    )
    
    result = analyze_sentiment("texto cualquiera", client=mock_client)
    
    assert result["sentiment"] in ["positivo", "negativo", "neutral"], \
        f"Sentiment '{result['sentiment']}' no está en la lista permitida. LLM output: {llm_output}"

Ejercicio 5: Cuándo NO usar Hypothesis

Describe 3 situaciones donde property-based testing no es la herramienta correcta:

Ver guía
  1. Comportamientos muy específicos con valores exactos: Si el test es "para el input 'Hola, mundo', el output es 'Greeting detected'", no hay propiedad que generalizar. Usa ejemplo-based.

  2. Tests con LLM real (no mockeado) si max_examples es alto: 100 ejemplos × 1 LLM call = 100 API calls. Con gpt-4o-mini: ~$0.01 por run. Puede ser OK si max_examples=5, pero es peligroso con defaults.

  3. Comportamientos de UI o visualización: Si el test verifica que "el botón tiene el color correcto" o "el gráfico muestra el dato X", no hay propiedades claras para Hypothesis. Usa tests visuales específicos.

  4. Tests de estado de base de datos con rollback complejo: Si cada ejemplo requiere setup y teardown de BD, Hypothesis puede generar problemas de estado. Mejor usar fixtures de pytest con casos específicos.


Resumen

  • Property-based testing = invariantes que se cumplen para cualquier input — más robusto que ejemplos manuales
  • Hypothesis genera casos automáticamente y hace shrinking para encontrar el mínimo reproductor
  • Ideal para parsers y processors: 100% determinísticos, propiedades claras, sin costo de API
  • Con mocks: combinar con @given para coverage masivo sin llamadas al LLM real
  • Con LLM real: usar max_examples pequeño para controlar costo
  • Propiedades útiles: clamping de rangos, invariantes de tipo, roundtrip de parsing, manejo de errores

Recursos adicionales

  1. Hypothesis Documentation — Documentación completa
  2. Hypothesis Strategies — Todas las estrategias disponibles
  3. In Praise of Property-Based Testing — Por qué es poderoso
  4. Hypothesis Settings — Configuración de max_examples y otros
  5. QuickCheck — Haskell — El origen del concepto
  6. Property-Based Testing in Python — Tutorial de Real Python
  7. Shrinking in Hypothesis — Cómo Hypothesis minimiza los casos fallidos