Módulo 1: Testing Fundamentals para AI

3. Configuración de pytest para Apps AI

Descripción

Configurar pytest correctamente para una app AI no es solo instalar la librería. Necesitas: conftest.py con fixtures compartidas específicas para LLM, markers para separar tests rápidos de los que llaman a la API (y cuestan dinero), parametrize para testear múltiples variaciones de prompts y parsers, y configuración de pytest.ini o pyproject.toml adaptada a proyectos AI.

Esta cápsula es 80% práctica. Al terminarla tendrás todos los archivos de configuración listos para copiar a tu proyecto, con explicación de cada decisión de diseño. No es un tutorial genérico de pytest — es la configuración específica que funciona para proyectos con LLMs.

Al terminar podrás configurar un proyecto de testing desde cero, organizar tu suite con markers que separan los tests por velocidad y costo, crear fixtures reutilizables para mocking del LLM, y correr subsets específicos de tests según el contexto (desarrollo local, PR, CI nightly).


Estructura de directorios completa

Antes de ver los archivos de configuración, aquí está la estructura de directorios que vamos a construir:

mi-app-ai/
│
├── src/
│   └── app/
│       ├── __init__.py
│       ├── llm.py              # Wrapper del cliente LLM
│       ├── parsers.py          # Parsers de output
│       ├── validators.py       # Validators de input/output
│       ├── prompts.py          # Prompt templates
│       └── main.py             # Función principal
│
├── tests/
│   ├── conftest.py             # Fixtures compartidas (root-level)
│   ├── unit/
│   │   ├── conftest.py         # Fixtures específicas de unit tests
│   │   ├── test_parsers.py
│   │   ├── test_validators.py
│   │   └── test_llm_chain.py
│   ├── integration/
│   │   ├── conftest.py         # Fixtures específicas de integration
│   │   └── test_e2e.py
│   └── regression/
│       ├── conftest.py
│       └── test_model_regression.py
│
├── pytest.ini                  # Configuración principal de pytest
├── .env                        # Variables de entorno (no commitear)
├── .env.test                   # Variables de entorno para tests
└── requirements-test.txt       # Dependencias de testing

Por qué esta estructura:

  • tests/ separado de src/ → convención estándar, facilita configuración de coverage
  • Subdirectorios por tipo de test → puedes correr pytest tests/unit/ para solo unit tests
  • conftest.py en múltiples niveles → fixtures más específicas sobrescriben las generales
  • .env.test separado → distintas API keys o config para tests

pytest.ini — Configuración base

# pytest.ini
[pytest]
# Dónde buscar tests
testpaths = tests

# Convenciones de naming
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Markers (cada uno debe estar documentado aquí)
markers =
    unit: Tests con mocks. Sin costo, <1 segundo. Corren en cada commit.
    integration: Tests con LLM real. Tienen costo. Corren en PR.
    regression: Tests de regresión vs golden set. Costosos. Corren semanalmente.
    smoke: Tests de humo básicos. Verifican que el sistema levanta.
    slow: Tests que tardan >5 segundos.
    contract: Tests que verifican contratos estructurales de prompts.

# Opciones por defecto: verbose + traceback corto
addopts = -v --tb=short

# Modo de async (crítico para apps que usan asyncio)
asyncio_mode = auto

Ejecutar subsets:

# Solo tests rápidos (desarrollo local)
pytest -m "unit or smoke"

# Solo unit tests (más rápido aún)
pytest -m unit

# Excluir costosos (CI por defecto)
pytest -m "not integration and not regression"

# Solo los que fallan (debug rápido)
pytest --lf

# Parar al primer fallo
pytest -x

# Ver output de print() en tests (útil para debug)
pytest -s

# Correr un test específico
pytest tests/unit/test_parsers.py::TestParseJson::test_valid_json -v

conftest.py — Root level (fixtures globales)

El conftest.py en la raíz de tests/ está disponible para TODOS los tests de todos los subdirectorios:

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


# ─────────────────────────────────────────────────────────────
# Fixtures para variables de entorno
# ─────────────────────────────────────────────────────────────

