Unit tests para agentes de IA (deterministas, baratos, realmente útiles)

Los unit tests para agentes de IA atrapan tool spam, bugs de budgets y regresiones de stop_reason antes de producción. Ejemplos Python + JS.
En esta página
  1. Problema (lo primero que se rompe)
  2. Por qué esto falla en producción
  3. Diagrama: cómo se ve un agente unit‑testeable
  4. Código real: un loop testeable (Python + JS)
  5. Fallo real (del que duele)
  6. Compromisos
  7. Cuándo NO usar esto
  8. Checklist (copiar/pegar)
  9. Config segura por defecto (YAML)
  10. FAQ (3–5)
  11. Páginas relacionadas (3–6 links)

Problema (lo primero que se rompe)

En dev, tu agente “funciona”.

Luego cambias algo aburrido:

  • una clave del schema del tool
  • defaults de retry/backoff
  • la condición de parada
  • la versión del modelo

Y de repente producción se ve así:

  • 3× más tool calls
  • 2× más coste de un día a otro
  • runs que nunca llegan a finish y acaban en “stop: budget”

Si no puedes reproducir un run de forma determinista, no tienes un bug — tienes arqueología.

Por qué esto falla en producción

Los agentes fallan distinto al software normal, porque están dirigidos por:

  • un planner probabilista (el modelo)
  • un loop runtime (tu orquestación)
  • side effects (tools)
  • inestabilidad externa (429/5xx, respuestas parciales, timeouts)

Muchos equipos “testean” el prompt. No alcanza. Hay que testear el contrato del loop:

  • input → actions → tool calls → trace → stop_reason

Si stop reasons y traces no son estables, nada más lo es.

Diagrama: cómo se ve un agente unit‑testeable

Código real: un loop testeable (Python + JS)

El truco es aburrido: inyección de dependencias. Tu loop acepta dos cosas que no controla:

  • llm.next_action(...)
  • tools.call(...)

Todo lo demás debería ser determinista y comprobable.

PYTHON
from dataclasses import dataclass
from typing import Any, Dict, List, Protocol


@dataclass(frozen=True)
class Budget:
  max_steps: int = 10
  max_tool_calls: int = 10


class LLM(Protocol):
  def next_action(self, state: Dict[str, Any]) -> Dict[str, Any]: ...


