Módulo 1: Testing Fundamentals para AI

7. Proyecto: Test Suite Setup

Descripción

Este es el mini-proyecto del Módulo 1. El objetivo: tomar una app LLM real (la de referencia provista o la tuya propia) y configurar una infraestructura de testing completa desde cero — pytest configurado, fixtures para mocking del LLM, estructura de directorios, smoke tests, contract tests y regression tests funcionales.

No es un ejercicio teórico. Al terminar tendrás una suite real corriendo, con pytest -m "not integration" ejecutándose en menos de 30 segundos con todos los tests en verde. Esta suite es la base sobre la que construirás todos los módulos siguientes.

Al terminar habrás aplicado todo el Módulo 1: la taxonomía de tests, el patrón AAA, las fixtures para LLM, los markers de pytest, y la estrategia de testing en capas.


Prerequisitos del proyecto

Antes de empezar verifica que tienes:

# Python 3.10+
python --version

# Dependencias instaladas
pip install pytest pytest-asyncio pytest-mock pytest-cov
pip install openai pydantic python-dotenv

# Verificar pytest
pytest --version
# pytest 8.x.x

La app de referencia

Si no tienes una app propia, usa esta app de análisis de sentimiento:

# src/app/__init__.py
# (vacío)
# src/app/config.py
import os
from dotenv import load_dotenv

load_dotenv()


class Settings:
    """Configuración centralizada de la app."""
    model_name: str = os.getenv("LLM_MODEL", "gpt-4o-mini")
    max_tokens: int = int(os.getenv("MAX_TOKENS", "500"))
    temperature: float = float(os.getenv("TEMPERATURE", "0.3"))
    openai_api_key: str = os.getenv("OPENAI_API_KEY", "")

    @property
    def is_configured(self) -> bool:
        return bool(self.openai_api_key)


settings = Settings()
# src/app/parsers.py
import json
import re


def parse_json_from_llm_output(raw: str) -> dict:
    """
    Extrae y parsea JSON de la respuesta del LLM.
    Maneja JSON puro, JSON en bloques markdown, JSON con texto alrededor.
    
    Args:
        raw: String con la respuesta del LLM
        
    Returns:
        dict con el JSON parseado
        
    Raises:
        ValueError: Si no se encuentra JSON en el string
        json.JSONDecodeError: Si el JSON está malformado
    """
    if not raw or not raw.strip():
        raise ValueError(f"Input vacío o None: {raw!r}")

    # Intentar 1: JSON puro (más común con response_format=json_object)
    try:
        return json.loads(raw.strip())
    except json.JSONDecodeError:
        pass

    # Intentar 2: Extraer de bloque markdown ```json ... ```
    markdown_match = re.search(r'```(?:json)?\s*\n(.*?)\n```', raw, re.DOTALL)
    if markdown_match:
        return json.loads(markdown_match.group(1))

    # Intentar 3: Extraer JSON de texto mixto (buscar primer { y último })
    start = raw.find('{')
    end = raw.rfind('}')
    if start != -1 and end != -1 and end > start:
        return json.loads(raw[start:end + 1])

    raise ValueError(f"No JSON found in: {raw!r}")
# src/app/sentiment.py
from openai import OpenAI
from app.config import settings
from app.parsers import parse_json_from_llm_output

client = OpenAI(api_key=settings.openai_api_key)


def validate_input(text: str, max_chars: int = 5000) -> str:
    """Valida y normaliza el input antes de enviarlo al LLM."""
    if not text or not text.strip():
        raise ValueError("El texto de análisis no puede estar vacío")
    if len(text) > max_chars:
        # Truncar en vez de fallar (graceful degradation)
        text = text[:max_chars]
    return text.strip()


def build_sentiment_prompt(text: str) -> str:
    """Construye el prompt para análisis de sentimiento."""
    return f"""Analyze the sentiment of the following text.
Return ONLY a JSON object with this exact structure (no other text):
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}

Text: {text}"""