@pytest.fixture(autouse=True)
def set_test_environment(monkeypatch):
    """
    Asegura que siempre corremos con configuración de test.
    autouse=True: aplica a TODOS los tests sin necesidad de declararlo.
    """
    monkeypatch.setenv("ENVIRONMENT", "test")
    monkeypatch.setenv("LOG_LEVEL", "WARNING")  # Silencia logs en tests


@pytest.fixture
def api_key():
    """
    Lee la API key del entorno. Hace skip si no está definida.
    Usar en tests de integration que necesitan API key real.
    """
    key = os.getenv("OPENAI_API_KEY")
    if not key:
        pytest.skip("OPENAI_API_KEY no definida — test de integration omitido")
    return key


# ─────────────────────────────────────────────────────────────
# Factory de responses mock (reutilizable en toda la suite)
# ─────────────────────────────────────────────────────────────

def create_openai_response(content: str, tokens_used: int = 150) -> MagicMock:
    """
    Factory que crea un objeto mock con la estructura exacta
    de una respuesta real de OpenAI chat completions.
    
    Importante: refleja la estructura REAL de la API para que
    el código que accede a resp.choices[0].message.content funcione
    igual con mock que con la API real.
    """
    response = MagicMock()

    # Estructura de choices (lista con al menos un elemento)
    choice = MagicMock()
    choice.message.content = content
    choice.message.role = "assistant"
    choice.finish_reason = "stop"
    response.choices = [choice]

    # Estructura de usage (tokens)
    response.usage.prompt_tokens = tokens_used // 3
    response.usage.completion_tokens = tokens_used * 2 // 3
    response.usage.total_tokens = tokens_used

    # Metadatos
    response.model = "gpt-4o-mini"
    response.id = "chatcmpl-test-mock-id"

    return response


@pytest.fixture
def mock_openai_response():
    """
    Fixture que retorna la factory de responses.
    Uso: mock_openai_response('{"key": "value"}')
    
    Retorna la función factory, no un response fijo.
    Esto permite crear múltiples responses con diferentes contenidos.
    """
    return create_openai_response


@pytest.fixture
def mock_openai_client(mock_openai_response):
    """
    Cliente OpenAI completamente mockeado.
    
    Uso en tests:
        def test_algo(mock_openai_client):
            mock_openai_client.chat.completions.create.return_value = (
                mock_openai_response('{"result": "ok"}')
            )
            ...
    """
    client = MagicMock()
    # Respuesta por defecto (puede sobreescribirse en cada test)
    client.chat.completions.create.return_value = mock_openai_response(
        '{"default": "mock response"}'
    )
    return client


# ─────────────────────────────────────────────────────────────
# Fixtures de datos de prueba
# ─────────────────────────────────────────────────────────────

@pytest.fixture
def sample_texts():
    """Textos de prueba para tests de análisis de texto."""
    return {
        "positive": "I absolutely love this product! Best purchase ever.",
        "negative": "Terrible experience. Never buying again.",
        "neutral": "The package arrived on Tuesday as expected.",
        "empty": "",
        "long": "a" * 5000,
        "with_json": 'The result is {"key": "value"} embedded.',
        "multilingual": "Esta es una oración en español.",
    }


@pytest.fixture
def sample_json_responses():
    """Respuestas JSON típicas de LLMs para tests de parsers."""
    return {
        "clean": '{"sentiment": "positive", "confidence": 0.9}',
        "in_markdown": '```json\n{"sentiment": "positive", "confidence": 0.9}\n```',
        "with_prefix": 'Here is the result: {"sentiment": "positive", "confidence": 0.9}',
        "with_suffix": '{"sentiment": "positive", "confidence": 0.9}\nNote: confidence is high.',
        "malformed": '{"sentiment": "positive", "confidence": ',  # JSON incompleto
        "empty": '',
        "no_json": 'The sentiment is positive with high confidence.',
    }

conftest.py — Unit tests level

Fixtures más específicas para unit tests (disponibles solo dentro de tests/unit/):

# tests/unit/conftest.py
"""
Fixtures específicas para unit tests.
Disponibles solo en tests/unit/ y subdirectorios.
"""
import pytest
from unittest.mock import patch, MagicMock


@pytest.fixture
def patched_llm_client():
    """
    Parchea el cliente LLM directamente en el módulo app.
    Más conveniente que @patch en cada test cuando muchos tests
    necesitan el mismo patch.
    """
    with patch("app.llm.client") as mock_client:
        yield mock_client


@pytest.fixture
def sentiment_mock_responses():
    """
    Respuestas mock predefinidas para tests de análisis de sentimiento.
    Usa una fixture factory para flexibilidad.
    """
    def _make_response(sentiment: str, confidence: float) -> MagicMock:
        from tests.conftest import create_openai_response
        return create_openai_response(
            f'{{"sentiment": "{sentiment}", "confidence": {confidence}}}'
        )
    return _make_response


@pytest.fixture
def summary_mock_responses():
    """Respuestas mock para tests de summarización."""
    def _make_response(points: list) -> MagicMock:
        import json
        from tests.conftest import create_openai_response
        return create_openai_response(json.dumps({"points": points}))
    return _make_response

conftest.py — Integration tests level

Fixtures para tests de integración (requieren API key real):

# tests/integration/conftest.py
"""
Fixtures para integration tests.
Requieren API key real. Tienen costo por ejecución.
"""
import os
import pytest
from openai import OpenAI


@pytest.fixture(scope="module")
def real_openai_client():
    """
    Cliente OpenAI real para integration tests.
    scope="module": un solo cliente para todos los tests del módulo.
    Evita overhead de crear múltiples clientes.
    """
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        pytest.skip("OPENAI_API_KEY requerida para integration tests")
    return OpenAI(api_key=api_key)


@pytest.fixture(scope="module")
def budget_tracker():
    """
    Tracker simple de costo de tokens en tests de integración.
    Ayuda a monitorear que los tests no excedan budget.
    """
    class BudgetTracker:
        def __init__(self, max_tokens: int = 10_000):
            self.total_tokens = 0
            self.max_tokens = max_tokens
            self.calls = 0

        def track(self, response) -> None:
            """Registra el uso de tokens de una respuesta."""
            if hasattr(response, 'usage') and response.usage:
                self.total_tokens += response.usage.total_tokens
                self.calls += 1

        def check_budget(self) -> None:
            """Falla el test si se excedió el budget."""
            if self.total_tokens > self.max_tokens:
                pytest.fail(
                    f"Budget excedido: {self.total_tokens} tokens "
                    f"(max: {self.max_tokens}). "
                    f"Calls: {self.calls}"
                )

        def __repr__(self) -> str:
            return f"BudgetTracker(tokens={self.total_tokens}, calls={self.calls})"

    return BudgetTracker()

Markers en acción

Los markers son la herramienta más importante para organizar tu suite:

# tests/unit/test_parsers.py
import pytest
from app.parsers import parse_json_from_llm_output


# Marcar tests individuales
@pytest.mark.unit
def test_parse_valid_json():
    raw = '{"key": "value"}'
    result = parse_json_from_llm_output(raw)
    assert result == {"key": "value"}


# Marcar clase completa (aplica a todos los métodos)
@pytest.mark.unit
class TestParseJsonFromLLMOutput:

    def test_pure_json(self):
        result = parse_json_from_llm_output('{"x": 1}')
        assert result == {"x": 1}

    def test_json_in_markdown_block(self):
        raw = '```json\n{"x": 1}\n```'
        result = parse_json_from_llm_output(raw)
        assert result == {"x": 1}

    def test_json_with_surrounding_text(self):
        raw = "Here is the result: {\"x\": 1} end."
        result = parse_json_from_llm_output(raw)
        assert result == {"x": 1}

    def test_no_json_raises_value_error(self):
        with pytest.raises(ValueError, match="No JSON found"):
            parse_json_from_llm_output("No hay JSON aquí")

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


# Combinar múltiples markers
@pytest.mark.integration
@pytest.mark.slow
def test_full_pipeline_with_real_llm():
    """Este test llama a la API real — lento y con costo."""
    ...


# Marker condicional — skip si falta configuración
@pytest.mark.skipif(
    not os.getenv("OPENAI_API_KEY"),
    reason="OPENAI_API_KEY no definida"
)
def test_requires_api_key():
    ...

parametrize — Testear múltiples variaciones

parametrize es especialmente útil para testear parsers con múltiples formatos de input:

Caso de uso básico: parser con múltiples formatos

# tests/unit/test_parsers.py
import pytest
import json
from app.parsers import parse_json_from_llm_output

# Tabla de casos: (input, expected_output)
JSON_PARSE_CASES = [
    # Descripción, input raw, expected result
    ("pure JSON", '{"key": "value"}', {"key": "value"}),
    ("JSON in markdown", '```json\n{"key": "value"}\n```', {"key": "value"}),
    ("JSON in markdown no lang", '```\n{"key": "value"}\n```', {"key": "value"}),
    ("JSON with prefix text", 'Result: {"key": "value"}', {"key": "value"}),
    ("JSON with suffix text", '{"key": "value"}\nNotes: etc.', {"key": "value"}),
    ("JSON with leading whitespace", '  \n{"key": "value"}', {"key": "value"}),
    ("nested JSON", '{"outer": {"inner": 1}}', {"outer": {"inner": 1}}),
    ("JSON with list", '{"items": [1, 2, 3]}', {"items": [1, 2, 3]}),
]

@pytest.mark.unit
@pytest.mark.parametrize("description,raw_input,expected", JSON_PARSE_CASES)
def test_parse_json_variations(description, raw_input, expected):
    """
    Testa el parser con múltiples formatos de output del LLM.
    Cada fila de JSON_PARSE_CASES es un test separado.
    Los names serán: test_parse_json_variations[pure JSON-...]
    """
    result = parse_json_from_llm_output(raw_input)
    assert result == expected, f"Failed for case: {description}"


# Para casos de error
ERROR_CASES = [
    ("empty string", "", ValueError),
    ("no JSON", "No JSON here at all", ValueError),
    ("malformed JSON", '{"key":}', json.JSONDecodeError),
    ("None input", None, (TypeError, ValueError)),  # Múltiples excepciones válidas
]

@pytest.mark.unit
@pytest.mark.parametrize("description,bad_input,expected_exception", ERROR_CASES)
def test_parse_json_errors(description, bad_input, expected_exception):
    with pytest.raises(expected_exception):
        parse_json_from_llm_output(bad_input)

Caso de uso avanzado: parametrize con fixtures

# Parametrize que genera respuestas mock dinámicas
@pytest.mark.unit
@pytest.mark.parametrize("sentiment,confidence", [
    ("positive", 0.9),
    ("negative", 0.1),
    ("neutral", 0.5),
    ("positive", 1.0),  # Caso límite
    ("negative", 0.0),  # Caso límite
])
@patch("app.sentiment.client.chat.completions.create")
def test_analyze_sentiment_valid_outputs(mock_create, sentiment, confidence, mock_openai_response):
    """
    Para cada combinación sentiment/confidence, verifica que
    la función retorna la estructura correcta.
    """
    mock_create.return_value = mock_openai_response(
        f'{{"sentiment": "{sentiment}", "confidence": {confidence}}}'
    )
    result = analyze_sentiment("Some text")
    assert result["sentiment"] == sentiment
    assert result["confidence"] == confidence

Fixture scopes — Cuándo usar cada uno

El scope determina cuánto tiempo "vive" una fixture:

# Comparación de scopes con ejemplos de uso

# SCOPE: function (default)
# La fixture se crea y destruye por cada test
@pytest.fixture  # scope="function" por defecto
def fresh_mock_client():
    """Cada test recibe un mock nuevo y limpio."""
    return MagicMock()
# Usar cuando: el test modifica el estado del mock y
# no quieres que afecte otros tests

# SCOPE: class
# Una instancia por clase de test
@pytest.fixture(scope="class")
def class_mock_client():
    """Compartido dentro de una clase TestX."""
    return MagicMock()
# Usar cuando: múltiples tests en la misma clase
# usan el mock sin modificarlo

# SCOPE: module
# Una instancia por archivo de test
@pytest.fixture(scope="module")
def module_real_client():
    """Un cliente real para todo el módulo de integration tests."""
    from openai import OpenAI
    return OpenAI()
# Usar cuando: el objeto es costoso de crear (conexiones reales)
# y los tests del módulo lo comparten sin modificarlo

# SCOPE: session
# Una instancia para toda la sesión de tests
@pytest.fixture(scope="session")
def session_config():
    """Configuración global cargada una vez para toda la suite."""
    from dotenv import load_dotenv
    load_dotenv(".env.test")
    return {
        "model": os.getenv("TEST_MODEL", "gpt-4o-mini"),
        "max_tokens": int(os.getenv("TEST_MAX_TOKENS", "500")),
    }
