La boucle d'agent minimale vue précédemment tient en une trentaine de lignes : un appel API, un dispatch de tools, une réinjection du résultat. Reste la question qu'on repousse rarement assez longtemps en pratique : comment on teste ça sans payer des tokens à chaque run, et sans que le test devienne flaky parce que le modèle a reformulé sa réponse autrement le jour où la CI tourne ? Le principe de base rejoint tester un appel LLM côté PHP — doublures plutôt qu'appels réels — mais un agent ajoute une dimension : ce n'est plus un aller-retour, c'est une boucle avec état.
Trois couches, trois stratégies de test
Un agent mélange du code déterministe (les tools, le dispatch, la boucle) et un composant qui ne l'est pas (le modèle). Les confondre dans une seule stratégie de test est l'erreur la plus commune : soit on mocke tout et on ne teste plus rien d'utile, soit on appelle la vraie API partout et la suite devient lente, chère et instable.
Base de la pyramide : les tools sont des fonctions pures
calculate et get_weather ne font pas d'appel réseau vers le modèle — ce sont des fonctions Python ordinaires, avec entrée et sortie déterministes. Elles se testent sans rien mocker :
import pytest
from agent import calculate
def test_calculate_addition():
assert calculate("3 + 5") == "8"
def test_calculate_priorite_operateurs():
assert calculate("2 + 3 * 4") == "14"
def test_calculate_division():
assert calculate("10 / 4") == "2.5"
def test_calculate_rejette_expression_non_supportee():
with pytest.raises(ValueError):
calculate("__import__('os').system('ls')")
Ce dernier test n'est pas cosmétique : c'est la vérification que le garde-fou contre eval() tient vraiment. Sans lui, un refactor qui réintroduit eval() par erreur passe inaperçu jusqu'en prod.
Milieu de la pyramide : la boucle sans réseau
Tester run_agent demande de simuler client.messages.create() — sans appeler l'API. Premier prérequis, souvent oublié : la fonction doit accepter le client en paramètre plutôt que d'aller chercher une variable globale. C'est de l'injection de dépendances, pas un pattern exotique :
# avant : client global, impossible à substituer proprement
client = anthropic.Anthropic()
def run_agent(question, max_turns=6): ...
# après : le client est un paramètre, avec une valeur par défaut pour l'usage normal
def run_agent(question, client=None, max_turns=6):
client = client or anthropic.Anthropic()
...
Avec ça, un test peut passer un client factice qui rejoue des réponses préparées à l'avance. Une dataclass suffit à représenter les blocs de réponse sans dépendre des classes internes du SDK :
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FakeBlock:
type: str
text: str = ""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
@dataclass
class FakeResponse:
content: list[FakeBlock]
stop_reason: str
class FakeClient:
"""Rejoue une liste de réponses dans l'ordre, un appel = une réponse."""
def __init__(self, responses: list[FakeResponse]):
self._responses = iter(responses)
self.calls: list[dict[str, Any]] = []
class _Messages:
def __init__(self, outer):
self._outer = outer
def create(self, **kwargs):
self._outer.calls.append(kwargs)
return next(self._outer._responses)
@property
def messages(self):
return self._Messages(self)
self.calls garde une trace de chaque appel : ça permet d'affirmer non seulement ce que run_agent retourne, mais aussi ce qu'il a envoyé au modèle — utile pour vérifier que l'historique messages s'accumule correctement entre les tours.
def test_run_agent_execute_un_tool_puis_repond():
responses = [
FakeResponse(
content=[FakeBlock(type="tool_use", id="t1", name="calculate",
input={"expression": "6 * 7"})],
stop_reason="tool_use",
),
FakeResponse(
content=[FakeBlock(type="text", text="Le résultat est 42.")],
stop_reason="end_turn",
),
]
client = FakeClient(responses)
resultat = run_agent("Combien font 6 * 7 ?", client=client)
assert resultat == "Le résultat est 42."
assert len(client.calls) == 2
# le deuxième appel doit contenir le tool_result du premier tour
deuxieme_appel_messages = client.calls[1]["messages"]
assert deuxieme_appel_messages[-1]["content"][0]["content"] == "42"
Ce test ne dit rien sur la qualité du modèle — il vérifie que la mécanique de la boucle (dispatch du tool, réinjection du résultat, arrêt sur end_turn) fonctionne, indépendamment de ce que le modèle décide réellement de faire. C'est exactement le même principe que les golden tests évoqués pour un appel LLM simple, appliqué à un état qui évolue sur plusieurs tours plutôt qu'à un aller-retour unique.
Cas à couvrir : le budget de tours et les erreurs de tool
Deux scénarios qu'on oublie facilement d'écrire, alors qu'ils protègent contre des régressions coûteuses en prod :
def test_run_agent_stoppe_au_budget_de_tours():
# le modèle demande toujours un tool, jamais de réponse finale
boucle_infinie = [
FakeResponse(
content=[FakeBlock(type="tool_use", id=f"t{i}", name="calculate",
input={"expression": "1 + 1"})],
stop_reason="tool_use",
)
for i in range(10)
]
client = FakeClient(boucle_infinie)
resultat = run_agent("question", client=client, max_turns=3)
assert "budget de tours dépassé" in resultat
assert len(client.calls) == 3
def test_run_agent_capture_une_erreur_de_tool():
responses = [
FakeResponse(
content=[FakeBlock(type="tool_use", id="t1", name="calculate",
input={"expression": "1 / 0"})],
stop_reason="tool_use",
),
FakeResponse(content=[FakeBlock(type="text", text="division impossible")],
stop_reason="end_turn"),
]
client = FakeClient(responses)
resultat = run_agent("question", client=client)
# le tool a levé une exception : elle doit être capturée et réinjectée,
# pas remonter et crasher toute la boucle
tool_result = client.calls[1]["messages"][-1]["content"][0]["content"]
assert tool_result.startswith("erreur:")
assert resultat == "division impossible"
Le premier vérifie que max_turns est un vrai plafond, pas une intention de commentaire. Le second vérifie qu'une exception dans un tool devient un message d'erreur réinjecté au modèle plutôt qu'un crash Python — un tool tiers mal écrit ou une entrée inattendue du modèle ne doivent jamais faire tomber l'agent entier.
Piège à éviter : mocker au mauvais niveau
Il est tentant de faire unittest.mock.patch("agent.run_agent") et d'affirmer que la fonction a été appelée avec les bons arguments. Ce test passe toujours, même si run_agent est cassée — il ne teste que le fait qu'on l'a appelée, pas ce qu'elle fait. La substitution doit se faire à la frontière du système (le client API), pas sur la fonction qu'on est en train de tester. C'est la même logique que pour un mock de repository en base de données : on mocke la dépendance externe, jamais l'objet sous test.
| Niveau testé | Ce qu'on substitue | Ce que le test prouve |
|---|---|---|
| Tool isolé | rien | La logique métier (calcul, formatage) est correcte |
Boucle run_agent | le client API (FakeClient) | Le dispatch, l'accumulation des messages et l'arrêt sont corrects |
run_agent elle-même | (à ne pas faire) | Rien — le test décrit l'implémentation, pas le comportement |
Fixture pytest pour factoriser
Dès que plusieurs tests construisent le même type de FakeClient, une fixture évite la répétition et centralise le point de changement si FakeResponse évolue :
@pytest.fixture
def make_client():
def _make(*responses: FakeResponse) -> FakeClient:
return FakeClient(list(responses))
return _make
def test_avec_fixture(make_client):
client = make_client(
FakeResponse(content=[FakeBlock(type="text", text="ok")], stop_reason="end_turn"),
)
assert run_agent("salut", client=client) == "ok"
Ce qu'on ne teste pas ici, volontairement : si le modèle choisit le bon tool pour une question donnée. Cette partie-là relève de l'évaluation (jeux de questions annotées, comparaison de sorties), pas du test unitaire — au même titre que la observabilité d'un agent répond à « qu'est-ce qui s'est passé en prod » plutôt qu'à « est-ce que le code est correct ».
FAQ
Faut-il mocker le SDK Anthropic directement avec unittest.mock ?
unittest.mock.Mock(spec=anthropic.Anthropic) fonctionne aussi et évite d'écrire les dataclasses FakeBlock/FakeResponse. L'inconvénient : un mock générique ne vérifie pas la forme des objets renvoyés par le vrai SDK, alors qu'une classe factice explicite documente cette forme et casse de façon lisible si le SDK change son schéma de réponse.
Pourquoi ne pas simplement appeler la vraie API dans les tests ?
Coût en tokens à chaque run, latence réseau qui ralentit la CI, et non-déterminisme : le modèle peut reformuler sa réponse texte différemment d'un run à l'autre sans que ce soit un bug. Les tests de boucle doivent vérifier la mécanique, pas le contenu généré.
Comment tester que le bon tool est choisi pour une question donnée ?
Ce n'est pas un test unitaire mais une évaluation : un jeu de questions avec le tool attendu en référence, exécuté avec le vrai modèle, mesuré en taux de réussite plutôt qu'en assertion binaire pass/fail.