def analyze_sentiment(text: str) -> dict:
    """
    Analiza el sentimiento de un texto usando LLM.
    
    Returns:
        dict con keys "sentiment" (str) y "confidence" (float)
        
    Raises:
        ValueError: Si el input es inválido
    """
    # Validar input
    validated_text = validate_input(text)

    # Construir prompt
    prompt = build_sentiment_prompt(validated_text)

    # Llamar al LLM
    response = client.chat.completions.create(
        model=settings.model_name,
        messages=[{"role": "user", "content": prompt}],
        temperature=settings.temperature,
        max_tokens=settings.max_tokens,
    )

    # Parsear respuesta
    raw_content = response.choices[0].message.content
    return parse_json_from_llm_output(raw_content)
# src/app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app.sentiment import analyze_sentiment

app = FastAPI(title="Sentiment Analysis API", version="1.0.0")


class TextInput(BaseModel):
    text: str


class SentimentResponse(BaseModel):
    sentiment: str
    confidence: float


@app.get("/health")
def health_check():
    return {"status": "healthy", "service": "sentiment-api", "version": "1.0.0"}


@app.post("/analyze", response_model=SentimentResponse)
def analyze(input_data: TextInput):
    try:
        result = analyze_sentiment(input_data.text)
        return SentimentResponse(**result)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail="Internal server error")
# requirements.txt
openai>=1.0.0
fastapi>=0.100.0
uvicorn>=0.23.0
pydantic>=2.0.0
python-dotenv>=1.0.0
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-mock>=3.11.0
pytest-cov>=4.1.0
httpx>=0.24.0

Paso 1: Estructura de directorios

Crea la siguiente estructura:

mkdir -p tests/unit/contracts tests/unit/behavioral tests/unit/regression
mkdir -p tests/integration tests/smoke
touch tests/__init__.py tests/unit/__init__.py
touch tests/smoke/__init__.py tests/integration/__init__.py
touch tests/unit/contracts/__init__.py
touch tests/unit/behavioral/__init__.py
touch tests/unit/regression/__init__.py

Resultado esperado:

mi-app-ai/
├── src/
│   └── app/
│       ├── __init__.py
│       ├── config.py
│       ├── parsers.py
│       ├── sentiment.py
│       └── main.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py               ← Crearemos en Paso 2
│   ├── helpers.py                ← Crearemos en Paso 2
│   ├── smoke/
│   │   ├── __init__.py
│   │   └── test_smoke.py         ← Crearemos en Paso 4
│   ├── unit/
│   │   ├── __init__.py
│   │   ├── contracts/
│   │   │   ├── __init__.py
│   │   │   └── test_contracts.py ← Crearemos en Paso 5
│   │   ├── behavioral/
│   │   │   ├── __init__.py
│   │   │   └── test_behavioral.py ← Crearemos en Paso 6
│   │   └── regression/
│   │       ├── __init__.py
│   │       └── test_regression.py ← Crearemos en Paso 7
│   └── integration/
│       ├── __init__.py
│       └── test_e2e.py           ← Estructura lista (módulo 3)
├── pytest.ini                    ← Crearemos en Paso 3
├── .env
└── requirements.txt

Paso 2: Helpers y conftest.py

helpers.py

# tests/helpers.py
"""
Funciones helper para testing. No son fixtures pytest.
Se importan en conftest.py y en tests que necesitan control fino.
"""
from unittest.mock import MagicMock


def create_openai_chat_response(
    content: str,
    prompt_tokens: int = 100,
    completion_tokens: int = 50,
    finish_reason: str = "stop",
    model: str = "gpt-4o-mini",
) -> MagicMock:
    """
    Crea un mock que replica exactamente la estructura de ChatCompletion.
    Usar siempre esta función en vez de crear MagicMock directamente.
    """
    response = MagicMock()

    # Metadatos
    response.id = "chatcmpl-test-fixture"
    response.model = model

    # Choice
    choice = MagicMock()
    choice.index = 0
    choice.finish_reason = finish_reason
    choice.message.role = "assistant"
    choice.message.content = content
    response.choices = [choice]

    # Usage
    response.usage.prompt_tokens = prompt_tokens
    response.usage.completion_tokens = completion_tokens
    response.usage.total_tokens = prompt_tokens + completion_tokens

    return response