class Tools(Protocol):
  def call(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: ...


def run_agent(task: str, *, llm: LLM, tools: Tools, budget: Budget) -> Dict[str, Any]:
  trace: List[Dict[str, Any]] = []
  tool_calls = 0
  state: Dict[str, Any] = {"task": task, "notes": []}

  for step in range(budget.max_steps):
      action = llm.next_action(state)
      trace.append({"step": step, "action": action})

      if action.get("type") == "finish":
          return {"output": action.get("answer", ""), "trace": trace, "stop_reason": "finish"}

      if action.get("type") != "tool":
          return {"output": "", "trace": trace, "stop_reason": "invalid_action"}

      tool_calls += 1
      if tool_calls > budget.max_tool_calls:
          return {"output": "", "trace": trace, "stop_reason": "max_tool_calls"}

      obs = tools.call(action["tool"], action.get("args", {}))
      trace.append({"step": step, "observation": obs, "tool": action["tool"]})
      state["notes"].append(obs)

  return {"output": "", "trace": trace, "stop_reason": "max_steps"}


# --- unit test (pytest style) ---
class FakeLLM:
  def __init__(self):
      self.n = 0

  def next_action(self, state):
      self.n += 1
      if self.n == 1:
          return {"type": "tool", "tool": "http.get", "args": {"url": "https://example.com"}}
      return {"type": "finish", "answer": "ok"}


class FakeTools:
  def __init__(self):
      self.calls = []

  def call(self, name, args):
      self.calls.append((name, args))
      return {"ok": True, "status": 200, "body": "hello"}


def test_unit_loop_contract():
  out = run_agent(
      "fetch once and finish",
      llm=FakeLLM(),
      tools=FakeTools(),
      budget=Budget(max_steps=5, max_tool_calls=3),
  )

  assert out["stop_reason"] == "finish"
  assert len(out["trace"]) >= 2
JAVASCRIPT
export function runAgent(task, { llm, tools, budget }) {
const trace = [];
let toolCalls = 0;
const state = { task, notes: [] };

for (let step = 0; step < budget.maxSteps; step++) {
  const action = llm.nextAction(state);
  trace.push({ step, action });

  if (action?.type === "finish") {
    return { output: action.answer ?? "", trace, stop_reason: "finish" };
  }

  if (action?.type !== "tool") {
    return { output: "", trace, stop_reason: "invalid_action" };
  }

  toolCalls += 1;
  if (toolCalls > budget.maxToolCalls) {
    return { output: "", trace, stop_reason: "max_tool_calls" };
  }

  const obs = tools.call(action.tool, action.args || {});
  trace.push({ step, tool: action.tool, observation: obs });
  state.notes.push(obs);
}

return { output: "", trace, stop_reason: "max_steps" };
}

// --- unit test (jest style) ---
test("unit loop contract", () => {
const llm = {
  n: 0,
  nextAction() {
    this.n += 1;
    if (this.n === 1) return { type: "tool", tool: "http.get", args: { url: "https://example.com" } };
    return { type: "finish", answer: "ok" };
  },
};
const tools = { calls: [], call(name, args) { this.calls.push([name, args]); return { ok: true, status: 200 }; } };

const out = runAgent("fetch once and finish", {
  llm,
  tools,
  budget: { maxSteps: 5, maxToolCalls: 3 },
});

expect(out.stop_reason).toBe("finish");
expect(tools.calls.length).toBe(1);
});

Fallo real (del que duele)

Una vez cambiamos un default de retries en un wrapper de tool compartido. Nada “se cayó”.

Pero en una ruta con tráfico:

  • tool calls/run: 8 → 16
  • impacto en coste: +~$900/día (tokens + créditos de tools)
  • on‑call: ~3 horas para demostrar que no era “el modelo raro”

El fix no fue un prompt mejor. El fix fue un unit test que verifica:

  • un bound sobre tool calls/run
  • que la taxonomía de stop reasons no drifte
  • que el gateway de tools no reintenta dos veces (agent retry + tool retry = tormenta)

Compromisos

  • Los unit tests no prueban que el modelo sea “smart”. Prueban que tu loop es seguro.
  • Los stubs deterministas esconden flakiness real (para eso están los replay tests).
  • Vas a escribir más código aburrido. Vas a recibir menos pagers.

Cuándo NO usar esto

No intentes unit testear “calidad de prompt” como si fuera una función determinista. Si el objetivo es estilo/tono: sampling + evals.

Sí unit‑testea:

  • budgets
  • allowlists de tools
  • stop reasons
  • validación de schema de action
  • comportamiento de idempotencia

Checklist (copiar/pegar)

  • [ ] Inyecta llm y tools como interfaces (sin globals).
  • [ ] Haz asserts sobre stop_reason en cada test.
  • [ ] Haz asserts sobre tool calls: cantidad + orden + hash de args (no args crudos si son sensibles).
  • [ ] Testea “bad paths”: acción inválida, error de tool, stop por budget.
  • [ ] Un golden test por incidente de producción (sí).

Config segura por defecto (YAML)

YAML
agent_tests:
  budgets:
    max_steps: 25
    max_tool_calls: 12
  invariants:
    stop_reason_required: true
    action_schema_strict: true
    tool_allowlist_required: true
  golden_tasks:
    - id: "fetch_once"
      task: "Fetch https://example.com and summarize in 3 bullets."
      expect_stop_reason: "finish"
      max_tool_calls: 2
  replay:
    enabled: true
    mode: "record_then_replay"
    store: ".agent-replays/"

FAQ (3–5)

¿Esto no es solo mockear el modelo?
Sí — a propósito. Los unit tests son para tu contrato de loop: budgets, gateway de tools, stop reasons y forma de la traza.
¿Qué debería comprobar?
Stop reason, número de tool calls, decisiones de allowlist y forma de la traza. No el texto exacto de salida.
¿Cómo testeo flakiness de tools?
Record/replay de fixtures (o integration tests en sandbox). Los unit tests deben ser deterministas.
¿Necesito evals si ya tengo unit tests?
Sí. Los unit tests previenen incidentes. Los evals detectan drift de calidad. Son fallos distintos.

Q: ¿Esto no es solo mockear el modelo?
A: Sí — a propósito. Los unit tests son para tu contrato de loop: budgets, gateway de tools, stop reasons y forma de la traza.

Q: ¿Qué debería comprobar?
A: Stop reason, número de tool calls, decisiones de allowlist y forma de la traza. No el texto exacto de salida.

Q: ¿Cómo testeo flakiness de tools?
A: Record/replay de fixtures (o integration tests en sandbox). Los unit tests deben ser deterministas.

Q: ¿Necesito evals si ya tengo unit tests?
A: Sí. Los unit tests previenen incidentes. Los evals detectan drift de calidad. Son fallos distintos.

⏱️ 7 min de lecturaActualizado Mar, 2026Dificultad: ★★☆
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Permisos de herramientas (allowlist / blocklist)
  • Kill switch y parada por incidente
  • Idempotencia y dedupe
  • Audit logs y trazabilidad
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.