Módulo 1: Testing Fundamentals para AI

5. Fixtures para Apps LLM

Descripción

Los fixtures para testing de apps AI no son simples datos de prueba — son representaciones exactas del comportamiento de una API externa costosa. Un fixture mal diseñado puede hacer que todos tus tests pasen pero que el código falle en producción porque el mock no refleja la estructura real de la API. Esta cápsula cubre cómo diseñar fixtures robustos: estáticos para casos típicos, factories para variaciones, y fixtures con scope apropiado para optimizar velocidad.

El objetivo de esta cápsula es darte un conjunto completo de fixtures reutilizables que puedes copiar directamente a tu conftest.py. Cada fixture tiene su razonamiento de diseño explicado — no solo el código, sino por qué está construida así.

Al terminar tendrás fixtures para: respuestas de OpenAI con estructura completa, mocks de clientes LLM, factories para variaciones de output (incluyendo edge cases), y fixtures para async. Todo listo para usar en los módulos siguientes.


Fundamento: ¿Por qué los fixtures para LLM son diferentes?

El problema de los mocks simples

# ❌ MOCK PELIGROSO: demasiado simple
@pytest.fixture
def mock_llm_response():
    return "This is the LLM response"  # Un string simple

# El test pasa:
def test_process_response(mock_llm_response):
    result = process(mock_llm_response)
    assert "response" in result  # ✅ Pasa

# Pero la app REAL hace esto:
def process(api_response):
    content = api_response.choices[0].message.content  # AttributeError!
    # El mock no tiene .choices, el string no tiene .choices[0]

El mock simple no refleja la estructura real de la API. Cuando el código hace response.choices[0].message.content, el string no tiene ese atributo.

La estructura real de la OpenAI API

# Estructura real de client.chat.completions.create()
# (Documentación: platform.openai.com/docs/api-reference/chat)

response = ChatCompletion(
    id="chatcmpl-abc123",
    object="chat.completion",
    model="gpt-4o-mini",
    choices=[
        Choice(
            index=0,
            message=ChatCompletionMessage(
                role="assistant",
                content='{"sentiment": "positive", "confidence": 0.9}'
            ),
            finish_reason="stop",
            logprobs=None
        )
    ],
    usage=CompletionUsage(
        prompt_tokens=100,
        completion_tokens=50,
        total_tokens=150
    ),
    created=1699000000,
    system_fingerprint="fp_abc"
)

# Tu código accede así:
content = response.choices[0].message.content
tokens = response.usage.total_tokens
finish = response.choices[0].finish_reason

El mock DEBE replicar exactamente esta estructura de acceso.


Fixture estática: la base

Factory function (sin pytest)

