Módulo 1: Testing Fundamentals para AI
4. Anatomía del Test: Arrange-Act-Assert para LLM
Descripción
Un test bien estructurado sigue el patrón Arrange-Act-Assert (AAA). Para apps LLM, la fase "Arrange" tiene complejidades adicionales: configurar mocks del LLM, preparar fixtures con respuestas realistas, y asegurarte de que el mock se inyecta en el lugar correcto. Si Arrange está mal, el test puede parecer que pasa pero no está testeando lo que crees.
Esta cápsula profundiza en cada fase del patrón AAA adaptado a LLM apps: cómo hacer un Arrange robusto con mocks, cómo estructurar el Act para testear exactamente una cosa, y cómo escribir assertions específicas para outputs que pueden variar. También cubre naming conventions para tests, que es más importante de lo que parece cuando tienes 200 tests y un fallo a las 3am.
Al terminar sabrás estructurar cualquier test para una app LLM de forma que sea legible, mantenible, y diagnostique exactamente dónde está el problema cuando falla.
El patrón Arrange-Act-Assert
Estructura básica
def test_nombre_descriptivo():
# ─────────── ARRANGE ───────────
# Preparar todo lo necesario para el test
# - Inputs
# - Mocks y stubs
# - Estado inicial
# ─────────── ACT ───────────────
# Ejecutar EXACTAMENTE una cosa
# - La función bajo test
# ─────────── ASSERT ────────────
# Verificar el resultado
# - Una o varias assertions sobre el mismo comportamiento
Por qué importa esta separación:
- Cuando un test falla, sabes inmediatamente en qué fase falló
- Fácil de leer y mantener (otros pueden entender el test sin comentarios)
- Previene tests que hacen demasiado (difíciles de debuggear)
Ejemplo básico: función determinística
# tests/unit/test_parsers.py
import pytest
from app.parsers import parse_json_from_llm_output
def test_parse_json_from_markdown_block():
# ─── ARRANGE ───
# Input: respuesta del LLM con JSON dentro de bloque markdown
raw_output = '```json\n{"sentiment": "positive", "confidence": 0.9}\n```'
expected = {"sentiment": "positive", "confidence": 0.9}
# ─── ACT ───
# Ejecutar el parser
result = parse_json_from_llm_output(raw_output)
# ─── ASSERT ───
# Verificar que el output es exactamente lo esperado
assert result == expected
Esto es lo más simple. El parser es 100% determinístico — puedes usar assert result == expected.
Arrange para LLM: configurando mocks
Cuando la función bajo test llama al LLM, el Arrange incluye configurar el mock. Hay tres formas de hacerlo:
Opción 1: @patch como decorador
# tests/unit/test_sentiment.py
import pytest
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment
@patch("app.sentiment.client.chat.completions.create")
def test_analyze_sentiment_positive(mock_create):
"""
@patch intercepta la llamada a la API y retorna lo que definimos.
mock_create es el argumento que recibe la función — es el objeto
que reemplaza a client.chat.completions.create durante el test.
"""
# ─── ARRANGE ───
# Configurar qué retorna el mock cuando se llama
mock_response = MagicMock()
mock_response.choices[0].message.content = (
'{"sentiment": "positive", "confidence": 0.9}'
)
mock_create.return_value = mock_response
# Input del test
text = "I absolutely love this product!"
# ─── ACT ───
result = analyze_sentiment(text)
# ─── ASSERT ───
assert result["sentiment"] == "positive"
assert result["confidence"] == 0.9
# También podemos verificar que el LLM fue llamado
mock_create.assert_called_once()
Opción 2: with patch como context manager
def test_analyze_sentiment_with_context_manager():
"""
Útil cuando necesitas múltiples patches o el test es muy corto.
"""
# ─── ARRANGE ───
mock_response = MagicMock()
mock_response.choices[0].message.content = (
'{"sentiment": "negative", "confidence": 0.8}'
)
with patch("app.sentiment.client.chat.completions.create") as mock_create:
mock_create.return_value = mock_response
# ─── ACT ───
result = analyze_sentiment("This is terrible.")
# ─── ASSERT ─── (fuera del with — el patch ya no aplica)
assert result["sentiment"] == "negative"
assert 0.0 <= result["confidence"] <= 1.0
Opción 3: Fixture del conftest
# tests/conftest.py (ya configurado en cápsula 3)
# La fixture mock_openai_client ya está disponible
def test_analyze_sentiment_with_fixture(mock_openai_client, mock_openai_response):
"""
Usa la fixture del conftest — más limpio cuando muchos tests
necesitan el mismo tipo de mock.
"""
# ─── ARRANGE ───
mock_openai_client.chat.completions.create.return_value = mock_openai_response(
'{"sentiment": "neutral", "confidence": 0.5}'
)
# Inyectar el cliente mockeado en la función
# (requiere que analyze_sentiment acepte client como argumento o
# que uses patch para reemplazar el cliente global)
# ─── ACT ───
with patch("app.sentiment.client", mock_openai_client):
result = analyze_sentiment("The package arrived.")
# ─── ASSERT ───
assert result["sentiment"] == "neutral"
Comparación de las tres opciones
| Enfoque | Cuándo usar | Ventaja | Desventaja |
|---|---|---|---|
@patch decorador | Un test necesita un mock específico | Explícito, fácil de leer | Añade argumento mock_xxx al test |
with patch | Necesitas patch en una sección específica | Control fino del scope | Puede anidar mucho si hay varios patches |
| Fixture | Múltiples tests usan el mismo mock | Reutilizable, DRY | Más abstraído (hay que buscar el conftest) |
Regla práctica: Usa fixture cuando >3 tests usan el mismo mock. Usa @patch para mocks únicos de un test específico.
Act: testear exactamente una cosa
El principio
# ❌ MALO: múltiples Acts en un test
def test_pipeline_bad():
# Act 1
validated = validate_input("texto")
assert validated is not None
# Act 2 (debería ser un test separado)
result = summarize(validated)
assert "summary" in result
# Act 3 (debería ser otro test separado)
formatted = format_for_user(result)
assert len(formatted) > 0
# ¿Qué parte falló? ¿validate_input? ¿summarize? ¿format_for_user?
# ✅ BUENO: un Act por test
def test_validate_input_returns_stripped_text():
result = validate_input(" texto con espacios ")
assert result == "texto con espacios"
def test_summarize_returns_summary_key():
with patch("app.llm.client.chat.completions.create") as mock:
mock.return_value = create_mock_response('{"summary": "resumen", "points": []}')
result = summarize("texto")
assert "summary" in result
def test_format_for_user_includes_header():
result = format_for_user({"summary": "resumen", "points": []})
assert result.startswith("## Resumen")
Cuando test_summarize_returns_summary_key falla, sabes exactamente dónde está el problema: en la función summarize, en la integración entre el mock y el parser de la respuesta.
Excepciones válidas: múltiples assertions en un Act
Un test puede (y debe) tener múltiples assertions si todas verifican el mismo comportamiento:
# ✅ ACEPTABLE: múltiples assertions sobre el mismo resultado
def test_sentiment_response_structure():
with patch("app.sentiment.client.chat.completions.create") as mock:
mock.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("I love this!")
# Todas estas assertions verifican "la estructura del resultado es correcta"
# Son aspectos del mismo comportamiento, no comportamientos diferentes
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
assert "sentiment" in result, "Missing 'sentiment' key"
assert "confidence" in result, "Missing 'confidence' key"
assert result["sentiment"] in ["positive", "negative", "neutral"]
assert isinstance(result["confidence"], float)
assert 0.0 <= result["confidence"] <= 1.0
Diferencia clave: Múltiples assertions sobre el mismo resultado → ✅. Múltiples calls a diferentes funciones → ❌.
Assert: específicos y útiles cuando fallan
El problema con assertions vagas
# ❌ MALO: vago, cuando falla no sabes qué esperabas
assert result
assert result["confidence"]
assert result["items"]
# ✅ BUENO: específico, mensajes de error informativos
assert result is not None, "analyze_sentiment retornó None"
assert result.get("confidence") is not None, "Falta key 'confidence'"
assert len(result.get("items", [])) > 0, "Lista 'items' está vacía"
Assertions con mensajes personalizados
@patch("app.sentiment.client.chat.completions.create")
def test_sentiment_confidence_range(mock_create):
mock_create.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 1.5}' # confidence inválido
)
result = analyze_sentiment("texto")
# El mensaje se muestra cuando el assert falla
assert 0.0 <= result["confidence"] <= 1.0, (
f"Confidence fuera de rango [0,1]: {result['confidence']}\n"
f"Resultado completo: {result}"
)
Assertions para tipos
def test_response_types():
with patch(...) as mock:
mock.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9, "aspects": ["price", "quality"]}'
)
result = analyze_sentiment("texto")
# Verificar tipos explícitamente
assert isinstance(result, dict), f"Expected dict, got: {type(result).__name__}"
assert isinstance(result["sentiment"], str), f"sentiment debe ser str"
assert isinstance(result["confidence"], (int, float)), "confidence debe ser numérico"
assert isinstance(result["aspects"], list), "aspects debe ser lista"
assert all(isinstance(a, str) for a in result["aspects"]), "Todos los aspects deben ser str"
Assertions para outputs no-determinísticos
Cuando el test usa el LLM real (no mockeado), las assertions deben ser sobre propiedades invariantes:
@pytest.mark.integration
def test_sentiment_real_llm_properties():
"""
Test de integración que llama a la API real.
Assert sobre propiedades invariantes (no valor exacto).
"""
result = analyze_sentiment("I love this product!")
# Propiedades que SIEMPRE deben cumplirse, independiente del output:
assert isinstance(result, dict), "Resultado debe ser dict"
assert "sentiment" in result, "Debe tener key 'sentiment'"
assert result["sentiment"] in ["positive", "negative", "neutral"], (
f"Valor inesperado: {result['sentiment']}"
)
assert 0.0 <= result.get("confidence", -1) <= 1.0, (
f"Confidence fuera de rango: {result.get('confidence')}"
)
# Para texto claramente positivo, podemos ser más específicos:
assert result["sentiment"] == "positive", (
f"Para texto positivo inequívoco esperábamos 'positive', "
f"got: {result['sentiment']}"
)
Naming conventions: nombres que diagnostican
El nombre del test es la primera información que ves cuando falla. Un buen nombre hace que el fallo sea autoexplicativo.
Patrón recomendado: test_[función]_[condición]_[resultado_esperado]
# ✅ BUENOS nombres — el fallo es autoexplicativo:
def test_parse_json_with_markdown_block_returns_dict():
pass
def test_analyze_sentiment_with_empty_text_raises_value_error():
pass
def test_summarize_with_valid_input_returns_three_points():
pass
def test_validate_input_with_text_over_limit_raises_value_error():
pass
# ❌ MALOS nombres — cuando fallan, no sabes qué pasó:
def test_parse():
pass
def test_it_works():
pass
def test_error():
pass
def test_sentiment_1():
pass
Nombres para tests parametrizados
@pytest.mark.parametrize(
"raw_input,expected",
[
pytest.param('{"x": 1}', {"x": 1}, id="pure_json"),
pytest.param('```json\n{"x": 1}\n```', {"x": 1}, id="markdown_block"),
pytest.param('Result: {"x": 1}', {"x": 1}, id="with_prefix_text"),
]
)
def test_parse_json_from_llm_output(raw_input, expected):
"""
Usar pytest.param con id= hace que el nombre del test sea descriptivo:
test_parse_json_from_llm_output[pure_json]
test_parse_json_from_llm_output[markdown_block]
test_parse_json_from_llm_output[with_prefix_text]
"""
assert parse_json_from_llm_output(raw_input) == expected
Nombres para clases de test
class TestAnalyzeSentiment:
"""Agrupa todos los tests de la función analyze_sentiment."""
class TestHappyPath:
"""Tests del flujo normal (inputs válidos)."""
def test_positive_text_returns_positive_sentiment(self):
...
def test_negative_text_returns_negative_sentiment(self):
...
class TestEdgeCases:
"""Tests de casos límite."""
def test_empty_text_raises_value_error(self):
...
def test_very_long_text_is_truncated(self):
...
class TestMocking:
"""Tests que verifican la integración con el LLM mock."""
def test_calls_llm_exactly_once(self):
...
def test_passes_text_in_prompt(self):
...
Test completo: ejemplo integrado
Aquí está un test completo bien estructurado para una función de análisis de sentimiento:
# tests/unit/test_sentiment_analysis.py
"""
Tests unitarios para app.sentiment.analyze_sentiment.
Todos los tests usan mocks — sin llamadas a API real.
"""
import pytest
from unittest.mock import patch, MagicMock, call
from app.sentiment import analyze_sentiment, SentimentResult
def create_sentiment_mock(sentiment: str, confidence: float) -> MagicMock:
"""Helper para crear mocks con estructura real de OpenAI API."""
response = MagicMock()
response.choices[0].message.content = (
f'{{"sentiment": "{sentiment}", "confidence": {confidence}}}'
)
response.usage.total_tokens = 150
return response
@pytest.mark.unit
class TestAnalyzeSentiment:
"""Tests para la función principal de análisis de sentimiento."""
@patch("app.sentiment.client.chat.completions.create")
def test_positive_text_returns_positive_sentiment(self, mock_create):
"""El análisis de texto claramente positivo retorna sentiment=positive."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock("positive", 0.9)
text = "I absolutely love this product!"
# ─── ACT ───
result = analyze_sentiment(text)
# ─── ASSERT ───
assert result["sentiment"] == "positive", (
f"Texto positivo debería retornar 'positive', got: {result['sentiment']}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_returns_required_keys(self, mock_create):
"""El resultado siempre tiene las keys 'sentiment' y 'confidence'."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock("neutral", 0.5)
# ─── ACT ───
result = analyze_sentiment("Some text")
# ─── ASSERT ───
assert "sentiment" in result, "Falta key 'sentiment'"
assert "confidence" in result, "Falta key 'confidence'"
@patch("app.sentiment.client.chat.completions.create")
def test_confidence_is_float_in_valid_range(self, mock_create):
"""Confidence es float en rango [0.0, 1.0]."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock("positive", 0.85)
# ─── ACT ───
result = analyze_sentiment("Great!")
# ─── ASSERT ───
assert isinstance(result["confidence"], (int, float)), (
f"confidence debe ser numérico, got: {type(result['confidence'])}"
)
assert 0.0 <= result["confidence"] <= 1.0, (
f"confidence fuera de rango: {result['confidence']}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_calls_llm_exactly_once_per_analysis(self, mock_create):
"""La función llama al LLM exactamente una vez por análisis."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock("positive", 0.9)
# ─── ACT ───
analyze_sentiment("Some text")
# ─── ASSERT ───
mock_create.assert_called_once()
@patch("app.sentiment.client.chat.completions.create")
def test_passes_text_in_the_prompt(self, mock_create):
"""El texto de análisis está incluido en el prompt enviado al LLM."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock("positive", 0.9)
text = "unique_test_text_12345"
# ─── ACT ───
analyze_sentiment(text)
# ─── ASSERT ───
# Verificar que el texto fue pasado al LLM
call_args = mock_create.call_args
messages = call_args.kwargs.get("messages") or call_args.args[0]
# El texto debe aparecer en alguno de los mensajes
all_content = " ".join(
m.get("content", "") for m in messages
if isinstance(m, dict)
)
assert text in all_content, (
f"El texto '{text}' no aparece en el prompt enviado al LLM"
)
@pytest.mark.unit
class TestAnalyzeSentimentEdgeCases:
"""Tests de casos límite para analyze_sentiment."""
def test_empty_text_raises_value_error(self):
"""Texto vacío lanza ValueError antes de llamar al LLM."""
# ─── ARRANGE / ACT / ASSERT combinados (test de excepción)
with pytest.raises(ValueError, match="vacío|empty"):
analyze_sentiment("")
def test_none_text_raises_type_error(self):
"""None como input lanza TypeError."""
with pytest.raises((TypeError, ValueError)):
analyze_sentiment(None)
@patch("app.sentiment.client.chat.completions.create")
def test_handles_malformed_json_from_llm(self, mock_create):
"""Si el LLM retorna JSON malformado, la función maneja el error."""
# ─── ARRANGE ───
mock_create.return_value = create_sentiment_mock.__wrapped__ if hasattr(
create_sentiment_mock, '__wrapped__') else MagicMock()
bad_response = MagicMock()
bad_response.choices[0].message.content = "This is not JSON at all"
mock_create.return_value = bad_response
# ─── ACT / ASSERT ───
# La función debe manejar el error gracefully
# (lanzar excepción controlada, no crash con AttributeError)
with pytest.raises((ValueError, KeyError)):
analyze_sentiment("Some text")
Verificar interacciones con el LLM
Los tests no solo verifican el resultado — también pueden verificar cómo se llamó al LLM:
@patch("app.sentiment.client.chat.completions.create")
def test_uses_correct_model(mock_create):
"""La función usa el modelo correcto."""
mock_create.return_value = create_sentiment_mock("positive", 0.9)
analyze_sentiment("texto")
# Verificar los argumentos con que se llamó al LLM
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs.get("model") == "gpt-4o-mini", (
f"Se esperaba gpt-4o-mini, got: {call_kwargs.get('model')}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_uses_low_temperature_for_consistency(mock_create):
"""Temperature debe ser baja para mayor consistencia en análisis."""
mock_create.return_value = create_sentiment_mock("positive", 0.9)
analyze_sentiment("texto")
call_kwargs = mock_create.call_args.kwargs
temperature = call_kwargs.get("temperature", 1.0)
assert temperature <= 0.3, (
f"Temperature debería ser ≤0.3 para consistencia, got: {temperature}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_does_not_call_llm_for_empty_input(mock_create):
"""Si el input es inválido, NO se debe llamar al LLM (ahorro de costo)."""
with pytest.raises(ValueError):
analyze_sentiment("")
mock_create.assert_not_called()
Comparación: test bien vs mal estructurado
| Aspecto | Test mal estructurado | Test bien estructurado |
|---|---|---|
| Nombre | test_sentiment() | test_analyze_sentiment_positive_text_returns_positive_sentiment() |
| Arrange | Mock global sin configurar | Mock configurado con respuesta específica |
| Act | Múltiples llamadas a funciones | Una sola llamada |
| Assert | assert result | assert result["sentiment"] == "positive" con mensaje |
| Cuando falla | "No sé qué esperaba" | "analyze_sentiment con texto positivo debería retornar 'positive'" |
Troubleshooting
Problema: El test pasa pero cuando ejecuto el código real falla.
Causa: El mock no refleja la estructura real de la API. El código hace response.choices[0].message.content pero el mock retorna un dict en vez de un objeto con atributos.
Solución: Usa MagicMock() y configura los atributos: mock.choices[0].message.content = "...". No uses dicts planos como mocks de la API.
Problema: AssertionError pero no sé qué valor tenía result.
Solución: Añade el valor al mensaje de la assertion:
assert "sentiment" in result, f"Resultado completo: {result}"
Problema: El test falla con AttributeError: 'MagicMock' object has no attribute 'content'.
Solución: MagicMock() crea atributos automáticamente pero los accesos encadenados a listas requieren configuración explícita:
mock_response.choices = [MagicMock()] # Lista real
mock_response.choices[0].message.content = "..." # Configurar el atributo
Problema: assert_called_once() falla aunque visualmente el código llama a la función.
Causa: Estás verificando un mock diferente al que se usa. El path del @patch no coincide con dónde se usa la función.
Solución: El path debe ser "modulo_que_usa.atributo". Si app.sentiment importa con from openai import OpenAI; client = OpenAI(), el patch es @patch("app.sentiment.client.chat.completions.create").
Problema: Tests lentos por mocks que tardan en inicializarse.
Solución: MagicMock() es instantáneo. Si los tests son lentos, probablemente hay una llamada real al LLM sin mockear. Usa pytest -s para ver el output y detectar llamadas inesperadas.
Ejercicios
Ejercicio 1: Identificar AAA
En este test, identifica claramente dónde termina Arrange, dónde está Act, y dónde están los Asserts:
def test_parse_summary():
import json
raw = '```json\n{"summary": "Texto corto", "points": ["a", "b", "c"]}\n```'
result = parse_summary_response(raw)
assert isinstance(result, dict)
assert result["summary"] == "Texto corto"
assert len(result["points"]) == 3
Ver solución
def test_parse_summary():
# ─── ARRANGE ───────────────────────────────────────────
import json
raw = '```json\n{"summary": "Texto corto", "points": ["a", "b", "c"]}\n```'
# No hay mock porque parse_summary_response es determinístico
# ─── ACT ────────────────────────────────────────────────
result = parse_summary_response(raw)
# ─── ASSERT ─────────────────────────────────────────────
assert isinstance(result, dict)
assert result["summary"] == "Texto corto"
assert len(result["points"]) == 3
Las tres assertions verifican el mismo comportamiento ("el parser retorna un dict con la estructura correcta"), por eso es válido tenerlas juntas en un test.
Ejercicio 2: Reescribir con AAA y assertions específicas
Refactoriza este test para que sea más legible y las assertions sean más informativas:
def test_x():
r = summarize("texto")
assert r
assert r.get("s")
Ver solución
@patch("app.summarizer.client.chat.completions.create")
def test_summarize_returns_non_empty_summary(mock_create):
# ─── ARRANGE ───
mock_response = MagicMock()
mock_response.choices[0].message.content = (
'{"summary": "Resumen del texto", "points": ["Punto 1", "Punto 2", "Punto 3"]}'
)
mock_create.return_value = mock_response
input_text = "texto de prueba para summarizar"
# ─── ACT ───
result = summarize(input_text)
# ─── ASSERT ───
assert result is not None, "summarize no debe retornar None"
assert "summary" in result, f"Falta key 'summary'. Resultado: {result}"
assert len(result["summary"]) > 0, "El resumen no debe estar vacío"
Cambios:
- Nombre descriptivo:
test_summarize_returns_non_empty_summary - Mock configurado correctamente con
MagicMock() - Assertions con mensajes informativos
- Variables con nombres descriptivos (
input_text, no"texto")
Ejercicio 3: Mock realista de OpenAI
Crea un mock de respuesta que replique exactamente la estructura real del objeto ChatCompletion de OpenAI (incluyendo usage, model, id, y choices[0].finish_reason).
Ver solución
from unittest.mock import MagicMock
def create_realistic_openai_mock(content: str, tokens: int = 150) -> MagicMock:
"""
Mock que replica la estructura exacta de ChatCompletion de OpenAI.
Basado en la estructura real de la API (verificar con OpenAI docs).
"""
response = MagicMock()
# Metadatos de la respuesta
response.id = "chatcmpl-test-abc123"
response.model = "gpt-4o-mini"
response.object = "chat.completion"
# El Choice principal
choice = MagicMock()
choice.index = 0
choice.finish_reason = "stop" # O "length" si se truncó
choice.message.role = "assistant"
choice.message.content = content
response.choices = [choice]
# Uso de tokens (importante para cost tracking)
response.usage.prompt_tokens = tokens // 3
response.usage.completion_tokens = tokens * 2 // 3
response.usage.total_tokens = tokens
return response
# Verificar que funciona igual que la API real:
# result = response.choices[0].message.content ← idéntico a API real
Ejercicio 4: Assertions para propiedades invariantes
Para una función extract_key_entities(text) -> list[dict] que extrae entidades de texto con el LLM, escribe 5 assertions que verifiquen propiedades invariantes del output (sin comparar el contenido exacto de las entidades).
Ver solución
@patch("app.entities.client.chat.completions.create")
def test_extract_key_entities_structure(mock_create):
# ─── ARRANGE ───
mock_create.return_value = create_realistic_openai_mock(
'[{"name": "Apple", "type": "company"}, {"name": "Tim Cook", "type": "person"}]'
)
# ─── ACT ───
entities = extract_key_entities("Apple CEO Tim Cook announced new products.")
# ─── ASSERT — 5 propiedades invariantes ───
# 1. El resultado es una lista
assert isinstance(entities, list), f"Expected list, got {type(entities)}"
# 2. La lista no está vacía para texto con entidades obvias
assert len(entities) > 0, "La lista de entidades no debe estar vacía"
# 3. Cada entidad es un dict
for entity in entities:
assert isinstance(entity, dict), f"Cada entidad debe ser dict: {entity}"
# 4. Cada entidad tiene las keys requeridas
required_keys = {"name", "type"}
for entity in entities:
assert required_keys.issubset(entity.keys()), (
f"Entidad falta keys {required_keys - entity.keys()}: {entity}"
)
# 5. Los valores de 'name' no están vacíos
for entity in entities:
assert entity["name"].strip(), f"Nombre de entidad vacío: {entity}"
Ejercicio 5: Verificar que el LLM no se llama en casos de error
Escribe un test que verifique que analyze_sentiment("") no llama al LLM (para verificar que la validación de input ocurre ANTES de la llamada costosa a la API).
Ver solución
@patch("app.sentiment.client.chat.completions.create")
def test_empty_text_does_not_call_llm(mock_create):
"""
Si el input es inválido, el LLM NO debe ser llamado.
Esto es importante: llamar al LLM con input inválido
desperdicia dinero y no produce resultados útiles.
"""
# ─── ARRANGE ───
# No configuramos return_value porque NO esperamos que se llame
# ─── ACT / ASSERT ───
with pytest.raises(ValueError):
analyze_sentiment("") # Debe fallar por validación, no por el LLM
# Verificar que el LLM nunca fue llamado
mock_create.assert_not_called(), (
"El LLM no debería ser llamado si el input es inválido"
)
Por qué importa: Si el test falla porque assert_not_called() falla, significa que tu código llama al LLM antes de validar el input. Esto desperdicia dinero y puede causar errores difíciles de debuggear.
Resumen
- Arrange para LLM: configura el mock ANTES del Act, replica la estructura real de la API con
MagicMock() - Act es una sola cosa: la función bajo test. Múltiples Acts → múltiples tests
- Assert específico: mensajes informativos + verificar tipos + verificar rangos
- Para outputs no-determinísticos: assertions sobre propiedades invariantes, no valores exactos
- Nombres descriptivos:
test_[función]_[condición]_[resultado_esperado]— cuando falla, el nombre diagnostica - Verifica no solo el resultado, sino también las interacciones:
assert_called_once(),assert_called_with(),assert_not_called()
Recursos adicionales
- Arrange-Act-Assert (Python Testing with pytest) — Libro de referencia sobre testing en Python
- pytest assert introspection — Cómo pytest mejora los asserts con información de contexto
- unittest.mock — Mock objects — Documentación oficial de MagicMock y sus métodos de verificación
- Where to patch (Python docs) — Guía crítica sobre el path correcto para @patch
- Test Naming Best Practices — Roy Osherove sobre convenciones de nombres
- pytest parametrize with ids — Cómo hacer nombres descriptivos en tests parametrizados