Leçon 4 — Tester, évaluer, exploiter

Tester sans clé, simuler un élève, mettre en service

6 min de lecture

Tester sans clé, simuler un élève, mettre en service

Deux niveaux de tests

Tests unitaires (pytest)Test de bout en bout (--demo)
Clé d'APInonoui
ModèleFauxModele qui rejoue un scriptGemini, tuteur et élève
Ce qu'on vérifierègle d'adaptation, écriture de l'état, persistance SQLitecomportement réel : positionnement, retours, reprise
Durée, coût2 secondes, gratuitenviron une minute, une quinzaine d'appels
Déterministeouinon
flowchart LR
  subgraph U["pytest : sans clé"]
    F["FauxModele (script)"] --> AG["Agent + outils réels"] --> SQ[("SQLite temporaire")]
  end
  subgraph E["--demo : avec clé"]
    T["Tuteur (Gemini)"] <--> S["Élève simulé (Gemini)"]
    T --> DB[("tuteur.db")]
  end

Un faux modèle pour ADK

ADK accepte comme model n'importe quel objet qui hérite de BaseLlm. Il suffit d'implémenter generate_content_async :

files/test_tuteur.py
class FauxModele(BaseLlm):
    """Rejoue une liste de réponses : un appel d'outil puis un texte, etc."""
    script: list = []

    async def generate_content_async(self, llm_request, stream=False):
        yield LlmResponse(content=types.Content(role="model", parts=[self.script.pop(0)]))

Le test fait jouer au faux modèle les appels definir_niveau puis trois enregistrer_reponse, avec un vrai SqliteSessionService sur un fichier temporaire. Ensuite, il crée un nouveau service et une nouvelle session sur le même fichier, comme le ferait un second lancement du programme :

files/test_tuteur.py
etat = asyncio.run(_discuter(db, ["python", "r1", "r2", "r3"], script))
assert etat["user:progression"]["python"]["niveau"] == 3  # 2 bonnes d'affilée -> +1
assert etat["user:progression"]["python"]["a_revoir"] == ["dict"]

# Nouvelle session, nouveau service (= nouveau processus) : la progression « user: » est relue du disque.
etat2 = asyncio.run(_discuter(db, ["re-bonjour"], [types.Part(text="Bon retour !")]))
assert etat2["user:progression"]["python"]["bonnes"] == 2

Que prouve test_progression_persiste_entre_sessions que test_ajuster_niveau ne prouve pas ?

L'élève simulé

Pour tester sans taper les réponses, main.py --demo 1 1 1 1 0 fait répondre un second appel Gemini, qui joue un élève juste (1) ou faux (0) :

files/main.py
def eleve_simule(question: str, juste: bool) -> str:
    """Un « faux élève » : un appel Gemini direct qui répond juste ou faux exprès."""
    consigne = "correctement" if juste else "en faisant une erreur typique de débutant"
    client = genai.Client()  # garder une référence : sinon le client est fermé avant la requête
    r = client.models.generate_content(
        model=os.environ.get("MODEL", "gemini-3.5-flash"),
        contents=f"Tu joues un élève. Réponds en une ou deux phrases, {consigne}, "
                 f"à la DERNIÈRE question de ce message du tuteur :\n{question}")
    return r.text.strip()

Ce que l'on vérifie à la lecture : le positionnement a bien lieu, le tuteur appelle enregistrer_reponse avec correcte cohérent avec la consigne donnée à l'élève, il annonce les changements de niveau, et la session 2 rappelle le niveau et les notions à revoir.

Résultat obtenu

Tests du 2026-10-15, sans clé d'API :

test_tuteur.py::test_ajuster_niveau PASSED                               [ 50%]
test_tuteur.py::test_progression_persiste_entre_sessions PASSED          [100%]
============================== 2 passed in 1.85s ===============================

Test de bout en bout du même jour (extrait) : l'élève simulé « faux » a répondu A) 7 à une question sur les décorateurs ; le tuteur a appelé enregistrer_reponse {'theme': 'python', 'notion': 'décorateurs de fonction', 'correcte': False}, expliqué la bonne réponse B) 14, puis proposé une nouvelle question sur la même notion. Dans la session suivante : « Tu es actuellement au niveau 4 (avancé) en Python, et nous avons la notion des décorateurs de fonction à revoir. »

Mettre en service

En local : python main.py --user <identifiant> suffit pour un usage individuel. Le fichier tuteur.db est la seule chose à sauvegarder.

Sur Google Cloud (non exécuté dans ce lab sans GCP) : remplacez le modèle par Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=TRUE, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION), servez l'agent avec adk deploy cloud_run et passez à un service de session partagé (DatabaseSessionService sur Cloud SQL, ou VertexAiSessionService). Le code des outils ne change pas : seul le service de session est remplacé.

Vous passez le tuteur sur Cloud Run avec plusieurs instances. Que faut-il changer dans agent.py ?

Exercice : tester la descente de niveau

Écrivez un test pytest qui place un élève au niveau 3 puis lui fait rater deux questions, et vérifiez qu'il redescend au niveau 2 avec deux notions à revoir.

Voir la solution
def test_descente(tmp_path):
    script = [appel("definir_niveau", theme="sql", niveau=3), types.Part(text="Q1"),
              appel("enregistrer_reponse", theme="sql", notion="jointures", correcte=False), types.Part(text="Q2"),
              appel("enregistrer_reponse", theme="sql", notion="group by", correcte=False), types.Part(text="Q3")]
    etat = asyncio.run(_discuter(str(tmp_path / "t.db"), ["sql", "r1", "r2"], script))
    fiche = etat["user:progression"]["sql"]
    assert fiche["niveau"] == 2 and fiche["serie"] == []
    assert fiche["a_revoir"] == ["jointures", "group by"]

Chaque message utilisateur consomme deux réponses du script : l'appel d'outil, puis le texte final.

Je sais faire

  • Écrire un faux modèle BaseLlm pour tester un agent sans clé
  • Tester la persistance avec un vrai SqliteSessionService sur un fichier temporaire
  • Simuler un utilisateur avec un second appel au modèle
  • Expliquer ce qui change (et ce qui ne change pas) pour passer sur Google Cloud