# tests/helpers.py
"""
Helpers de testing reutilizables.
No son fixtures pytest — son funciones que crean objetos mock.
Se importan en conftest.py para crear las fixtures.
"""
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.
    
    Args:
        content: El contenido del mensaje del asistente (lo que hace response.choices[0].message.content)
        prompt_tokens: Tokens del prompt (para cost tracking)
        completion_tokens: Tokens de la respuesta
        finish_reason: "stop" (normal) | "length" (truncado) | "content_filter" (filtrado)
        model: Nombre del modelo
    
    Returns:
        MagicMock que se comporta igual que un ChatCompletion real
    """
    response = MagicMock()
    
    # Metadatos
    response.id = "chatcmpl-test-fixture"
    response.object = "chat.completion"
    response.model = model
    response.created = 1699000000
    
    # Choice (el resultado principal)
    choice = MagicMock()
    choice.index = 0
    choice.finish_reason = finish_reason
    choice.message.role = "assistant"
    choice.message.content = content
    choice.logprobs = None
    response.choices = [choice]
    
    # Usage (tokens — importante para cost tracking en módulo 5)
    response.usage.prompt_tokens = prompt_tokens
    response.usage.completion_tokens = completion_tokens
    response.usage.total_tokens = prompt_tokens + completion_tokens
    
    return response

Fixtures en conftest.py

# tests/conftest.py
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from tests.helpers import create_openai_chat_response


# ─────────────────────────────────────────────────────────────────────
# Fixture base: factory de respuestas
# ─────────────────────────────────────────────────────────────────────

@pytest.fixture
def make_llm_response():
    """
    Factory fixture: retorna la función create_openai_chat_response.
    
    Uso en tests:
        def test_algo(make_llm_response):
            response = make_llm_response('{"sentiment": "positive", "confidence": 0.9}')
            # Configura el mock del cliente con este response
    
    Por qué factory en vez de fixture directa:
    - Diferentes tests necesitan diferentes contenidos
    - Permite customizar tokens, finish_reason, etc.
    - Evita repetir la creación del MagicMock en cada test
    """
    return create_openai_chat_response


# ─────────────────────────────────────────────────────────────────────
# Fixtures predefinidas para casos comunes
# ─────────────────────────────────────────────────────────────────────

@pytest.fixture
def llm_response_valid_json():
    """Respuesta típica: JSON limpio y válido."""
    return create_openai_chat_response(
        content='{"summary": "Resumen de prueba", "confidence": 0.9, "points": ["a", "b", "c"]}',
        prompt_tokens=100,
        completion_tokens=60,
    )


@pytest.fixture
def llm_response_json_in_markdown():
    """Respuesta con JSON dentro de bloque markdown (formato común)."""
    return create_openai_chat_response(
        content='```json\n{"summary": "Resumen de prueba", "confidence": 0.9}\n```'
    )


@pytest.fixture
def llm_response_empty_content():
    """Edge case: el LLM retorna string vacío."""
    return create_openai_chat_response(content="")


@pytest.fixture
def llm_response_malformed_json():
    """Edge case: JSON incompleto/malformado."""
    return create_openai_chat_response(
        content='{"summary": "Resumen incompleto"',  # Falta cierre
    )


@pytest.fixture
def llm_response_truncated():
    """Edge case: respuesta truncada (finish_reason="length")."""
    return create_openai_chat_response(
        content='{"summary": "Este resumen fue truncado por',  # Truncado
        finish_reason="length",  # Indica que se truncó por límite de tokens
    )


@pytest.fixture
def llm_response_with_extra_text():
    """Edge case: LLM añade texto antes/después del JSON."""
    return create_openai_chat_response(
        content='Aquí está el análisis:\n{"sentiment": "positive", "confidence": 0.8}\nEspero que sea útil.'
    )

Fixtures para el cliente LLM

# tests/conftest.py (continuación)

@pytest.fixture
def mock_llm_client():
    """
    Cliente OpenAI completamente mockeado.
    
    Uso básico:
        def test_algo(mock_llm_client, make_llm_response):
            mock_llm_client.chat.completions.create.return_value = make_llm_response("contenido")
            # ... test code ...
    
    Uso con @patch (más común para tests unitarios aislados):
        @patch("app.sentiment.client")
        def test_algo(mock_client, make_llm_response):
            mock_client.chat.completions.create.return_value = make_llm_response("contenido")
    
    Esta fixture es útil cuando necesitas inyectar el cliente
    como dependencia en la función (dependency injection).
    """
    client = MagicMock()
    # Respuesta por defecto (sobreescribir en cada test)
    client.chat.completions.create.return_value = create_openai_chat_response(
        '{"default": "mock response — override this in your test"}'
    )
    return client


@pytest.fixture
def mock_llm_client_with_side_effect(make_llm_response):
    """
    Cliente mock que retorna respuestas diferentes en llamadas sucesivas.
    Útil para testear chains o pipelines con múltiples llamadas al LLM.
    """
    client = MagicMock()
    client.chat.completions.create.side_effect = [
        make_llm_response('{"step": "extraction", "data": "extracted"}'),
        make_llm_response('{"step": "analysis", "result": "positive"}'),
        make_llm_response('{"step": "summary", "text": "Final summary"}'),
    ]
    return client

Fixture factories avanzadas

Factory con params para parametrize

# Puedes usar params en fixtures para ejecutar un test múltiples veces

SENTIMENT_TEST_CASES = [
    pytest.param("positive", 0.9, id="positive_high_confidence"),
    pytest.param("negative", 0.8, id="negative_high_confidence"),
    pytest.param("neutral", 0.5, id="neutral_medium_confidence"),
    pytest.param("positive", 0.1, id="positive_low_confidence"),
]

@pytest.fixture(params=SENTIMENT_TEST_CASES)
def sentiment_response(request, make_llm_response):
    """
    Fixture parametrizada: genera un response para cada combinación
    de sentiment y confidence en SENTIMENT_TEST_CASES.
    
    El test que use esta fixture se ejecuta 4 veces automáticamente.
    """
    sentiment, confidence = request.param
    return make_llm_response(
        f'{{"sentiment": "{sentiment}", "confidence": {confidence}}}'
    )


# Uso:
def test_parser_handles_all_sentiments(sentiment_response):
    """Este test se ejecuta 4 veces — una por cada param."""
    content = sentiment_response.choices[0].message.content
    result = parse_sentiment_response(content)
    assert result["sentiment"] in ["positive", "negative", "neutral"]
    assert 0.0 <= result["confidence"] <= 1.0

Factory con casos de error

PARSER_ERROR_CASES = [
    pytest.param("", id="empty_content"),
    pytest.param('{"summary": "incomplete', id="malformed_json"),
    pytest.param("No JSON here", id="no_json"),
    pytest.param('{"wrong_key": "value"}', id="missing_required_key"),
    pytest.param("null", id="null_response"),
]

@pytest.fixture(params=PARSER_ERROR_CASES)
def problematic_llm_content(request):
    """
    Fixture que genera contenidos problemáticos para testear el manejo de errores.
    """
    return request.param


# Uso en test de parser robusto:
@pytest.mark.unit
def test_parser_handles_problematic_content_gracefully(problematic_llm_content, make_llm_response):
    """
    El parser no debe crashear con contenido problemático.
    Debe retornar None o lanzar una excepción controlada.
    """
    response = make_llm_response(problematic_llm_content)
    content = response.choices[0].message.content

    try:
        result = parse_llm_response(content)
        # Si no lanza excepción, verificar que el resultado está vacío o es None
        assert result is None or result == {}
    except (ValueError, KeyError, json.JSONDecodeError):
        # Excepción controlada — aceptable
        pass
    except AttributeError as e:
        pytest.fail(f"Parser no debería lanzar AttributeError: {e}")
    except Exception as e:
        pytest.fail(f"Parser lanzó excepción inesperada: {type(e).__name__}: {e}")

Fixtures para apps async

Si tu app usa async/await para llamadas al LLM (recomendado para producción con FastAPI):

# tests/conftest.py (para async apps)
import pytest
from unittest.mock import AsyncMock, MagicMock


def create_async_openai_response(content: str, tokens: int = 150) -> MagicMock:
    """
    Crea un mock para AsyncOpenAI client.
    La diferencia: create() debe ser awaitable.
    """
    response = MagicMock()
    response.choices[0].message.content = content
    response.usage.total_tokens = tokens
    return response


@pytest.fixture
def mock_async_llm_client():
    """
    Mock de AsyncOpenAI para tests de código async.
    Usa AsyncMock para que 'await client.chat.completions.create(...)' funcione.
    """
    client = MagicMock()
    # AsyncMock hace que 'await create(...)' funcione en tests
    client.chat.completions.create = AsyncMock(
        return_value=create_async_openai_response(
            '{"default": "async mock response"}'
        )
    )
    return client


# Uso en test async:
@pytest.mark.asyncio
async def test_async_sentiment_analysis(mock_async_llm_client, make_llm_response):
    """Test de función async que llama al LLM."""
    # Configurar respuesta
    mock_async_llm_client.chat.completions.create = AsyncMock(
        return_value=make_llm_response('{"sentiment": "positive", "confidence": 0.9}')
    )

    # Ejecutar función async
    with patch("app.async_sentiment.async_client", mock_async_llm_client):
        result = await async_analyze_sentiment("I love this!")

    assert result["sentiment"] == "positive"

Fixtures para testing de FastAPI

# tests/conftest.py (para proyectos con FastAPI)
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient


@pytest.fixture(scope="module")
def test_client():
    """
    Cliente HTTP para tests de FastAPI.
    scope="module": un solo cliente para todos los tests del módulo.
    
    Uso:
        def test_health_check(test_client):
            response = test_client.get("/health")
            assert response.status_code == 200
    """
    from app.main import app
    with TestClient(app) as client:
        yield client


@pytest.fixture(scope="module")
async def async_test_client():
    """Cliente async para tests de FastAPI async."""
    from app.main import app
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

Organización de fixtures por módulo

Para proyectos grandes, organiza las fixtures en múltiples archivos:

tests/
├── conftest.py              # Fixtures globales (make_llm_response, mock_llm_client)
├── helpers.py               # Funciones helper (create_openai_chat_response)
├── fixtures/                # Fixtures especializadas
│   ├── sentiment.py         # Fixtures para módulo de sentiment
│   ├── summarizer.py        # Fixtures para módulo de summarization
│   └── rag.py               # Fixtures para RAG pipeline
├── unit/
│   ├── conftest.py          # Fixtures específicas de unit tests
│   └── test_*.py
└── integration/
    ├── conftest.py          # Fixtures para integration tests
    └── test_*.py
# tests/fixtures/sentiment.py
"""Fixtures especializadas para tests del módulo de sentiment."""
import pytest
from tests.helpers import create_openai_chat_response

@pytest.fixture
def positive_sentiment_response():
    return create_openai_chat_response(
        '{"sentiment": "positive", "confidence": 0.95, "aspects": ["quality", "value"]}'
    )

@pytest.fixture
def negative_sentiment_response():
    return create_openai_chat_response(
        '{"sentiment": "negative", "confidence": 0.88, "aspects": ["shipping", "price"]}'
    )

# En tests/unit/conftest.py — importar las fixtures especializadas:
# pytest permite reexportar fixtures desde archivos externos

Comparación de enfoques de fixture

EnfoqueEjemploCuándo usarVentaja
Fixture estática@pytest.fixture def response(): return create(...)Caso típico que la mayoría de tests necesitaSimple, reutilizable
Factory fixture@pytest.fixture def make_response(): return create_funcTests necesitan diferentes contenidosFlexible, sin duplicación
Fixture parametrizada@pytest.fixture(params=[...])Correr el mismo test con múltiples casosAutomatiza cobertura
Fixture con scopescope="module"Objeto costoso de crear (cliente real)Velocidad
AsyncMock fixtureclient.create = AsyncMock(...)App usa async/awaitCompatibilidad con async

Anti-patrones de fixtures

# ❌ ANTI-PATRÓN 1: Fixture que retorna dict en vez de MagicMock
@pytest.fixture
def bad_llm_response():
    return {"choices": [{"message": {"content": "..."}}]}
# El código real hace response.choices[0].message.content (dot notation)
# El dict requiere response["choices"][0]["message"]["content"] (bracket notation)
# Son incompatibles → el test pasa pero el código real falla

# ✅ CORRECTO:
@pytest.fixture
def good_llm_response(make_llm_response):
    return make_llm_response('{"content": "..."}')
# MagicMock soporta dot notation: response.choices[0].message.content ✅


# ❌ ANTI-PATRÓN 2: Fixture demasiado específica
@pytest.fixture
def response_for_test_42():
    return create_openai_chat_response('{"very": "specific"}')
# Crea una fixture por test → conftest.py se convierte en basura

# ✅ CORRECTO: Factory que cualquier test puede customizar
@pytest.fixture
def make_llm_response():
    return create_openai_chat_response  # Retorna la función


# ❌ ANTI-PATRÓN 3: Fixture global con scope="session" para mocks mutables
@pytest.fixture(scope="session")
def shared_mock_client():
    client = MagicMock()
    client.chat.completions.create.return_value = ...
    return client
# Si un test modifica client.chat.completions.create.return_value,
# TODOS los tests siguientes en la sesión usan el valor modificado

# ✅ CORRECTO: scope="function" para mocks (o "module" si son read-only)
@pytest.fixture  # scope="function" por defecto
def mock_client():
    return MagicMock()  # Cada test recibe un mock limpio

Conexión con el proyecto del módulo

Las fixtures que defines aquí son exactamente las que usarás en el Proyecto 07: Test Suite Setup. El proyecto consiste en aplicar estas fixtures a una app LLM real, configurar el conftest.py completo, y escribir los primeros tests usando estas fixtures.

En los módulos siguientes:

  • Módulo 2: Usarás make_llm_response para crear prompt contract tests
  • Módulo 3: Ampliarás las fixtures para integration tests con LLM real
  • Módulo 4: Añadirás fixtures para testing de guardrails
  • Módulo 5: Añadirás fixtures que capturan logs para verificar logging

Las fixtures son una inversión: el tiempo que gastas ahora en diseñarlas bien se multiplica en productividad a lo largo de toda la guía.


Troubleshooting

Problema: AttributeError: 'dict' object has no attribute 'choices' Causa: La fixture retorna un dict en vez de un MagicMock. El código hace response.choices[0] (dot notation), no response["choices"][0] (bracket notation). Solución: Usa MagicMock() siempre: response = MagicMock(); response.choices[0].message.content = "...".

Problema: TypeError: 'MagicMock' object is not subscriptable Causa: El código accede con bracket notation response["choices"] pero la fixture usa MagicMock con dot notation. Solución: Identifica cómo accede tu código al response. Si hace response["choices"], usa un dict. Si hace response.choices, usa MagicMock. Lo más seguro es coincidir con la API real (OpenAI usa dot notation → usar MagicMock).

Problema: La fixture se ejecuta muchas veces y el test es lento (>2s por test). Causa: scope="function" en una fixture costosa (conexión real, carga de modelo). Solución: Cambia a scope="module" si la fixture es read-only y compartible entre tests.

Problema: El side_effect no funciona como esperaba. Causa: side_effect con lista consume un elemento por llamada. Si hay más llamadas que elementos, lanza StopIteration. Solución:

  • Para excepciones: mock.side_effect = Exception("error")
  • Para lista de respuestas: mock.side_effect = [resp1, resp2, resp3] (una por llamada)
  • Para función dinámica: mock.side_effect = lambda *args, **kwargs: calcular_response(args)

Problema: La fixture async no funciona (TypeError: object MagicMock can't be used in 'await' expression) Causa: MagicMock() no es awaitable. Para funciones async necesitas AsyncMock. Solución: from unittest.mock import AsyncMock; mock.create = AsyncMock(return_value=response)


Ejercicios

Ejercicio 1: Crear fixture de respuesta vacía y malformada

Crea dos fixtures en conftest.py: una que simule content="" (vacío) y otra que simule JSON truncado. Escribe tests que verifiquen cómo tu parser maneja cada caso.

Ver solución
# tests/conftest.py
@pytest.fixture
def llm_response_empty():
    """Edge case: el LLM retorna string vacío."""
    return create_openai_chat_response(content="")


@pytest.fixture
def llm_response_truncated_json():
    """Edge case: JSON truncado (finish_reason='length')."""
    return create_openai_chat_response(
        content='{"summary": "Este resumen fue',  # JSON incompleto
        finish_reason="length"
    )


# tests/unit/test_parser_robustness.py
def test_parser_handles_empty_content(llm_response_empty):
    content = llm_response_empty.choices[0].message.content
    with pytest.raises((ValueError, json.JSONDecodeError)):
        parse_llm_response(content)


def test_parser_handles_truncated_json(llm_response_truncated_json):
    content = llm_response_truncated_json.choices[0].message.content
    finish = llm_response_truncated_json.choices[0].finish_reason

    # Si finish_reason == "length", debería lanzar error o retornar None
    assert finish == "length"
    with pytest.raises((ValueError, json.JSONDecodeError)):
        parse_llm_response(content)

Ejercicio 2: Factory con parámetros

Modifica la función create_openai_chat_response para aceptar un parámetro multiple_choices: bool que, cuando es True, genera una respuesta con 2 choices (algunas APIs retornan múltiples opciones con n=2).

Ver solución
def create_openai_chat_response_multi(
    contents: list[str],
    tokens: int = 200,
) -> MagicMock:
    """
    Crea un mock con múltiples choices (cuando se usa n>1 en la API).
    """
    response = MagicMock()
    response.usage.total_tokens = tokens

    choices = []
    for i, content in enumerate(contents):
        choice = MagicMock()
        choice.index = i
        choice.finish_reason = "stop"
        choice.message.content = content
        choices.append(choice)

    response.choices = choices
    return response


# Uso:
@pytest.fixture
def multi_choice_response():
    return create_openai_chat_response_multi([
        '{"sentiment": "positive", "confidence": 0.9}',
        '{"sentiment": "positive", "confidence": 0.85}',
    ])


def test_function_uses_first_choice(multi_choice_response):
    content = multi_choice_response.choices[0].message.content
    result = parse_sentiment_response(content)
    assert result["sentiment"] == "positive"

Ejercicio 3: Fixture que depende de otra

Crea una fixture mock_sentiment_client que:

  1. Use la factory make_llm_response (fixture del conftest)
  2. Configure el client para retornar siempre un sentiment positivo
  3. Retorne un MagicMock del cliente ya configurado
Ver solución
@pytest.fixture
def mock_sentiment_client(make_llm_response):
    """
    Cliente mock preconfigurado para tests de sentiment.
    Ya configurado para retornar sentiment positivo.
    """
    client = MagicMock()
    client.chat.completions.create.return_value = make_llm_response(
        '{"sentiment": "positive", "confidence": 0.9}'
    )
    return client


# Uso en test:
def test_positive_text_is_analyzed_correctly(mock_sentiment_client):
    with patch("app.sentiment.client", mock_sentiment_client):
        result = analyze_sentiment("I love this!")

    assert result["sentiment"] == "positive"
    mock_sentiment_client.chat.completions.create.assert_called_once()

Ejercicio 4: AsyncMock fixture

Crea una fixture mock_async_openai_client usando AsyncMock que sea compatible con código que hace await client.chat.completions.create(...).

Ver solución
# tests/conftest.py
from unittest.mock import AsyncMock

@pytest.fixture
def mock_async_openai_client(make_llm_response):
    """
    Mock de AsyncOpenAI para tests de código async.
    El método create() es AsyncMock para soportar 'await'.
    """
    client = MagicMock()
    # AsyncMock hace que 'await client.chat.completions.create(...)' funcione
    client.chat.completions.create = AsyncMock(
        return_value=make_llm_response('{"result": "async mock default"}')
    )
    return client


# Uso:
@pytest.mark.asyncio
async def test_async_function(mock_async_openai_client, make_llm_response):
    # Configurar respuesta específica
    mock_async_openai_client.chat.completions.create = AsyncMock(
        return_value=make_llm_response('{"sentiment": "positive", "confidence": 0.9}')
    )

    with patch("app.async_module.async_client", mock_async_openai_client):
        result = await my_async_function("test input")

    assert result["sentiment"] == "positive"
    mock_async_openai_client.chat.completions.create.assert_awaited_once()

Ejercicio 5: Fixture parametrizada para cobertura automática

Crea una fixture parametrizada all_finish_reasons que genere respuestas con finish_reason en ["stop", "length", "content_filter"]. Escribe un test que use esta fixture para verificar que tu código maneja correctamente cada caso.

Ver solución
@pytest.fixture(params=[
    pytest.param("stop", id="normal_completion"),
    pytest.param("length", id="truncated_by_token_limit"),
    pytest.param("content_filter", id="filtered_by_safety"),
])
def response_with_finish_reason(request, make_llm_response):
    """Fixture parametrizada por finish_reason."""
    finish = request.param
    content = '{"result": "some content"}' if finish == "stop" else '{"result": "partial'
    return (finish, create_openai_chat_response(content=content, finish_reason=finish))


def test_handles_all_finish_reasons(response_with_finish_reason):
    """Verifica que el código maneja correctamente cada finish_reason."""
    finish_reason, response = response_with_finish_reason

    if finish_reason == "stop":
        # Normal: debe procesar correctamente
        result = process_llm_response(response)
        assert result is not None
    elif finish_reason == "length":
        # Truncado: debe lanzar error o retornar parcial con flag
        with pytest.raises(ValueError, match="truncated"):
            process_llm_response(response)
    elif finish_reason == "content_filter":
        # Filtrado: debe lanzar ContentFilterError
        with pytest.raises((ValueError, ContentFilterError)):
            process_llm_response(response)

Resumen

  • Las fixtures para LLM deben usar MagicMock() con estructura que replica la API real (dot notation, no dict)
  • La factory create_openai_chat_response() es la base de todas las fixtures — cópiala a tests/helpers.py
  • Usa factory fixtures (return create_openai_chat_response) para tests que necesitan contenidos diferentes
  • Las fixtures parametrizadas (params=[...]) ejecutan el test múltiples veces y maximizan cobertura
  • Para código async: usa AsyncMock en vez de MagicMock para que await funcione
  • Diseña fixtures para edge cases desde el inicio: vacío, malformado, truncado, con texto extra

Recursos adicionales

  1. pytest fixtures — documentación oficial — Scopes, factory fixtures, fixture parametrization
  2. unittest.mock — MagicMock — Cómo configura atributos automáticamente
  3. unittest.mock — AsyncMock — Para código async
  4. pytest-mock — Plugin que simplifica el uso de mocks en pytest
  5. OpenAI API Reference — Chat — Estructura real del ChatCompletion object
  6. Factories as Fixtures — Pattern documentado en pytest