Inicio / Inteligencia Artificial / AI Engineering Pro / Testing y Code Review para IA

Testing y Code Review para IA

Testing pyramid para IA, mocking LLMs, evaluation tests y checklist de code review.

Avanzado
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Testing, Code Review y Documentación en Proyectos de IA

Testing en IA: Un Desafío Diferente

El testing de aplicaciones de IA es fundamentalmente diferente al testing tradicional: los outputs son no determinísticos, las dependencias externas son costosas, y la "correctitud" es a menudo subjetiva.

Estrategia de Testing por Capas

┌─────────────────────────────────────────┐
│         E2E Tests (5%)                   │
│    Flujo completo: query → response      │
├─────────────────────────────────────────┤
│       Integration Tests (20%)            │
│    RAG pipeline, agent workflow           │
├─────────────────────────────────────────┤
│        Unit Tests (75%)                  │
│    Chunking, formatting, parsing         │
└─────────────────────────────────────────┘

Unit Tests para Componentes de IA

import pytest
from unittest.mock import AsyncMock, patch, MagicMock

# ── Test de Chunking ─────────────────────────────────────
class TestChunking:
    def test_chunk_respects_max_size(self):
        text = "x " * 1000
        chunks = chunk_text(text, max_size=500, overlap=50)
        for chunk in chunks:
            assert len(chunk) <= 500

    def test_chunk_overlap(self):
        text = "Sentence one. Sentence two. Sentence three. Sentence four."
        chunks = chunk_text(text, max_size=30, overlap=10)
        # Verificar solapamiento
        for i in range(len(chunks) - 1):
            overlap_text = chunks[i][-10:]
            assert overlap_text in chunks[i + 1]

    def test_empty_text(self):
        chunks = chunk_text("", max_size=500)
        assert chunks == []

    def test_preserves_code_blocks(self):
        text = "Intro\n```python\ndef hello():\n    print('hi')\n```\nEnd"
        chunks = chunk_text(text, max_size=50)
        # El bloque de código no debe cortarse a la mitad
        for chunk in chunks:
            assert chunk.count("```") % 2 == 0  # Siempre pares

# ── Test de Prompt Template ──────────────────────────────
class TestPromptTemplate:
    def test_template_includes_context(self):
        prompt = build_rag_prompt("¿Qué es RAG?", ["doc1", "doc2"])
        assert "doc1" in prompt
        assert "doc2" in prompt
        assert "¿Qué es RAG?" in prompt

    def test_template_limits_context_length(self):
        long_docs = ["x" * 10000] * 20
        prompt = build_rag_prompt("query", long_docs, max_context_tokens=4000)
        assert count_tokens(prompt) <= 5000  # context + prompt overhead

Mocking de LLMs y APIs

# ── Mock de OpenAI ───────────────────────────────────────
class MockLLMResponse:
    def __init__(self, content):
        self.choices = [MagicMock(message=MagicMock(content=content))]
        self.usage = MagicMock(prompt_tokens=100, completion_tokens=50)

@pytest.fixture
def mock_openai():
    with patch("openai.AsyncOpenAI") as mock:
        client = AsyncMock()
        client.chat.completions.create = AsyncMock(
            return_value=MockLLMResponse("Mocked response")
        )
        mock.return_value = client
        yield client

@pytest.mark.asyncio
async def test_rag_service_returns_answer(mock_openai, mock_vectorstore):
    service = RAGService(llm_client=mock_openai, vectorstore=mock_vectorstore)
    result = await service.answer("¿Qué es RAG?")

    assert result["answer"] == "Mocked response"
    assert result["tokens_used"] == 150
    mock_openai.chat.completions.create.assert_called_once()

# ── Mock de Vector Store ─────────────────────────────────
@pytest.fixture
def mock_vectorstore():
    store = MagicMock()
    store.similarity_search.return_value = [
        MagicMock(page_content="RAG es...", metadata={"source": "doc.md"}),
    ]
    return store

Integration Tests para RAG Pipeline