conftest.py

# tests/conftest.py
"""
Fixtures compartidas para toda la suite de testing.
Disponibles automáticamente en todos los tests.
"""
import os
import sys
import pytest
from unittest.mock import MagicMock

# Asegurar que src/ está en el path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))

from tests.helpers import create_openai_chat_response


# ─── Variables de entorno para tests ──────────────────────────────────

@pytest.fixture(autouse=True)
def set_test_environment(monkeypatch):
    """Configura ambiente de test para todos los tests."""
    monkeypatch.setenv("ENVIRONMENT", "test")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key-for-testing")


@pytest.fixture
def real_api_key():
    """API key real. Skip si no está configurada."""
    key = os.getenv("OPENAI_API_KEY")
    if not key or key.startswith("sk-test"):
        pytest.skip("API key real requerida para integration tests")
    return key


# ─── Factory de responses ─────────────────────────────────────────────

@pytest.fixture
def make_llm_response():
    """
    Factory fixture para crear responses mock.
    Retorna la función create_openai_chat_response.
    """
    return create_openai_chat_response


# ─── Responses predefinidas ───────────────────────────────────────────

@pytest.fixture
def sentiment_positive_response(make_llm_response):
    """Response mock: sentiment positivo."""
    return make_llm_response('{"sentiment": "positive", "confidence": 0.9}')


@pytest.fixture
def sentiment_negative_response(make_llm_response):
    """Response mock: sentiment negativo."""
    return make_llm_response('{"sentiment": "negative", "confidence": 0.85}')


@pytest.fixture
def sentiment_neutral_response(make_llm_response):
    """Response mock: sentiment neutro."""
    return make_llm_response('{"sentiment": "neutral", "confidence": 0.5}')


@pytest.fixture
def malformed_json_response(make_llm_response):
    """Edge case: JSON malformado."""
    return make_llm_response('{"sentiment": "positive"')  # Incompleto


@pytest.fixture
def empty_content_response(make_llm_response):
    """Edge case: content vacío."""
    return make_llm_response("")


# ─── Cliente mock ─────────────────────────────────────────────────────

@pytest.fixture
def mock_openai_client(make_llm_response):
    """Cliente OpenAI mockeado con respuesta por defecto."""
    client = MagicMock()
    client.chat.completions.create.return_value = make_llm_response(
        '{"sentiment": "positive", "confidence": 0.9}'
    )
    return client


# ─── FastAPI test client ───────────────────────────────────────────────

@pytest.fixture(scope="module")
def test_client():
    """Cliente HTTP para tests de FastAPI."""
    from fastapi.testclient import TestClient
    from app.main import app
    with TestClient(app) as client:
        yield client

Paso 3: pytest.ini

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

markers =
    smoke: Tests de humo. Sin costo. Verifican que el sistema arranca.
    contract: Tests de contrato. Sin costo. Verifican estructura del output.
    behavioral: Tests de comportamiento. Sin costo (mocks). Propiedades del output.
    regression: Tests de regresión. Sin costo. Previenen bugs conocidos.
    unit: Tests con mocks. Sin costo. Rápidos.
    integration: Tests con LLM real. Tienen costo. Lentos.

addopts = -v --tb=short
asyncio_mode = auto

Paso 4: Smoke tests

# tests/smoke/test_smoke.py
"""
Smoke tests: verifican que el sistema está vivo.
Son los primeros tests a correr. Si fallan, nada más importa.
"""
import pytest


@pytest.mark.smoke
class TestAppSmoke:
    """Verifica que la app arranca correctamente."""

    def test_config_module_loads(self):
        """El módulo de configuración se carga sin errores."""
        from app.config import settings
        assert settings is not None
        assert hasattr(settings, "model_name")

    def test_parsers_module_loads(self):
        """El módulo de parsers se carga y las funciones son callable."""
        from app.parsers import parse_json_from_llm_output
        assert callable(parse_json_from_llm_output)

    def test_sentiment_module_loads(self):
        """El módulo de sentiment se carga correctamente."""
        from app.sentiment import analyze_sentiment, validate_input, build_sentiment_prompt
        assert callable(analyze_sentiment)
        assert callable(validate_input)
        assert callable(build_sentiment_prompt)


@pytest.mark.smoke
class TestAPISmoke:
    """Verifica que la API FastAPI responde."""

    def test_health_endpoint_returns_200(self, test_client):
        """El endpoint /health retorna 200."""
        response = test_client.get("/health")
        assert response.status_code == 200

    def test_health_response_has_status(self, test_client):
        """La respuesta de /health tiene el campo 'status'."""
        response = test_client.get("/health")
        data = response.json()
        assert "status" in data
        assert data["status"] == "healthy"

    def test_analyze_endpoint_exists(self, test_client):
        """El endpoint /analyze existe (no retorna 404)."""
        # No verificamos el resultado — solo que el endpoint existe
        response = test_client.post("/analyze", json={"text": "test"})
        assert response.status_code != 404

    def test_docs_accessible(self, test_client):
        """La documentación Swagger es accesible."""
        response = test_client.get("/docs")
        assert response.status_code == 200

Paso 5: Contract tests

# tests/unit/contracts/test_contracts.py
"""
Contract tests: verifican que los componentes cumplen sus contratos.
Todos usan mocks — sin llamadas a API real.
"""
import pytest
import json
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment
from app.parsers import parse_json_from_llm_output


# ─── Contratos del parser ─────────────────────────────────────────────

@pytest.mark.contract
@pytest.mark.unit
class TestParserContract:
    """
    parse_json_from_llm_output:
    - Para JSON válido: retorna dict
    - Para JSON en markdown: retorna dict
    - Para texto sin JSON: lanza ValueError
    - Para JSON malformado: lanza JSONDecodeError
    """

    @pytest.mark.parametrize("raw_input,expected", [
        ('{"sentiment": "positive", "confidence": 0.9}',
         {"sentiment": "positive", "confidence": 0.9}),
        ('```json\n{"sentiment": "positive", "confidence": 0.9}\n```',
         {"sentiment": "positive", "confidence": 0.9}),
        ('Result: {"sentiment": "positive", "confidence": 0.9}',
         {"sentiment": "positive", "confidence": 0.9}),
    ])
    def test_returns_dict_for_valid_json(self, raw_input, expected):
        result = parse_json_from_llm_output(raw_input)
        assert result == expected

    def test_raises_value_error_for_no_json(self):
        with pytest.raises(ValueError, match="No JSON found|vacío"):
            parse_json_from_llm_output("No hay JSON en este texto")

    def test_raises_for_empty_input(self):
        with pytest.raises(ValueError):
            parse_json_from_llm_output("")

    def test_raises_json_decode_error_for_malformed(self):
        with pytest.raises((json.JSONDecodeError, ValueError)):
            parse_json_from_llm_output('{"incomplete":')


# ─── Contratos del sentiment analyzer ────────────────────────────────