# Usar cuando: cargar configuración costosa una sola vez

Regla práctica:

SituaciónScope recomendado
Mock que se modifica en el testfunction (default)
Mock read-only compartido en claseclass
Cliente de API (costoso inicializar)module
Configuración global (.env, constantes)session
Base de datos de testssession

pyproject.toml como alternativa a pytest.ini

Si tu proyecto usa pyproject.toml, puedes poner la configuración ahí:

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
markers = [
    "unit: Tests con mocks. Sin costo, rápidos.",
    "integration: Tests con LLM real. Tienen costo.",
    "regression: Tests de regresión. Costosos, semanales.",
    "smoke: Tests de humo básicos.",
    "slow: Tests lentos (>5s).",
    "contract: Tests de contratos de prompts.",
]

Async testing con pytest-asyncio

Si tu app usa async/await para llamadas al LLM (recomendado para producción), necesitas pytest-asyncio:

# Para AsyncOpenAI client
import asyncio
import pytest
from unittest.mock import AsyncMock, patch
from app.async_sentiment import async_analyze_sentiment


@pytest.mark.asyncio  # Solo necesario si asyncio_mode != "auto" en pytest.ini
async def test_async_sentiment_analysis():
    """Test de función async."""
    with patch("app.async_sentiment.async_client.chat.completions.create") as mock_create:
        # AsyncMock para simular respuesta async
        mock_response = MagicMock()
        mock_response.choices[0].message.content = '{"sentiment": "positive", "confidence": 0.9}'
        mock_create.return_value = mock_response
        # Si la función es async, el mock debe serlo también:
        mock_create = AsyncMock(return_value=mock_response)

        result = await async_analyze_sentiment("Great product!")

        assert result["sentiment"] == "positive"
        assert mock_create.await_count == 1


# Fixture async
@pytest.fixture
async def async_app_client():
    """Fixture async para tests de FastAPI con HTTPX."""
    from httpx import AsyncClient
    from app.main import app
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client


async def test_api_endpoint(async_app_client):
    response = await async_app_client.post(
        "/analyze",
        json={"text": "Great product!"}
    )
    assert response.status_code == 200

Coverage — Medir qué está testeado

# Instalar
pip install pytest-cov

# Correr con coverage
pytest --cov=app --cov-report=term-missing

# Output típico:
# Name                    Stmts   Miss  Cover   Missing
# -------------------------------------------------------
# app/__init__.py             0      0   100%
# app/llm.py                 25      5    80%   45-50, 67
# app/parsers.py             18      0   100%
# app/validators.py          22      2    91%   38, 42
# -------------------------------------------------------
# TOTAL                      65      7    89%

# Generar reporte HTML (más detallado)
pytest --cov=app --cov-report=html
# Abre htmlcov/index.html en el navegador

# Fallar si coverage < threshold
pytest --cov=app --cov-fail-under=80

Configurar coverage en .coveragerc:

# .coveragerc
[run]
source = app
omit =
    app/__init__.py
    app/config.py  # Si es solo configuración
    tests/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise NotImplementedError
    if TYPE_CHECKING:

Comparación de opciones de configuración

Opciónpytest.inipyproject.tomlconftest.pysetup.cfg
Markers
testpaths
Fixtures
addopts
asyncio_mode
Hooks personalizados
Recomendado paraProyectos pequeñosProyectos modernosFixtures siempreLegacy

Recomendación: Usa pyproject.toml para configuración de pytest si tu proyecto ya lo tiene. Si el proyecto es solo para testing, pytest.ini es más simple. Las fixtures siempre van en conftest.py.


Conexión con el proyecto del módulo

Esta configuración es exactamente la que usarás en el Proyecto 07: Test Suite Setup. Al llegar a ese proyecto tendrás:

  • pytest.ini con los markers correctos para tu app
  • conftest.py en root con fixtures para mock del LLM
  • conftest.py en unit/ con fixtures específicas de tus componentes
  • Estructura de directorios unit/ + integration/ lista

El proyecto consiste en aplicar esta estructura a una app LLM real, no en aprenderla de cero.