@pytest.mark.integration
class TestRAGPipeline:
    """Tests que usan servicios reales (vector DB local)."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        # Usar ChromaDB en memoria para tests
        self.vectorstore = Chroma(
            embedding_function=FakeEmbeddings(size=384),
            persist_directory=str(tmp_path / "chroma"),
        )
        self.vectorstore.add_texts(
            texts=["JWT se configura en auth.config", "Rate limit es 1000/min"],
            metadatas=[{"source": "auth.md"}, {"source": "api.md"}],
        )

    def test_retriever_finds_relevant_docs(self):
        results = self.vectorstore.similarity_search("configurar JWT", k=2)
        assert len(results) > 0
        assert "JWT" in results[0].page_content

    def test_full_rag_chain(self):
        chain = build_rag_chain(self.vectorstore, FakeLLM())
        result = chain.invoke("¿Cómo configuro JWT?")
        assert len(result) > 0

    def test_no_results_returns_fallback(self):
        chain = build_rag_chain(self.vectorstore, FakeLLM())
        result = chain.invoke("¿Cuál es la receta de paella?")
        assert "no tengo información" in result.lower()

Evaluation Tests (LLM-as-Judge)

@pytest.mark.evaluation
class TestRAGQuality:
    """Tests de calidad que usan LLM como juez."""

    EVAL_CASES = [
        {
            "question": "¿Cómo autenticar con JWT?",
            "expected_topics": ["JWT", "token", "configuración"],
            "min_faithfulness": 0.8,
        },
    ]

    @pytest.mark.parametrize("case", EVAL_CASES)
    def test_answer_relevancy(self, rag_service, case):
        result = rag_service.answer(case["question"])
        answer = result["answer"].lower()

        # Al menos 2 de los topics esperados deben mencionarse
        matches = sum(1 for t in case["expected_topics"] if t.lower() in answer)
        assert matches >= 2, f"Expected topics {case['expected_topics']}, got: {answer[:200]}"

    @pytest.mark.parametrize("case", EVAL_CASES)
    def test_faithfulness(self, rag_service, judge_llm, case):
        result = rag_service.answer(case["question"])

        # LLM-as-judge evalúa si la respuesta es fiel al contexto
        score = judge_llm.evaluate_faithfulness(
            question=case["question"],
            answer=result["answer"],
            context=result["sources"],
        )
        assert score >= case["min_faithfulness"]

Code Review para Proyectos de IA

Checklist de Code Review

## Code Review Checklist — AI Systems

### Seguridad
- [ ] ¿Los prompts están protegidos contra injection?
- [ ] ¿Los API keys están en secrets manager (no hardcoded)?
- [ ] ¿Se validan los inputs del usuario antes de pasar al LLM?
- [ ] ¿Las queries SQL generadas por agentes son read-only?

### Calidad
- [ ] ¿Los prompts tienen instrucciones claras y específicas?
- [ ] ¿Hay guardrails para respuestas inapropiadas?
- [ ] ¿Se manejan los edge cases (sin contexto, timeout, rate limit)?
- [ ] ¿La evaluación cubre los principales casos de uso?

### Performance
- [ ] ¿Se cachean embeddings y respuestas frecuentes?
- [ ] ¿Se usa streaming para respuestas largas?
- [ ] ¿El batch size es óptimo para las APIs de embedding?
- [ ] ¿Hay timeouts configurados para llamadas a LLMs?

### Costos
- [ ] ¿Se usa el modelo más económico viable?
- [ ] ¿Hay límites de tokens en los prompts?
- [ ] ¿El contexto recuperado se limita a lo necesario?
- [ ] ¿Hay alertas de costo configuradas?

### Observabilidad
- [ ] ¿Se loggean queries, respuestas y métricas?
- [ ] ¿Hay tracing para el pipeline completo?
- [ ] ¿Las métricas de calidad se monitorean?

Documentación para Proyectos de IA

Documentar Prompts

class PromptRegistry:
    """Registro centralizado de prompts con versionado."""

    RAG_SYSTEM_PROMPT = {
        "version": "2.1",
        "last_updated": "2026-03-15",
        "author": "ai-team",
        "description": "System prompt para el asistente RAG principal",
        "template": """Eres un asistente técnico. Responde SOLO con información 
del contexto proporcionado. Si no tienes información, di "No tengo datos suficientes".

