Guía9 min

Tests de tools de agentes sin LLM: Vitest primero, evals después

Resumen

Si la tool de refund está mal, el modelo no la arregla. Esta guía separa tests deterministas de las funciones que el agente llama (Vitest, fixtures, nock) de los evals del loop. El LLM entra cuando las tools ya tienen verde. Menos tokens, fallos más baratos.

OpenAI
Una tool de agente bajo un test unitario, el modelo fuera del recuadro

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un eval de agente que “a veces pasa” suele estar midiendo la tool rota, no el modelo. Refund duplicado, path traversal, JSON mal parseado: eso se ve en Vitest en 50 ms, no en un judge de 2 USD.

Los evals de producto viven en evaluar si el agente funciona. Aquí el contrato es más vago y más útil: cada tool es una función pura+I/O que se testea sin fetch al LLM. El curso instalar un agente no sustituye esto.

Por qué el LLM no es el test runner

El modelo es no determinista. Si createTicket({title}) no valida title, un eval “bueno” igual abre tickets vacíos. Fijas el bug en la función; el eval deja de ser un detector de typos.

Pirámide perezosa:

  1. Unit de tools (Vitest).
  2. Contrato del schema (JSON schema / zod) contra fixtures.
  3. Un eval chico del loop cuando 1 y 2 están verdes.

Pirámide: tools, schema, luego eval

Cómo se ve un test de tool

Exporta la función que el harness registra. No testees el SDK del vendor: testea tu wrapper.

import { describe, expect, it } from "vitest";
import { refundOrder } from "./tools/refund";

describe("refundOrder", () => {
  it("rechaza amount <= 0", async () => {
    await expect(refundOrder({ orderId: "o1", amount: 0 }))
      .rejects.toThrow(/amount/);
  });

  it("es idempotente con la misma key", async () => {
    const a = await refundOrder({ orderId: "o1", amount: 10, key: "k1" });
    const b = await refundOrder({ orderId: "o1", amount: 10, key: "k1" });
    expect(a.id).toBe(b.id);
  });
});

pnpm test (Vitest) ya es el runner de este repo. No instales Jest. Mocks: vi.mock del HTTP de Stripe, no del cerebro.

Tres casos mínimos por tool:

  • Input ilegal → error, sin side effect.
  • Input feliz → un side effect (assert del mock).
  • Retry / idempotencia si escribe.

Lo que no mockeas

No mockees el modelo “para que el agente elija refund”. Eso es un eval. Si quieres un loop de 20 líneas, inyecta un script de tool calls ([{name, args}]) y corre el executor. El executor es determinista; el modelo no.

await runTools([{ name: "refundOrder", args: { orderId: "o1", amount: 10 } }]);

Así cazas “la tool no está registrada” y “el schema no parsea” sin OpenAI.

Executor de tools con una lista fija de calls

Checklist

  • Cada tool en un archivo importable, no cerrada dentro del prompt.
  • Vitest (o node --test) en CI del PR del agente.
  • Fixtures de args: copiar el JSON que el modelo ya mandó en prod (anonimizado).
  • Cero red al LLM en pnpm test.
  • Paths: la tool de fs no sale del worktree (sandbox).
  • Evals: solo el happy-path del producto, 5–20 casos, no 200.

FAQ

¿Snapshot del system prompt? Frágil. Prefiere un test de “el prompt incluye la lista de tools”.

¿Browser tests? Cuando la tool es UI. Vitest Browser Mode existe; YAGNI hasta que un bug de DOM te pague el costo.

¿El eval sustituye esto? No. El eval mide política (“¿refundió de más?”). El unit mide mecánica (“¿amount 0 tira?”).

¿Tools en n8n? Extrae la lógica a una función y testéala. El canvas no es un runner.

Si el harness solo deja “tools como JSON en el system prompt”, igual extrae el handler a src/tools/*.ts. El JSON es el schema; el test pega el handler. Un agente que no puede importar su propia tool no es un agente de producción: es un chat con side effects.

Verificado 2026-09-03 contra Vitest Getting Started y Mocking. Runner del repo: pnpm test / Vitest 4.