Tu as construit un agent IA — un programme qui utilise un LLM (large language model — le cerveau derrière ChatGPT, Claude, Gemini) pour appeler des outils de manière autonome. Il interroge des bases de données, vérifie des permissions, envoie des emails. Dans ton terminal, ça marche à tous les coups. Puis tu ouvres le dossier tests/ et tu réalises : c'est vide. Pas parce que t'es un flemmard, mais parce que ton SDK (software development kit — la boîte à outils fournie par le framework) est livré avec exactement zéro utilitaire de test.
Bienvenue dans le développement d'agents en avril 2026.
Le problème (en bref)
On a couvert le paysage des SDK en détail dans l'article compagnon. La version courte : Anthropic livre zéro utilitaire de test, OpenAI refuse d'exporter leur FakeModel interne, et l'évaluateur de Google ADK a un bug ouvert qui renvoie 0.0 sur des correspondances correctes. Personne ne te donne les outils pour tester tes agents.
La réaction logique : « Je vais juste faire un assert sur la sortie texte de l'agent. » Non. Un LLM reformule sa réponse à chaque exécution. La température (un paramètre qui contrôle le caractère aléatoire de la sortie du modèle) à zéro ne te sauvera pas — versions de modèle différentes, jours différents, formulation différente. Tester du texte, c'est tester un pile ou face.
Ce qui ne change pas ? La séquence d'appels d'outils. Quand ton agent traite « vérifie si l'utilisateur peut envoyer un email », il appelle lookup_user → check_permissions → send_email à chaque fois, quelle que soit la façon dont il formule sa réponse. Le pattern d'action, c'est le contrat. Teste ça.
Ce guide te donne un harnais de test fonctionnel en ~60 lignes. Mocks, fixtures golden, séparation CI — tout ce que les SDK auraient dû livrer mais n'ont pas livré.
La recette : tests comportementaux en ~60 lignes
On va construire un harnais de test — un petit framework qui surveille ton agent — qui :
- Mocke le LLM pour que les tests s'exécutent en millisecondes à coût zéro
- Enregistre quels outils l'agent appelle et dans quel ordre
- Fait des assertions sur la séquence, pas sur les mots
Étape 1 : Choisis ta stratégie de mock
Deux voies. Les tests mock-model remplacent le LLM par un répondeur scripté — instantané, gratuit, déterministe. Les smoke tests real-model appellent la vraie API et enregistrent les réponses — réalistes mais chers et fragiles. Tu as besoin des deux. Les mocks dans la CI (chaque pull request), le real-model en cron nocturne.
Pour le mocking, Pydantic AI a les meilleures primitives : TestModel (appelle automatiquement chaque outil de l'agent) et FunctionModel (tu scriptes la réponse exacte). Si tu n'utilises pas Pydantic AI, on va construire la même chose from scratch.
Étape 2 : Construis l'intercepteur
Voici une approche pure pytest — aucune dépendance framework, compatible avec n'importe quel SDK :
# test_agent_tools.py
import json
from dataclasses import dataclass, field
@dataclass
class ToolCallRecord:
"""Un appel d'outil enregistré."""
name: str
arguments: dict
order: int
@dataclass
class AgentRecorder:
"""Intercepte et enregistre chaque appel d'outil."""
calls: list[ToolCallRecord] = field(default_factory=list)
def record(self, tool_name: str, arguments: dict):
self.calls.append(ToolCallRecord(
name=tool_name,
arguments=arguments,
order=len(self.calls),
))
@property
def sequence(self) -> list[str]:
"""Juste les noms d'outils, dans l'ordre."""
return [c.name for c in self.calls]
def assert_sequence(self, expected: list[str]):
assert self.sequence == expected, (
f"Expected {expected}, got {self.sequence}"
)
def assert_called_with(self, tool_name: str, **expected_args):
match = [c for c in self.calls if c.name == tool_name]
assert match, f"{tool_name} was never called"
assert match[0].arguments == expected_args
35 lignes. Le recorder capture chaque invocation d'outil et expose deux assertions : assert_sequence (bons outils, bon ordre ?) et assert_called_with (bons arguments ?).
Étape 3 : Branche-le sur ton agent
Wrappe tes fonctions d'outils pour que le recorder voie chaque appel :
def make_tracked_tool(original_fn, recorder: AgentRecorder):
"""Wrappe une fonction outil pour enregistrer les appels avant exécution."""
async def tracked(*args, **kwargs):
recorder.record(original_fn.__name__, kwargs)
return await original_fn(*args, **kwargs)
return tracked
faion est un assistant CLI qui génère du scaffolding de code à partir de prompts structurés — colle le bloc ci-dessous et il produira le recorder et le wrapper adaptés à ton SDK.
/faion
Generate an AgentRecorder class and make_tracked_tool wrapper for behavioral testing of an AI agent.
Requirements:
- Pure Python, no framework dependency, dataclass-based
- AgentRecorder must record tool name, arguments, and call order
- Must expose assert_sequence(expected_names) and assert_called_with(tool_name, **kwargs)
- make_tracked_tool wraps any async tool function to record calls before executing
- Detect my agent SDK (anthropic / openai-agents / google-adk / pydantic-ai) from imports and adapt the wrapper signature accordingly
- Include a pytest fixture that returns a fresh AgentRecorder
Étape 4 : Écris ton premier test comportemental
import pytest
@pytest.fixture
def recorder():
return AgentRecorder()
@pytest.mark.asyncio
async def test_email_permission_flow(recorder, mock_agent):
"""L'agent DOIT vérifier les permissions avant d'envoyer un email."""
await mock_agent.run("Send the quarterly report to [email protected]")
# LE CONTRAT : toujours vérifier avant d'envoyer
recorder.assert_sequence([
"lookup_user",
"check_permissions",
"send_email",
])
# Vérifie que l'agent a cherché le bon utilisateur
recorder.assert_called_with(
"lookup_user", email="[email protected]"
)
Ce test se fiche de ce que l'agent dit. Il se soucie de ce que l'agent fait. Le texte peut changer à chaque exécution. La vérification des permissions, non.
Étape 5 : Teste les chemins de récupération d'erreur
L'article d'actualité couvrait la thèse du « happy path ». Les guides prouvent leur valeur sur les chemins sales — ce qui se passe quand un outil plante, quand le modèle retente, quand il hallucine un outil qui n'existe pas.
@pytest.mark.asyncio
async def test_graceful_degradation_on_tool_failure(recorder, mock_agent):
"""Quand send_email échoue, l'agent NE DOIT PAS retenter sans revérifier les permissions."""
mock_agent.configure_tool_failure("send_email", error="SMTP timeout")
await mock_agent.run("Send the quarterly report to [email protected]")
recorder.assert_sequence([
"lookup_user",
"check_permissions",
"send_email", # échoue
"check_permissions", # doit revérifier avant de retenter
"send_email", # retry
])
@pytest.mark.asyncio
async def test_unknown_tool_call_is_caught(recorder, mock_agent):
"""Si le modèle hallucine un nom d'outil, le harnais le détecte."""
mock_agent.inject_fake_tool_call("send_fax") # n'existe pas
with pytest.raises(UnknownToolError):
await mock_agent.run("Fax the report")
# L'agent ne devrait avoir rien appelé avec succès
assert recorder.sequence == []
La récupération d'erreur, c'est là où les agents cassent silencieusement en production. Un mock qui simule des échecs d'outils ne coûte rien et attrape les pannes que tes tests happy-path ne verront jamais.
/faion
Scaffold a full behavioral test suite for my AI agent project.
Requirements:
- Scan my agent's registered tools and generate one test per tool
- For each tool: happy-path test + error-recovery test (tool raises exception)
- Generate a mock agent fixture that uses scripted responses instead of real LLM calls
- Include conftest.py with AgentRecorder fixture and make_tracked_tool helper
- Add golden fixture save/load utilities (JSON snapshots in tests/fixtures/)
- Mark real-model tests with @pytest.mark.smoke
- Detect my SDK from imports and adapt accordingly
Étape 6 : Ajoute des fixtures golden pour détecter les régressions
Une fixture golden est un instantané du comportement attendu que tu stockes dans le contrôle de version — ta baseline bénie. Sauvegarde la sortie de ton recorder en JSON :
# conftest.py
import json
from pathlib import Path
FIXTURE_DIR = Path("tests/fixtures")
def save_golden(recorder: AgentRecorder, name: str):
"""Sauvegarde la séquence d'appels actuelle comme baseline de référence."""
fixture = {
"sequence": recorder.sequence,
"calls": [
{"name": c.name, "arguments": c.arguments}
for c in recorder.calls
],
}
(FIXTURE_DIR / f"{name}.json").write_text(json.dumps(fixture, indent=2))
def assert_matches_golden(recorder: AgentRecorder, name: str):
"""Vérifie que le comportement actuel correspond à la baseline sauvegardée."""
fixture = json.loads((FIXTURE_DIR / f"{name}.json").read_text())
assert recorder.sequence == fixture["sequence"], (
f"Dérive comportementale détectée !\n"
f" Golden: {fixture['sequence']}\n"
f" Actual: {recorder.sequence}"
)
Quand tu upgrades ton modèle de Claude Sonnet 4 vers ce qui sortira ensuite, lance la suite. Si l'agent commence à sauter check_permissions, tu le sauras avant tes utilisateurs.
Étape 7 : Intégration CI
Ta config CI se divise en deux jobs. La pyramide de tests de Block Engineering résume parfaitement le principe : « On ne lance pas de tests LLM live en CI. C'est trop cher, trop lent, et trop fragile. »
# .github/workflows/agent-tests.yml
name: Agent Tests
on: [pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/agents/ -m "not smoke" --tb=short
# .github/workflows/agent-smoke.yml
name: Agent Smoke Tests (Nightly)
on:
schedule:
- cron: '0 6 * * *' # 2h du matin heure de Paris
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/agents/ -m smoke --tb=long
Marque les tests real-model avec @pytest.mark.smoke. Ils tournent la nuit, alertent sur les dérives, ne bloquent jamais une PR.
/faion
Generate GitHub Actions CI config for agent behavioral tests.
Requirements:
- Two workflow files: agent-tests.yml (on PR) and agent-smoke.yml (nightly cron at 2am ET)
- PR workflow runs pytest with -m "not smoke", fails fast
- Nightly workflow runs pytest with -m smoke, uses secrets for API keys
- Add a third workflow: agent-golden-update.yml (manual trigger) that runs tests with --update-golden flag and opens a PR with fixture diffs
- Include pip caching and Python 3.12 setup
Les pièges
1. Anthropic dit de ne pas faire ça. Leur blog d'ingénierie (9 janvier 2026) avertit explicitement : « Vérifier que les agents ont suivi des étapes très spécifiques comme une séquence d'appels d'outils produit des tests trop fragiles. » Ils ont raison — pour les flux exploratoires multi-étapes où plusieurs chemins valides existent. Ils ont tort pour les chemins métier critiques où sauter une étape signifie une faille de sécurité. Utilise les assertions de séquence pour les contrats (vérifications de permissions, validation de données), les assertions de résultat pour tout le reste.
2. Les fixtures golden deviennent obsolètes. Tu upgrades ton modèle et chaque fixture casse — pas parce que l'agent a tort, mais parce qu'il a trouvé un chemin légitimement meilleur. Solution : revois les diffs comme des code reviews. Si la nouvelle séquence est valide, mets la fixture à jour. Sinon, tu viens de détecter une régression.
3. Les tests mock te mentent. Un FakeModel qui appelle toujours check_permissions te dit que ta logique de routage fonctionne. Il ne te dit rien sur le fait que Claude ou GPT invoquera réellement cet outil. Les tests mock couvrent environ 70% de la surface testable — routage, parsing d'arguments, gestion d'erreurs. Les 30% restants nécessitent des smoke tests real-model en planning nocturne.
4. Le non-déterminisme est structurel, pas un bug. Même avec la même entrée, un modèle peut appeler les outils dans un ordre différent d'une exécution à l'autre. Pour les chemins non critiques, utilise AgentEvals avec le mode de trajectoire unordered — il vérifie l'ensemble des outils appelés, pas la séquence. Réserve l'ordonnancement strict aux chemins où l'ordre EST le contrat.
5. Teste les handoffs multi-agents séparément. Si ton agent délègue à des sous-agents, chaque handoff est une frontière. Enregistre l'appel de délégation comme un appel d'outil (delegate_to_research_agent), puis teste la séquence d'outils du sous-agent indépendamment. Traverser les frontières d'agents dans une seule assertion rend tout fragile et rien n'est debuggable.
Ce que tu peux faire maintenant
Tu as un dossier tests/agents/ avec des tests comportementaux, des fixtures golden dans le contrôle de version, et une CI découpée qui lance des mocks rapides sur chaque PR et des smoke tests real-model la nuit. Ton agent est livré avec la même confiance que le reste de ta codebase — non pas parce qu'il est devenu déterministe, mais parce que tu as arrêté de tester la mauvaise chose. Le texte change à chaque exécution. Les actions, non.