@pytest.mark.contract
@pytest.mark.unit
class TestSentimentAnalyzerContract:
    """
    analyze_sentiment:
    - Para texto válido: retorna dict con "sentiment" y "confidence"
    - "sentiment" es uno de ["positive", "negative", "neutral"]
    - "confidence" es float en [0.0, 1.0]
    - Para texto vacío: lanza ValueError (sin llamar al LLM)
    """

    @patch("app.sentiment.client.chat.completions.create")
    def test_returns_dict_with_required_keys(self, mock_create, sentiment_positive_response):
        mock_create.return_value = sentiment_positive_response
        result = analyze_sentiment("I love this!")
        assert isinstance(result, dict)
        assert "sentiment" in result
        assert "confidence" in result

    @patch("app.sentiment.client.chat.completions.create")
    def test_sentiment_is_valid_value(self, mock_create, make_llm_response):
        mock_create.return_value = make_llm_response(
            '{"sentiment": "positive", "confidence": 0.9}'
        )
        result = analyze_sentiment("Some text")
        assert result["sentiment"] in {"positive", "negative", "neutral"}

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_is_float_in_range(self, mock_create, make_llm_response):
        mock_create.return_value = make_llm_response(
            '{"sentiment": "negative", "confidence": 0.8}'
        )
        result = analyze_sentiment("Some text")
        assert isinstance(result["confidence"], (int, float))
        assert 0.0 <= result["confidence"] <= 1.0

    def test_empty_text_raises_value_error(self):
        with pytest.raises(ValueError):
            analyze_sentiment("")

    @patch("app.sentiment.client.chat.completions.create")
    def test_empty_text_does_not_call_llm(self, mock_create):
        with pytest.raises(ValueError):
            analyze_sentiment("")
        mock_create.assert_not_called()

    @patch("app.sentiment.client.chat.completions.create")
    def test_llm_called_exactly_once_per_analysis(self, mock_create, sentiment_positive_response):
        mock_create.return_value = sentiment_positive_response
        analyze_sentiment("Some text")
        mock_create.assert_called_once()

Paso 6: Behavioral tests

# tests/unit/behavioral/test_behavioral.py
"""
Behavioral tests: verifican propiedades del comportamiento.
No comparan valores exactos — verifican invariantes.
"""
import pytest
from unittest.mock import patch
from app.sentiment import analyze_sentiment, validate_input


@pytest.mark.behavioral
@pytest.mark.unit
class TestSentimentBehavior:

    @patch("app.sentiment.client.chat.completions.create")
    def test_confidence_is_not_zero_for_valid_input(self, mock_create, sentiment_positive_response):
        """Para input válido, la confidence no debe ser 0."""
        mock_create.return_value = sentiment_positive_response
        result = analyze_sentiment("Texto de prueba")
        assert result["confidence"] > 0.0

    @patch("app.sentiment.client.chat.completions.create")
    def test_same_input_produces_same_output_with_mock(self, mock_create, sentiment_positive_response):
        """Con mocks, el mismo input siempre produce el mismo output."""
        mock_create.return_value = sentiment_positive_response

        result1 = analyze_sentiment("Texto de prueba")
        result2 = analyze_sentiment("Texto de prueba")

        assert result1 == result2


@pytest.mark.behavioral
@pytest.mark.unit
class TestValidateInputBehavior:

    def test_strips_surrounding_whitespace(self):
        """El validador elimina espacios al inicio y al final."""
        result = validate_input("  texto con espacios  ")
        assert not result.startswith(" ")
        assert not result.endswith(" ")

    def test_truncates_very_long_text(self):
        """Texto muy largo se trunca (no lanza excepción)."""
        very_long = "a" * 10000
        result = validate_input(very_long, max_chars=5000)
        assert len(result) <= 5000

    def test_preserves_content_after_validation(self):
        """El contenido del texto se preserva después de validación."""
        text = "This is a specific test phrase."
        result = validate_input(text)
        # El contenido debe estar presente
        assert "specific test phrase" in result

Paso 7: Regression tests

# tests/unit/regression/test_regression.py
"""
Regression tests: previenen que bugs conocidos vuelvan.
Cada test tiene un comentario con la fecha y descripción del bug.
NO BORRAR estos tests.
"""
import pytest
import json
from unittest.mock import patch
from app.parsers import parse_json_from_llm_output
from app.sentiment import analyze_sentiment


