Tester sans clé avec un faux modèle
Pourquoi un faux modèle ?
Un vrai modèle est lent, payant et non déterministe (la même question ne donne pas toujours la même réponse). Impossible d'en faire la base d'une intégration continue. Ce qu'on veut vérifier en CI, ce n'est pas la qualité du texte (c'est le rôle de l'évaluation, leçon suivante) mais la plomberie : les agents s'enchaînent, l'état circule, la boucle s'arrête, les garde-fous se déclenchent.
flowchart LR
subgraph Prod["Production"]
P1["construire_pipeline()"] --> G["Gemini (réseau, payant)"]
end
subgraph Test["Tests pytest"]
P2["construire_pipeline(FauxModele())"] --> F["FauxModele (BaseLlm, local)"]
end
La seule condition : que le modèle soit injectable. C'est pourquoi construire_pipeline(model=None) accepte un modèle en paramètre, et que LlmAgent(model=...) accepte aussi bien un nom ("gemini-3.5-flash") qu'un objet BaseLlm.
| Ce qu'on teste | Comment | Réseau ? |
|---|---|---|
| Règles de score, masquage, contrôles | appel direct des fonctions pures | non |
| Callbacks | appel direct avec un faux contexte (SimpleNamespace) | non |
| Enchaînement, état, boucle, arrêt | pipeline complet + FauxModele | non |
| Qualité des rapports | adk eval + vrai modèle | oui |
Le faux modèle
Il suffit d'implémenter generate_content_async(llm_request, stream=False), un générateur asynchrone qui produit des LlmResponse. Le nôtre lit la requête pour savoir quel agent l'appelle (d'après les outils disponibles et l'instruction) :
class FauxModele(BaseLlm):
"""Imite Gemini : appelle l'outil disponible, refuse la 1re version du rapport, approuve la 2e."""
model: str = "faux-modele"
relectures: int = 0
appels: int = 0
async def generate_content_async(self, llm_request, stream=False):
self.appels += 1
demande = llm_request.contents[0].parts[0].text
fournisseur = re.search(r"fournisseur : (.+)$", demande).group(1)
outils = list(llm_request.tools_dict)
dernier = llm_request.contents[-1]
if any(p.function_response for p in dernier.parts or []):
yield _reponse(types.Part(text="- résumé factuel de l'outil"))
elif outils and outils[0].startswith("consulter_"):
yield _reponse(types.Part(function_call=types.FunctionCall(
name=outils[0], args={"fournisseur": fournisseur})))
elif "approuver_rapport" in outils:
self.relectures += 1
if self.relectures == 1:
yield _reponse(types.Part(text="1. Ajouter la section ## Plan d'action."))
else:
yield _reponse(types.Part(function_call=types.FunctionCall(name="approuver_rapport", args={})))
elif "Rédige" in str(llm_request.config.system_instruction):
yield _reponse(types.Part(text=RAPPORT_COMPLET))
else:
yield _reponse(types.Part(text="Risque financier élevé."))Le vrai ADK fait tout le reste : exécution des outils, callbacks, output_key, escalate. Le test le plus important vérifie le scénario complet :
def test_pipeline_complet_avec_faux_modele():
faux = FauxModele()
r = asyncio.run(due_diligence("Composants Arvenne", faux))
assert r["score"]["score"] == 45
assert r["statut"] == "approuvé" and r["passages"] == 2 # la boucle a tourné 2 fois
assert "[e-mail masqué]" in r["rapport"] # after_model_callback appliqué
assert faux.appels == 3 * 2 + 1 + 2 * 2 # 3 chercheurs, analyste, 2 tours de boucleLe compte d'appels est un test de coût : si quelqu'un ajoute par erreur un agent ou supprime skip_summarization, le test casse.
Pourquoi la dernière assertion compte-t-elle les appels au faux modèle ?
Chaque appel au vrai modèle coûte des tokens et des secondes. Figer leur nombre attendu (11 ici) transforme le coût en propriété testée.
Avec ce faux modèle, le test « Cartonnerie Belval » échouerait sur le statut. Pourquoi ?
C'est la validation déterministe qui fait son travail : un rapport qui ne cite pas le bon score n'est jamais approuvé, même si le relecteur appelle approuver_rapport.
Lancer les tests
cd due_diligence
pytest -q # aucune variable d'environnement nécessaireExercice : tester le garde-fou budget en conditions réelles
Le test test_garde_fou_budget appelle le callback directement. Écrivez un test de bout en bout qui fixe MAX_APPELS_LLM à 4 et vérifie que le rapport final contient le message du garde-fou.
Voir la solution
def test_budget_coupe_le_pipeline(monkeypatch):
monkeypatch.setattr(agent, "MAX_APPELS_LLM", 4)
r = asyncio.run(due_diligence("Composants Arvenne", FauxModele()))
assert r["rapport"].startswith("[garde-fou]")
assert r["statut"] == "non approuvé"Avec 4 appels autorisés, les 3 chercheurs font leur appel d'outil (3), un seul obtient son résumé (4), puis tout le reste reçoit la réponse du garde-fou, y compris le rédacteur dont la sortie devient rapport. Le relecteur, lui aussi bloqué, n'appelle jamais approuver_rapport : la boucle fait ses 3 tours, mais sans aucun appel au modèle. Le score (45) est quand même calculé, car les trois outils avaient répondu avant la coupure.
Résultat obtenu
pytest -v dans files/, sans aucune clé, le 2026-10-15 :
test_pipeline.py::test_score_par_fournisseur PASSED [ 11%]
test_pipeline.py::test_score_indetermine_si_donnee_manquante PASSED [ 22%]
test_pipeline.py::test_masquage_email_et_iban PASSED [ 33%]
test_pipeline.py::test_controle_outil_refuse_nom_vide PASSED [ 44%]
test_pipeline.py::test_garde_fou_budget PASSED [ 55%]
test_pipeline.py::test_pipeline_complet_avec_faux_modele PASSED [ 66%]
test_pipeline.py::test_pipeline_fournisseur_inconnu PASSED [ 77%]
test_pipeline.py::test_verifier_rapport PASSED [ 88%]
test_pipeline.py::test_approbation_refusee_si_rapport_incomplet PASSED [100%]
============================== 9 passed in 1.61s ===============================Je sais faire
- Rendre le modèle injectable dans un pipeline ADK
- Écrire un faux modèle
BaseLlmqui scripte appels d'outils et textes - Tester un callback avec un faux contexte
- Transformer le nombre d'appels au modèle en assertion de coût