Análisis automático de patrones PBT · Suite generada con Hypothesis · Python 3.12
| Función | Propiedad | Fórmula | Justificación | Prioridad |
|---|---|---|---|---|
| encode_event | Roundtrip | decode(encode(x)) == x | Par serialización detectado. Cubre edge cases de caracteres Unicode, floats extremos, listas vacías. | ▲ Alta |
| encode_event | Determinismo | encode(x) == encode(x) | Misma entrada siempre produce misma salida (sin timestamps internos). | ◆ Media |
| encode_event | Preservación de tipo | isinstance(encode(x), str) | Output siempre string base64 válido (no bytes ni None). | ● Normal |
| decode_event | No excepción en datos válidos | no crash para encode(x) | Cualquier evento válido puede regresar al decoder sin error. | ▲ Alta |
| normalize_source | Idempotencia | f(f(x)) == f(x) | Normalizar dos veces produce el mismo resultado que una vez. | ◆ Media |
| normalize_source | Invariante de longitud | len(f(x)) > 0 si len(x) > 0 | Una fuente no vacía siempre produce un slug no vacío. | ● Normal |
| validate_utm | Invariante post-normalización | is_valid(normalize(params)) | Parámetros válidos normalizados deben pasar siempre la validación. | ◆ Media |
""" Property-based tests for cultivaflow.event_codec module. Generado con la skill testing-basado-en-propiedades · CULTIVA IA """ from hypothesis import given, strategies as st, settings, example from hypothesis.strategies import SearchStrategy import pytest from cultivaflow.event_codec import ( EventPayload, encode_event, decode_event, normalize_source, validate_utm, DecodeError, ) # ────────────────────────────────────────────────────────────── # ESTRATEGIAS DE GENERACIÓN # ────────────────────────────────────────────────────────────── @st.composite def event_payloads(draw) -> EventPayload: """ Estrategia para generar EventPayload realistas. Los constraints están en la estrategia, no en assume(). """ return EventPayload( event_id = draw(st.uuids().map(str)), campaign_id = draw(st.text( alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd')), min_size=1, max_size=64, )), source = draw(st.sampled_from([ "facebook_ads", "google_ads", "email", "organic", "tiktok", "linkedin", ])), medium = draw(st.text(min_size=1, max_size=32)), value_eur = draw(st.floats( min_value=0.0, max_value=999_999.99, allow_nan=False, allow_infinity=False, )), tags = draw(st.lists( st.text(min_size=1, max_size=32), max_size=20, )), metadata = draw(st.fixed_dictionaries({ "agency_id": st.uuids().map(str), "region": st.sampled_from(["ES", "MX", "CO", "AR"]), })), ) def utm_params_valid() -> SearchStrategy: """Genera dicts de UTM siempre válidos antes de normalizar.""" return st.fixed_dictionaries({ "utm_source" : st.text(min_size=1, max_size=64), "utm_medium" : st.text(min_size=1, max_size=64), "utm_campaign": st.text(min_size=1, max_size=128), }) # ────────────────────────────────────────────────────────────── # ROUNDTRIP: encode_event / decode_event # ────────────────────────────────────────────────────────────── class TestEncodeDecodeRoundtrip: """ Propiedad: decode(encode(x)) == x Prioridad: ALTA — par de serialización detectado. """ @given(event_payloads()) @example(EventPayload( event_id="00000000-0000-0000-0000-000000000000", campaign_id="X", source="organic", medium="none", value_eur=0.0, tags=[], metadata={"agency_id":"0","region":"ES"}, )) # Caso mínimo @example(EventPayload( event_id="ffffffff-ffff-ffff-ffff-ffffffffffff", campaign_id="A"*64, source="facebook_ads", medium="m"*32, value_eur=999999.99, tags=["t"]*20, metadata={"agency_id":"0","region":"AR"}, )) # Caso máximo @settings(max_examples=200) def test_roundtrip(self, event: EventPayload): """Codificar y decodificar devuelve el evento original.""" encoded = encode_event(event) decoded = decode_event(encoded) assert decoded == event @given(event_payloads()) @settings(max_examples=100) def test_encode_deterministic(self, event: EventPayload): """El mismo evento siempre produce el mismo string codificado.""" assert encode_event(event) == encode_event(event) @given(event_payloads()) @settings(max_examples=100) def test_encode_returns_string(self, event: EventPayload): """encode_event siempre devuelve un str (base64 válido).""" result = encode_event(event) assert isinstance(result, str) assert len(result) > 0 @given(st.binary(max_size=256)) @settings(max_examples=200) def test_decode_garbage_raises_or_succeeds(self, data: bytes): """Bytes aleatorios se decodifican o lanzan DecodeError (nunca crash).""" try: decode_event(data.decode("latin-1")) except DecodeError: pass # Comportamiento esperado except Exception as e: pytest.fail(f"Excepción inesperada {type(e).__name__}: {e}") # ────────────────────────────────────────────────────────────── # IDEMPOTENCIA: normalize_source # ────────────────────────────────────────────────────────────── class TestNormalizeSourceIdempotence: """ Propiedad: normalize(normalize(x)) == normalize(x) Prioridad: MEDIA — función de normalización detectada. """ @given(st.text(max_size=200)) @example("") @example(" Facebook Ads ") @example("GOOGLE_ADS") @example("TikTok Organic 🚀") @example(" " ) @settings(max_examples=300) def test_idempotent(self, source: str): """Normalizar dos veces produce el mismo resultado que una vez.""" once = normalize_source(source) twice = normalize_source(once) assert once == twice, ( f"No idempotente: '{source}' → '{once}' → '{twice}'" ) @given(st.text(min_size=1, max_size=200).filter(lambda s: s.strip())) @settings(max_examples=200) def test_nonempty_stays_nonempty(self, source: str): """Una fuente con contenido siempre produce un slug no vacío.""" result = normalize_source(source) assert len(result) > 0 @given(st.text(max_size=200)) @settings(max_examples=200) def test_returns_lowercase(self, source: str): """El resultado siempre está en minúsculas.""" result = normalize_source(source) assert result == result.lower() @given(st.text(max_size=200)) @settings(max_examples=200) def test_no_leading_trailing_whitespace(self, source: str): """El resultado nunca tiene espacios al inicio o final.""" result = normalize_source(source) assert result == result.strip() # ────────────────────────────────────────────────────────────── # INVARIANTE: validate_utm post-normalización # ────────────────────────────────────────────────────────────── class TestValidateUtmInvariant: """ Propiedad: is_valid(normalize(params)) para toda entrada válida. Prioridad: MEDIA — validador con normalizador detectados. """ @given(utm_params_valid()) @example({"utm_source": "google", "utm_medium": "cpc", "utm_campaign": "verano2025"}) @example({"utm_source": "A", "utm_medium": "B", "utm_campaign": "C"}) @settings(max_examples=200) def test_valid_params_pass_validation(self, params: dict): """Parámetros UTM válidos siempre superan la validación.""" assert validate_utm(params) is True @given(utm_params_valid()) @settings(max_examples=200) def test_validate_no_crash(self, params: dict): """validate_utm nunca lanza excepción para dicts bien formados.""" try: validate_utm(params) except Exception as e: pytest.fail(f"validate_utm crasheó: {e}")
# Instalación pip install hypothesis pytest # Ejecución rápida (desarrollo) pytest tests/test_event_codec_properties.py -v # CI — más ejemplos, seed fijo para reproducibilidad pytest tests/test_event_codec_properties.py --hypothesis-seed=42 -v # Ver estadísticas de cobertura de inputs pytest tests/test_event_codec_properties.py --hypothesis-show-statistics # Nightly — exhaustivo (sin deadline) HYPOTHESIS_MAX_EXAMPLES=1000 pytest tests/ -v --hypothesis-seed=0