@pytest.mark.regression
@pytest.mark.unit
class TestParserRegression:

    def test_handles_json_with_unicode_chars(self):
        """
        Regresión: el parser fallaba con caracteres Unicode (ñ, é, ü, emojis).
        El problema era en la codificación al extraer el JSON del texto.
        """
        raw = '{"texto": "El niño aprendió inglés y matemáticas"}'
        result = parse_json_from_llm_output(raw)
        assert "ñ" in result["texto"]
        assert "é" in result["texto"]

    def test_handles_json_with_emojis(self):
        """
        Regresión: el parser fallaba cuando los valores del JSON contenían emojis.
        """
        raw = '{"message": "Great product! 🎉 Highly recommended! 🚀"}'
        result = parse_json_from_llm_output(raw)
        assert "🎉" in result["message"]

    def test_handles_nested_quotes(self):
        """
        Regresión: el parser fallaba con comillas escapadas dentro del JSON.
        """
        raw = '{"quote": "She said \\"hello\\" to me"}'
        result = parse_json_from_llm_output(raw)
        assert "hello" in result["quote"]


@pytest.mark.regression
@pytest.mark.unit
class TestSentimentRegression:

    @patch("app.sentiment.client.chat.completions.create")
    def test_handles_very_long_text_without_error(self, mock_create, sentiment_positive_response):
        """
        Regresión: para textos muy largos, la función debe truncar el input
        en vez de enviar tokens excesivos (que causaban timeouts).
        """
        mock_create.return_value = sentiment_positive_response
        very_long_text = "palabra " * 5000  # ~40,000 caracteres

        # No debe lanzar excepción — debe truncar y procesar
        result = analyze_sentiment(very_long_text)
        assert result is not None
        assert "sentiment" in result

    @patch("app.sentiment.client.chat.completions.create")
    def test_handles_text_with_single_quotes(self, mock_create, sentiment_positive_response):
        """
        Regresión: textos con comillas simples causaban problemas en la
        construcción del prompt con f-strings.
        """
        mock_create.return_value = sentiment_positive_response
        text_with_quotes = "I'm really happy with this product! It's amazing!"

        result = analyze_sentiment(text_with_quotes)
        assert result is not None
        assert "sentiment" in result

Paso 8: Verificar que todo funciona

Ejecutar la suite completa

# Todos los tests sin integration (no cuesta nada)
pytest -m "not integration" -v

# Output esperado:
# tests/smoke/test_smoke.py::TestAppSmoke::test_config_module_loads PASSED
# tests/smoke/test_smoke.py::TestAppSmoke::test_parsers_module_loads PASSED
# tests/smoke/test_smoke.py::TestAppSmoke::test_sentiment_module_loads PASSED
# tests/smoke/test_smoke.py::TestAPISmoke::test_health_endpoint_returns_200 PASSED
# tests/smoke/test_smoke.py::TestAPISmoke::test_health_response_has_status PASSED
# tests/smoke/test_smoke.py::TestAPISmoke::test_analyze_endpoint_exists PASSED
# tests/unit/contracts/test_contracts.py::TestParserContract::... PASSED
# tests/unit/contracts/test_contracts.py::TestSentimentAnalyzerContract::... PASSED
# tests/unit/behavioral/test_behavioral.py::... PASSED
# tests/unit/regression/test_regression.py::... PASSED
#
# ============= XX passed in X.XXs =============

Verificar cobertura

pytest -m "not integration" --cov=app --cov-report=term-missing

# Output esperado:
# Name                 Stmts   Miss  Cover   Missing
# -------------------------------------------------
# app/__init__.py          0      0   100%
# app/config.py           12      0   100%
# app/parsers.py          22      0   100%
# app/sentiment.py        28      4    86%   45-48 (llamada al LLM real)
# app/main.py             18      2    89%
# -------------------------------------------------
# TOTAL                   80      6    93%

Comandos útiles

# Solo smoke (sanity check rápido)
pytest -m smoke -v

# Solo contratos (después de cambio de prompt)
pytest -m contract -v

# Solo regresiones (antes de deploy)
pytest -m regression -v

# Parar al primer fallo (debug)
pytest -x -v

# Ver tests sin correr (dry run)
pytest --collect-only

# Correr test específico
pytest tests/unit/contracts/test_contracts.py::TestParserContract::test_returns_dict_for_valid_json -v

Comparación: Antes vs Después

