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 desrc/→ convención estándar, facilita configuración de coverage- Subdirectorios por tipo de test → puedes correr
pytest tests/unit/para solo unit tests conftest.pyen múltiples niveles → fixtures más específicas sobrescriben las generales.env.testseparado → 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ón | Scope recomendado |
|---|---|
| Mock que se modifica en el test | function (default) |
| Mock read-only compartido en clase | class |
| Cliente de API (costoso inicializar) | module |
| Configuración global (.env, constantes) | session |
| Base de datos de tests | session |
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ón | pytest.ini | pyproject.toml | conftest.py | setup.cfg |
|---|---|---|---|---|
| Markers | ✅ | ✅ | ❌ | ✅ |
| testpaths | ✅ | ✅ | ❌ | ✅ |
| Fixtures | ❌ | ❌ | ✅ | ❌ |
| addopts | ✅ | ✅ | ❌ | ✅ |
| asyncio_mode | ✅ | ✅ | ❌ | ✅ |
| Hooks personalizados | ❌ | ❌ | ✅ | ❌ |
| Recomendado para | Proyectos pequeños | Proyectos modernos | Fixtures siempre | Legacy |
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.inicon los markers correctos para tu appconftest.pyen root con fixtures para mock del LLMconftest.pyenunit/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:
- Correr solo tests rápidos y sin costo (por defecto en cada push)
- 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.inicon markers documenta la estrategia de testing y permite correr subsetsconftest.pyen 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 parametrizees 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-asyncioconasyncio_mode = "auto"simplifica testing de código async- Coverage:
--cov-fail-under=80en CI asegura que los tests cubren el código nuevo
Recursos adicionales
- pytest Documentation — Documentación oficial completa
- pytest conftest.py — Guía oficial de conftest y fixture scopes
- pytest markers — Marcado y selección de tests
- pytest parametrize — Parametrización avanzada
- pytest-asyncio — Testing de código async (crítico para LLM calls async)
- pytest-cov — Coverage reports
- pytest-mock —
mockerfixture para mocking más limpio