Troubleshooting

Problema: pytest: error: unrecognized arguments: -m unit Causa: Los markers no están registrados en pytest.ini. Solución: Añade el marker a la sección markers = en pytest.ini. Si lo registras, pytest no da el warning PytestUnknownMarkWarning.

Problema: fixture 'mock_openai_response' not found Causa: El conftest.py con la fixture no está en el directorio correcto o el archivo tiene error de sintaxis. Solución: El conftest.py debe estar en el directorio tests/ (o un subdirectorio padre). Verifica que el archivo no tenga errores: python -c "import tests.conftest".

Problema: Tests de integración se ejecutan en CI y fallan por falta de API key. Causa: La API key no está configurada en el entorno de CI. Solución 1: pytest -m "not integration" en CI por defecto. Solución 2: En GitHub Actions, añadir OPENAI_API_KEY como secret y usarlo con -m integration solo en workflows de PR.

Problema: ModuleNotFoundError: No module named 'app' Causa: El path de src/app no está en PYTHONPATH. Solución A: pip install -e . con un setup.py o pyproject.toml con [tool.setuptools.packages]. Solución B: Añadir al conftest.py de root:

import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))

Solución C (recomendada): Usar pytest-pythonpath o configurar en pyproject.toml:

[tool.setuptools.packages.find]
where = ["src"]

Problema: fixture 'event_loop' not found con pytest-asyncio. Causa: Versión incompatible de pytest-asyncio. Solución: Añadir asyncio_mode = "auto" en pytest.ini (pytest-asyncio >= 0.21).

Problema: Coverage muestra 0% para un módulo que sí tiene tests. Causa: El módulo no está en el source de coverage. Solución: pytest --cov=app donde app es el nombre del paquete (directorio con __init__.py).


Ejercicios

Ejercicio 1: Crear pytest.ini

Crea un pytest.ini para un proyecto con 3 tipos de tests: unit (mocks), integration (LLM real), y smoke (básicos). Incluye al menos 4 markers documentados y el comando para correr solo tests rápidos.

Ver solución
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

markers =
    unit: Tests con mocks. Rápidos, sin costo. Corren en cada commit.
    integration: Tests con LLM real. Tienen costo. Corren en PR.
    smoke: Tests básicos de humo. Verifican que el sistema levanta.
    contract: Tests de contratos estructurales de prompts.

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

# Comando para tests rápidos:
# pytest -m "unit or smoke"

Nota: Documentar el propósito de cada marker en markers = es importante. pytest da PytestUnknownMarkWarning si el marker no está registrado.


Ejercicio 2: conftest.py con fixture para API key

Crea una fixture api_key que lea OPENAI_API_KEY del entorno y haga skip si no está definida. Añade también una fixture model_name que retorne el modelo a usar en tests (con default configurable por env).

Ver solución
# tests/conftest.py
import os
import pytest


@pytest.fixture
def api_key():
    """API key para tests de integración. Skip si no está definida."""
    key = os.getenv("OPENAI_API_KEY")
    if not key:
        pytest.skip("OPENAI_API_KEY no definida — este test requiere API real")
    return key


@pytest.fixture
def model_name():
    """
    Nombre del modelo a usar en tests.
    Configurable por variable de entorno para flexibilidad en CI.
    Default: gpt-4o-mini (más barato para tests)
    """
    return os.getenv("TEST_MODEL", "gpt-4o-mini")


# Uso en un integration test:
# def test_with_real_api(api_key, model_name):
#     from openai import OpenAI
#     client = OpenAI(api_key=api_key)
#     response = client.chat.completions.create(
#         model=model_name,
#         messages=[{"role": "user", "content": "Say hi"}]
#     )
#     assert response.choices[0].message.content

Ejercicio 3: parametrize para parser

Tienes esta función:

def extract_number_from_response(raw: str) -> float:
    """Extrae el primer número flotante de una string."""
    import re
    match = re.search(r'\d+\.?\d*', raw)
    if not match:
        raise ValueError(f"No number found in: {raw!r}")
    return float(match.group())

Escribe un test parametrizado que cubra: número entero, número flotante, número en texto, sin número (debe fallar).

Ver solución
import pytest
from app.parsers import extract_number_from_response