Contexto: {context}
Pregunta: {question}""",
        "variables": ["context", "question"],
        "model": "gpt-4o",
        "temperature": 0,
        "eval_score": 0.92,  # Último score de evaluación
        "notes": "v2.1: Añadido 'di No tengo datos' para reducir alucinaciones",
    }

ADR: Architecture Decision Records

# ADR-007: Usar Qdrant como Vector Database Principal

## Estado: Aceptado

## Contexto
Necesitamos una vector database para el pipeline RAG que soporte
1M+ documentos con latencia <50ms p99.

## Opciones Consideradas
1. **Pinecone** — Serverless, fácil, $$$
2. **Qdrant** — Self-hosted, rápido, $$
3. **pgvector** — Ya usamos Postgres, $

## Decisión
Qdrant self-hosted en ECS.

## Razones
- Latencia p99 ~20ms (pgvector ~100ms)
- Filtrado avanzado necesario para multi-tenant
- Costo 60% menor que Pinecone a nuestra escala
- pgvector no escala bien más allá de 500K vectores

## Consecuencias
- Necesitamos operar el cluster (backups, upgrades)
- Equipo debe aprender API de Qdrant

Resumen

Testing y documentación en proyectos de IA requieren:

  1. Testing por capas — unit (determinístico), integration, evaluation (LLM-judge)
  2. Mocking inteligente — Simular LLMs y vector DBs para tests rápidos
  3. Code review con checklist — Seguridad, calidad, costos, observabilidad
  4. Documentación de prompts — Versionado, scores, notas de cambio
  5. ADRs — Decisiones arquitectónicas documentadas

🧠 Preguntas de Repaso

1. Según la pirámide de testing para proyectos de IA, ¿cuál es la distribución recomendada?

  • A) 33% unit, 33% integration, 33% E2E
  • B) 75% unit tests (determinísticos), 20% integration tests (RAG pipeline), 5% E2E tests (flujo completo)
  • C) 10% unit, 40% integration, 50% E2E
  • D) 90% unit tests, 10% E2E

Respuesta: B) — La pirámide es: 75% unit tests (chunking, parsing, formatting — rápidos y determinísticos), 20% integration (RAG pipeline, agent workflow — con mocks de LLM), 5% E2E (flujo completo query → response — lentos y costosos).

2. ¿Por qué se usa FakeEmbeddings(size=384) con ChromaDB en memoria para integration tests?

  • A) Para probar con embeddings reales pero más pequeños
  • B) Para simular la vector database sin llamar a APIs de embedding costosas, haciendo los tests rápidos y determinísticos
  • C) Porque ChromaDB solo acepta dimensiones de 384
  • D) Para medir la calidad de los embeddings

Respuesta: B) — Usar FakeEmbeddings con ChromaDB en memoria permite ejecutar integration tests del pipeline RAG sin llamar a APIs externas (OpenAI, Cohere), haciéndolos rápidos, gratuitos, determinísticos y ejecutables en CI/CD.

3. En el code review checklist para IA, ¿qué verificación de seguridad es específica para agentes que generan SQL?

  • A) Verificar que el SQL sea eficiente
  • B) Asegurar que el SQL generado sea read-only (solo SELECT), bloqueando DROP, DELETE, TRUNCATE y ALTER
  • C) Validar que el SQL use índices
  • D) Confirmar que el SQL funcione en PostgreSQL y MySQL

Respuesta: B) — Los agentes que generan SQL deben estar restringidos a operaciones read-only (SELECT). Se debe bloquear explícitamente DROP, DELETE, TRUNCATE y ALTER para prevenir que el agente modifique o destruya datos de la base de datos.

4. ¿Qué es un ADR (Architecture Decision Record) y por qué es útil en proyectos de IA?

  • A) Un log automático de errores del sistema
  • B) Un documento que registra decisiones arquitectónicas con contexto, opciones evaluadas, pros/cons y la decisión final — útil para recordar por qué se eligió una tecnología
  • C) Un tipo de test automatizado
  • D) Un formato de configuración de deployment

Respuesta: B) — Un ADR documenta decisiones como "¿Por qué Qdrant y no Pinecone?" con contexto, opciones evaluadas, pros/cons, y la decisión final. Es invaluable en proyectos de IA donde las decisiones técnicas (vector DB, modelo, chunking) tienen impacto significativo.

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes