Unit tests pour agents IA (déterministes, cheap, vraiment utiles)

Les unit tests pour agents IA attrapent le tool spam, les budgets buggés et les régressions de stop_reason avant la prod. Exemples Python + JS.
Sur cette page
  1. Problème (ce qui casse en premier)
  2. Pourquoi ça casse en prod
  3. Diagramme : à quoi ressemble un agent testable en unit
  4. Code réel : une boucle testable (Python + JS)
  5. Incident réel (celui qui fait mal)
  6. Compromis
  7. Quand NE PAS faire ça
  8. Checklist (copier-coller)
  9. Config safe par défaut (YAML)
  10. FAQ (3–5)
  11. Pages liées (3–6 liens)

Problème (ce qui casse en premier)

En dev, ton agent “marche”.

Puis tu changes un truc boring :

  • une clé de schéma de tool
  • des defaults de retry/backoff
  • une condition d’arrêt
  • la version du modèle

Et d’un coup la prod ressemble à :

  • 3× plus de tool calls
  • 2× plus de coût du jour au lendemain
  • des runs qui ne font jamais finish et finissent en “stop: budget”

Si tu ne peux pas reproduire un run de façon déterministe, tu n’as pas un bug — tu fais de l’archéologie.

Pourquoi ça casse en prod

Un agent casse différemment du code normal, parce qu’il est piloté par :

  • un planner probabiliste (le modèle)
  • une boucle runtime (ton orchestration)
  • des side effects (tools)
  • de l’instabilité externe (429/5xx, réponses partielles, timeouts)

Beaucoup d’équipes “testent” le prompt. Pas suffisant. Il faut tester le contrat de la boucle :

  • input → actions → tool calls → trace → stop_reason

Si stop reasons et traces ne sont pas stables, rien ne l’est.

Diagramme : à quoi ressemble un agent testable en unit

Code réel : une boucle testable (Python + JS)

Le hack est boring : injection de dépendances. La boucle accepte deux trucs qu’elle ne contrôle pas :

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

Le reste doit être déterministe et asserté.

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);
});

Incident réel (celui qui fait mal)

On a déjà changé un default de retry dans un wrapper de tool partagé. Rien n’a “crash”.

Mais sur une route chargée :

  • tool calls/run : 8 → 16
  • impact coût : +~$900/jour (tokens + crédits tools)
  • astreinte : ~3 heures à prouver que ce n’était pas “le modèle bizarre”

Le fix n’était pas un meilleur prompt. Le fix, c’était un unit test qui vérifie :

  • un bound sur le nombre de tool calls/run
  • pas de drift de la taxonomie des stop reasons
  • pas de double retry (agent retry + tool retry = storm)

Compromis

  • Les unit tests ne prouvent pas que le modèle est “smart”. Ils prouvent que ta boucle est safe.
  • Les stubs déterministes cachent la flakiness des tools (c’est pour ça qu’on fait du replay).
  • Tu vas écrire plus de code boring. Tu vas aussi pager moins.

Quand NE PAS faire ça

Ne “unit test” pas la qualité d’un prompt comme si c’était une fonction déterministe. Si le but est style/ton : sampling + evals.

Unit‑teste :

  • budgets
  • allowlists de tools
  • stop reasons
  • validation du schéma d’action
  • comportement d’idempotence

Checklist (copier-coller)

  • [ ] Injecter llm et tools via interfaces (pas de globals).
  • [ ] Assert sur stop_reason dans chaque test.
  • [ ] Assert sur les tool calls : count + ordre + hash des args (pas les args bruts si sensibles).
  • [ ] Tester les “bad paths” : action invalide, tool error, arrêt budget.
  • [ ] Un golden test par incident prod (oui).

Config safe par défaut (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)

Ce n’est pas juste du mocking du modèle ?
Si — volontairement. Les unit tests servent à tester le contrat de ta boucle : budgets, gateway tools, stop reasons, forme de la trace.
Sur quoi faut-il faire des asserts ?
Stop reason, nombre d’appels tools, décisions d’allowlist, shape de la trace. Pas le texte exact de sortie.
Comment tester la flakiness des tools ?
Record/replay de fixtures (ou tests d’intégration en sandbox). Les unit tests doivent rester déterministes.
J’ai des unit tests : je dois quand même faire des evals ?
Oui. Les unit tests évitent des incidents. Les evals détectent la dérive de qualité. Deux classes de failures différentes.

Q: Ce n’est pas juste du mocking du modèle ?
A: Si — volontairement. Les unit tests servent à tester le contrat de ta boucle : budgets, gateway tools, stop reasons, forme de la trace.

Q: Sur quoi faut-il faire des asserts ?
A: Stop reason, nombre d’appels tools, décisions d’allowlist, shape de la trace. Pas le texte exact de sortie.

Q: Comment tester la flakiness des tools ?
A: Record/replay de fixtures (ou tests d’intégration en sandbox). Les unit tests doivent rester déterministes.

Q: J’ai des unit tests : je dois quand même faire des evals ?
A: Oui. Les unit tests évitent des incidents. Les evals détectent la dérive de qualité. Deux classes de failures différentes.

Pages liées (3–6 liens)

⏱️ 7 min de lectureMis à jour Mars, 2026Difficulté: ★★☆
Intégré : contrôle en productionOnceOnly
Ajoutez des garde-fous aux agents tool-calling
Livrez ce pattern avec de la gouvernance :
  • Budgets (steps / plafonds de coût)
  • Permissions outils (allowlist / blocklist)
  • Kill switch & arrêt incident
  • Idempotence & déduplication
  • Audit logs & traçabilité
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
Auteur

Cette documentation est organisée et maintenue par des ingénieurs qui déploient des agents IA en production.

Le contenu est assisté par l’IA, avec une responsabilité éditoriale humaine quant à l’exactitude, la clarté et la pertinence en production.

Les patterns et recommandations s’appuient sur des post-mortems, des modes de défaillance et des incidents opérationnels dans des systèmes déployés, notamment lors du développement et de l’exploitation d’une infrastructure de gouvernance pour les agents chez OnceOnly.