AspectoAntes (sin tests)Después (con Test Suite)
Cambio de promptMiedo, testing manualCI detecta regresiones en segundos
Bug en producción"A ver qué pasó..."Regression test añadido para prevenir
RefactoringImposible sin miedoSeguro si los tests pasan
Coverage del código0%>80%
Tiempo para verificar30+ minutos manuales<30 segundos (pytest -m "not integration")

Troubleshooting

Problema: ModuleNotFoundError: No module named 'app' Causa: src/ no está en el Python path. Solución: El conftest.py tiene sys.path.insert(0, os.path.join(..., '..', 'src')). Verifica que el conftest se carga: pytest --co -q debería listar todos los tests sin error.

Problema: pytest.ini no se detecta / markers no funcionan. Causa: pytest.ini no está en la raíz del proyecto. Solución: pytest.ini debe estar en el mismo directorio desde donde corres pytest. Usa pytest --co para ver si la configuración se carga.

Problema: Todos los integration tests se corren aunque uses pytest -m "not integration". Causa: Los tests de integration no están marcados con @pytest.mark.integration. Solución: Asegúrate de que todos los tests en tests/integration/ tienen @pytest.mark.integration o pytestmark = pytest.mark.integration al inicio del archivo.

Problema: La fixture set_test_environment no funciona — los tests llaman a la API real. Causa: El OPENAI_API_KEY real está en el .env y se carga antes del monkeypatch. Solución: En .env.test (diferente de .env) pon OPENAI_API_KEY=sk-test-fake. O usa load_dotenv(".env.test") en el conftest.py.

Problema: Los smoke tests de FastAPI fallan con AppNotStarted. Causa: La fixture test_client tiene scope="module" pero se usa en clases diferentes. Solución: Cambia a scope="function" para mayor flexibilidad, o asegúrate de que todos los tests que usan test_client están en el mismo módulo.


Checklist de completitud

Verifica que completaste el proyecto:

  • Estructura: directorios smoke/, unit/contracts/, unit/behavioral/, unit/regression/, integration/ creados
  • pytest.ini: markers registrados, testpaths configurado
  • helpers.py: función create_openai_chat_response() funcional
  • conftest.py: fixtures make_llm_response, mock_openai_client, test_client, respuestas predefinidas
  • Smoke tests: al menos 5 (2 de módulos + 3 de API)
  • Contract tests: parser (4 tests) + sentiment analyzer (5 tests)
  • Behavioral tests: al menos 3 tests de propiedades invariantes
  • Regression tests: al menos 3 tests de bugs (incluso si son "preventivos")
  • pytest -m "not integration" corre en <30 segundos con todos en verde
  • Coverage: pytest --cov=app --cov-report=term-missing muestra >80%

Extensiones opcionales

Si tienes más tiempo y quieres ir más lejos:

Extension 1: CI con GitHub Actions

# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with: { python-version: "3.11" }
      - run: pip install -r requirements.txt
      - run: pytest -m "not integration" --cov=app --cov-fail-under=80

Extension 2: Pre-commit hook

# .git/hooks/pre-commit (hacer ejecutable con chmod +x)
#!/bin/sh
pytest -m "smoke or contract" --no-header -q

Extension 3: Tests para el endpoint FastAPI

# tests/unit/contracts/test_api_contracts.py
@pytest.mark.contract
class TestAPIContracts:

    @patch("app.main.analyze_sentiment")
    def test_analyze_endpoint_returns_sentiment_schema(self, mock_analyze, test_client):
        mock_analyze.return_value = {"sentiment": "positive", "confidence": 0.9}
        response = test_client.post("/analyze", json={"text": "Great!"})
        assert response.status_code == 200
        data = response.json()
        assert "sentiment" in data
        assert "confidence" in data

    def test_analyze_endpoint_rejects_empty_text(self, test_client):
        response = test_client.post("/analyze", json={"text": ""})
        assert response.status_code == 400

Recursos adicionales

  1. pytest Documentation — Referencia completa de pytest
  2. pytest-cov — Coverage reports
  3. FastAPI Testing — TestClient de FastAPI
  4. GitHub Actions Python — CI para proyectos Python
  5. Test structure best practices — Buenas prácticas de pytest