@pytest.mark.unit
@pytest.mark.parametrize("raw_input,expected", [
    ("0.95", 0.95),
    ("42", 42.0),
    ("The confidence is 0.87 for this response.", 0.87),
    ("Score: 100 out of 100", 100.0),
    ("3.14159", 3.14159),
])
def test_extract_number_valid(raw_input, expected):
    result = extract_number_from_response(raw_input)
    assert result == expected


@pytest.mark.unit
@pytest.mark.parametrize("bad_input", [
    "",
    "No numbers here at all.",
    "abc def ghi",
])
def test_extract_number_raises_on_no_number(bad_input):
    with pytest.raises(ValueError, match="No number found"):
        extract_number_from_response(bad_input)

Tip: @pytest.mark.parametrize genera un test separado por cada fila. El nombre del test incluye los parámetros, lo que facilita identificar qué caso falló.


Ejercicio 4: Scope de fixture

Tienes una fixture db_connection que tarda 2 segundos en inicializarse. Tienes 50 tests que la usan. ¿Cuánto tiempo ahorras usando scope="module" vs scope="function" si tienes todos los tests en un mismo archivo?

Ver solución
# scope="function" (default):
# La fixture se crea y destruye 50 veces
# Tiempo: 50 × 2s = 100 segundos

# scope="module":
# La fixture se crea 1 vez para todo el archivo
# Tiempo: 1 × 2s = 2 segundos

# Ahorro: 98 segundos (49x más rápido)

# Si tuvieras 5 archivos de test con 50 tests cada uno:
# scope="function": 250 × 2s = 500 segundos
# scope="module": 5 × 2s = 10 segundos
# scope="session": 1 × 2s = 2 segundos

# IMPORTANTE: Solo usar scopes más amplios si la fixture
# es SEGURA compartirla (no tiene estado que se modifica entre tests).
# Una conexión de base de datos en modo read-only es segura.
# Un mock que se configura diferente en cada test NO es seguro con scope="module".

Regla: Usa el scope más amplio posible, siempre que la fixture sea segura para compartir (sin estado mutable entre tests).


Ejercicio 5: Configurar CI para runs sin costo

Escribe el comando de pytest que usarías en GitHub Actions para:

  1. Correr solo tests rápidos y sin costo (por defecto en cada push)
  2. Incluir integration tests solo cuando la API key está disponible
Ver solución
# .github/workflows/tests.yml

name: Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with: { python-version: "3.11" }
      - run: pip install -r requirements-test.txt
      
      # Siempre: solo unit + smoke (sin costo)
      - name: Run fast tests
        run: pytest -m "unit or smoke" --cov=app --cov-fail-under=80

  integration-tests:
    runs-on: ubuntu-latest
    # Solo en PRs (no en cada push a feature branches)
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with: { python-version: "3.11" }
      - run: pip install -r requirements-test.txt
      
      # Integration tests: solo si la secret está disponible
      - name: Run integration tests
        if: secrets.OPENAI_API_KEY != ''
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: pytest -m integration -v

Nota: if: secrets.OPENAI_API_KEY != '' evita que el paso falle cuando el secret no está disponible (por ejemplo, en forks o PRs de contribuidores externos).


Resumen

  • pytest.ini con markers documenta la estrategia de testing y permite correr subsets
  • conftest.py en múltiples niveles organiza fixtures de lo general a lo específico
  • La factory create_openai_response() debe replicar la estructura real de la API para que el código funcione igual con mock que con API real
  • parametrize es esencial para testear parsers con los múltiples formatos que el LLM puede producir
  • Scope de fixtures: usa el más amplio posible para fixtures que no tienen estado mutable
  • pytest-asyncio con asyncio_mode = "auto" simplifica testing de código async
  • Coverage: --cov-fail-under=80 en CI asegura que los tests cubren el código nuevo

Recursos adicionales

  1. pytest Documentation — Documentación oficial completa
  2. pytest conftest.py — Guía oficial de conftest y fixture scopes
  3. pytest markers — Marcado y selección de tests
  4. pytest parametrize — Parametrización avanzada
  5. pytest-asyncio — Testing de código async (crítico para LLM calls async)
  6. pytest-cov — Coverage reports
  7. pytest-mockmocker fixture para mocking más limpio