Les modules précédents apprennent à produire vite. Celui-ci apprend à savoir si ce qui a été produit est correct. Le code généré est plausible par construction : il se lit bien, et l'attention baisse devant un code qui a l'air correct. Les vérifications qui suivent ne dépendent pas de cette attention.
Chez un collègue, on cherche des erreurs de raisonnement. Dans du code généré, on cherche des oublis (cas limites, erreurs, entrées invalides), des ajouts non demandés, des suppositions implicites et un excès de généralité. Les erreurs se concentrent sur les bords plutôt que sur le chemin nominal. La grille de relecture ci-dessous s'applique dans cet ordre, sert au TP 5 et vaut pour n'importe quel code généré :
try/except apparu de nulle part.except sans traitement, valeur par défaut qui masque un problème, None qui remonte en silence.| Signal | Pourquoi c'est suspect |
|---|---|
except Exception: pass |
Une erreur disparaît sans laisser de trace |
| Une valeur codée en dur | Un chemin, un port, un seuil qui devrait être un paramètre |
| Trois niveaux d'abstraction pour un besoin simple | Le motif appris, pas le besoin réel |
| Une dépendance inattendue | Existe-t-elle vraiment ? |
| Une conversion de type implicite | int(x) sur une entrée utilisateur non validée |
La dépendance inattendue est la seule ligne du tableau à présenter un risque de sécurité immédiat. Un modèle qui a besoin d'une bibliothèque en invente le nom quand il ne s'en souvient pas : fastapi-auth-utils, json-schema-helper, sqlite-migrate. Des attaquants publient de vrais paquets sous ces noms, et le code s'installe, fonctionne, puis exécute ce qu'on y a mis.
Note : à chaque dépendance qui apparaît dans un diff, vérifiez qu'elle existe sur l'index officiel et que c'est bien celle que vous croyez, regardez sa date de publication et son nombre de versions (un paquet de trois semaines en version 0.0.1 qui répond exactement à votre besoin est suspect), et demandez-vous si elle est nécessaire. Aucun outil ne fait cette vérification à votre place.
Avec du code généré, les tests deviennent le mécanisme principal de vérification. Ils ne dépendent pas de l'attention du relecteur et survivent au remaniement. C'est aussi le genre de code répétitif que l'IA produit bien et vite. Un prompt de génération de tests dit ce qu'on teste, les cas à couvrir et ce qu'on ne veut pas :
Regarde @app/main.py et @app/quiz.py.
Écris les tests pytest de l'API dans tests/test_api.py.
Cas à couvrir :
- GET /api/questions renvoie 10 questions, sans le champ "reponse"
- GET /api/questions?categorie=inconnue renvoie un code 404
- POST /api/answers avec 5 bonnes réponses sur 10 questions renvoie 50
Contraintes :
- pytest et le TestClient de FastAPI, aucune autre dépendance
- une base de test isolée : les tests ne touchent pas quizops.db
- un test par cas, nommé en français, sans classe
- aucun test qui vérifie un détail d'implémentation
Livré à lui-même, un modèle produit des tests qui vérifient qu'une fonction a été appelée ou qu'une variable interne a telle valeur. Ces tests cassent au premier remaniement sans qu'aucun comportement n'ait changé, et finissent supprimés en bloc. La dernière contrainte du prompt sert à les éviter.
Les tests rapportent le plus là où le code généré échoue : sur les bords. Demandez la liste avant le code :
Avant d'écrire quoi que ce soit, liste tous les cas limites de la route
POST /api/answers. Ne code rien.
Vous arbitrez ceux qu'on traite et ceux qu'on ignore consciemment, puis vous faites écrire les tests correspondants. La deuxième ligne est celle qu'un modèle oublie le plus volontiers, parce que rien ne plante : le score est simplement faux.
Le test devient une spécification exécutable que le modèle doit satisfaire. Dans le dépôt de référence, les tests des lots à venir sont écrits d'avance et rangés dans tests/a-venir/, que pytest ignore ; on déplace le fichier vers tests/ au moment d'attaquer le lot, et l'écart apparaît avant qu'une ligne de code ait été écrite.
$ mv tests/a-venir/test_validation.py tests/
$ uv run pytest -q tests/test_validation.py
.FFFFF. [100%]
5 failed, 2 passed in 0.17s
Les sept tests du fichier décrivent un contrat : POST /api/answers répond 422 sur une entrée invalide. Deux d'entre eux :
def test_un_pseudo_trop_long_est_refuse():
partie = partie_valide()
partie["pseudo"] = "x" * 31
assert client.post("/api/answers", json=partie).status_code == 422
def test_un_identifiant_de_question_inconnu_est_refuse():
partie = partie_valide()
partie["reponses"][0]["question_id"] = 9999
assert client.post("/api/answers", json=partie).status_code == 422
La demande faite au modèle tient alors en deux phrases : « Modifie app/main.py pour que ces tests passent. Ne modifie pas les tests. » La seconde est indispensable : sans elle, un modèle bloqué finit par ajuster le test à la place du code.
| Priorité | Quoi | Sur QuizOps |
|---|---|---|
| 1 | Ce qui est faux silencieusement | Le calcul du score |
| 2 | Les entrées extérieures | POST /api/answers |
| 3 | Ce qui touche aux données | Le format des questions, le schéma des scores |
| 4 | Ce qui a déjà cassé une fois | Tout bug corrigé donne un test |
| Jamais | Le style, la présentation | La feuille CSS, les textes |
$ uv run pytest -q
............................... [100%]
31 passed in 0.28s
$ uv run pytest -v # un test par ligne
$ uv run pytest -x # s'arrêter au premier échec
Quand un test échoue, pytest affiche la ligne fautive, la valeur obtenue et la valeur attendue. Voici la sortie après uv run outils/panne.py 3, qui replante le bug du score dans app/quiz.py :
..................F............ [100%]
=================================== FAILURES ===================================
________ test_cinq_bonnes_reponses_sur_dix_questions_donnent_cinquante _________
def test_cinq_bonnes_reponses_sur_dix_questions_donnent_cinquante():
questions = client.get("/api/questions").json()
reponses = reponses_justes(questions)
for reponse in reponses[5:]:
reponse["choix"] = None
partie = {"pseudo": "fanny", "reponses": reponses}
> assert client.post("/api/answers", json=partie).json()["score"] == 50
E assert 100 == 50
tests/test_score_partiel.py:17: AssertionError
=========================== short test summary info ============================
FAILED tests/test_score_partiel.py::test_cinq_bonnes_reponses_sur_dix_questions_donnent_cinquante
1 failed, 30 passed in 0.27s
Cette sortie se colle telle quelle dans un prompt : elle contient le fait, l'attendu et l'écart. Le compteur de tests verts sert aussi de critère quand on compare deux implémentations du même besoin, comme au TP 4.
Analyse statique et formatage dans un seul outil. Sur app/exemple.py, écrit à la va-vite, il remonte un bloc d'imports mal ordonné (I001) et deux imports inutilisés (F401). ruff propose la correction sous chaque diagnostic ; l'extrait ci-dessous s'arrête à la ligne help:.
$ uvx ruff check app/exemple.py
I001 [*] Import block is un-sorted or un-formatted
--> app/exemple.py:1:1
|
1 | import os, json
| ^^^^^^^^^^^^^^^
2 | def addition( a,b ):
3 | resultat=a+b
|
help: Organize imports
[puis F401 pour `os`, F401 pour `json`]
Found 3 errors.
[*] 3 fixable with the `--fix` option.
Le formatage se vérifie à part, sans rien modifier, puis les deux corrections s'appliquent :
$ uvx ruff format --check .
1 file would be reformatted, 31 files already formatted
$ uvx ruff check --fix app/exemple.py
Found 2 errors (2 fixed, 0 remaining).
$ uvx ruff format .
1 file reformatted, 31 files left unchanged
ruff attrape aussi ce que la relecture rate dans du code de production. Après uv run outils/saboter.py 4, qui introduit une comparaison douteuse et une condition inutilement dépliée dans app/quiz.py :
F632 [*] Use `==` to compare constant literals
--> app/quiz.py:16:12
|
15 | def categorie_est_docker(question: dict) -> bool:
16 | return question["categorie"] is "docker"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: Replace `is` with `==`
SIM103 Return the condition `question["categorie"] == "docker"` directly
--> app/quiz.py:20:5
|
19 | def categorie_docker(question: dict) -> bool:
20 | / if question["categorie"] == "docker":
21 | | return True
22 | | else:
23 | | return False
| |____________________^
help: Replace with `return question["categorie"] == "docker"`
Found 2 errors.
Le is sur une chaîne fonctionne par accident sur les littéraux courts et casse ailleurs. Le formatage rend en prime les différences Git lisibles : sans lui, chaque génération réindente à sa façon.
mypy s'exécute dans l'environnement du projet : lancé isolément avec uvx mypy, il ne voit pas les dépendances installées et signale des imports introuvables. Il attrape une valeur manipulée avec le mauvais type, ici une concaténation entre une chaîne et un entier introduite par une génération qui a supposé une signature sans la relire.
# Le projet est propre
$ uv run --with mypy mypy app/
Success: no issues found in 5 source files
# Après l'introduction d'une addition entre une chaîne et un entier
$ uv run --with mypy mypy app/
app/quiz.py:49: error: Unsupported operand types for + ("str" and "int") [operator]
Found 1 error in 1 file (checked 5 source files)
Les commandes précédentes se lancent à la main. Un serveur de langage donne les mêmes informations à l'agent au moment où il édite. La place de marché officielle en publie un par langage ; celui de Python s'appuie sur Pyright.
/plugin install pyright-lsp@claude-plugins-official
Le plugin ne fournit que la configuration : il déclare le serveur pyright-langserver pour les fichiers .py et .pyi, et le binaire s'installe séparément (npm install -g pyright). Chaque Write ou Edit de l'agent est ensuite suivi des diagnostics du fichier touché : attribut inconnu, argument en trop, type incompatible. L'agent les voit et corrige dans la foulée, avant même que pytest ne soit lancé ; Ctrl+O déplie la sortie détaillée des outils et les rend lisibles côté terminal. Cette section n'a pas de capture : le mode non interactif utilisé pour les autres sorties de ce module n'installe pas de plugin.
Le formatage, l'analyse statique et les tests se lancent en une seule commande, du plus rapide au plus lent, qui s'arrête à la première étape en échec.
$ uvx ruff format . && uvx ruff check . && uv run pytest -q
31 files left unchanged
All checks passed!
............................... [100%]
31 passed in 0.25s
Cette commande tourne avant chaque commit. C'est elle que la chaîne d'intégration du module 06 exécute automatiquement.
La méthode ne change pas : reproduire (la commande, la requête ou le clic exact), isoler (l'API, l'interface, la base ? tester la couche du dessous), fournir les preuves (commande exacte, sortie exacte, trace intégrale, comportement attendu), vérifier la correction puis les cas qui marchaient déjà.
Voici une panne provoquée par uv run outils/panne.py 4, qui renomme une colonne dans app/database.py. Côté client, on ne voit qu'un code HTTP.
$ curl -s -o /dev/null -w "code HTTP : %{http_code}\n" -X POST \
http://127.0.0.1:8000/api/answers -H 'Content-Type: application/json' \
-d '{"pseudo":"alice","reponses":[{"question_id":1,"choix":0}]}'
code HTTP : 500
Le terminal du serveur, lui, garde la trace. Elle se lit du bas vers le haut : la dernière ligne dit quoi, celles du dessus disent où.
File "~/projets/quizops/app/main.py", line 55, in soumettre_reponses
database.enregistrer_score(partie.pseudo, score)
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
File "~/projets/quizops/app/database.py", line 31, in enregistrer_score
base.execute(
~~~~~~~~~~~~^
"INSERT INTO scores (joueur, score) VALUES (?, ?)", (pseudo, score)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
sqlite3.OperationalError: table scores has no column named joueur
La dernière ligne nomme la table et la colonne fautives, les appels du dessus donnent le fichier et le numéro de ligne. Ces quelques lignes se collent telles quelles dans un prompt, sans reformulation.
Quand il n'y a pas de trace, on en fabrique une : lsof -i :8000 dit qui occupe le port, et la console du navigateur (F12) affiche les erreurs JavaScript que le serveur ne voit jamais. Le bug du score, lui, ne plante rien : la requête répond 200 avec une valeur fausse. Le prompt de débogage donne le contexte, la reproduction, l'obtenu et l'attendu, et demande une explication avant une correction.
Contexte : QuizOps, API FastAPI. Le fichier concerné est app/quiz.py.
Reproduction : partie de 10 questions, seules les 5 premières
répondues, toutes justes.
Obtenu : un score de 100.
Attendu : 50, puisque 5 questions sur 10 ont été réussies.
Tâche : explique-moi d'abord d'où vient l'écart. Ne corrige rien.
L'explication permet d'arbitrer la cause avant de subir la solution. Un agent à qui l'on dit simplement « corrige » traite le symptôme : il ajoute un garde sur la division par zéro, qui protège le seul cas capable de planter, et tous les scores partiels restent faux. Le bug est planté dans tp4-depart du dépôt de référence (git show tp4-depart:app/quiz.py) : la fonction y écarte les questions sans réponse avant de calculer le pourcentage.
def calculer_score(reponses: list[dict], questions_par_id: dict[int, dict]) -> int:
repondues = [reponse for reponse in reponses if reponse.get("choix") is not None]
bonnes = compter_bonnes_reponses(repondues, questions_par_id)
return round(bonnes / len(repondues) * 100)
La version corrigée, livrée dans tp5-depart (git show tp5-depart:app/quiz.py), compte les questions posées et traite le cas vide séparément :
def calculer_score(reponses: list[dict], questions_par_id: dict[int, dict]) -> int:
total = len(reponses)
if total == 0:
return 0
bonnes = compter_bonnes_reponses(reponses, questions_par_id)
return round(bonnes / total * 100)
Le test qui verrouille la correction est tests/test_score_partiel.py, livré au même tag : c'est celui qui échoue plus haut avec assert 100 == 50.
Après trois allers-retours infructueux, on change de méthode : revenir au dernier commit propre (git checkout -- .), réduire le cas au plus petit exemple qui reproduit le bug, demander trois causes possibles classées par probabilité avec la façon de tester chacune, ou lire le code soi-même.
tests/a-venir/ existent avant le code qu'ils spécifient, et la consigne « ne modifie pas les tests » accompagne toute demande de correction.uv run pytest -q donne le fait, l'attendu et l'écart dans une sortie qui se colle dans un prompt.uvx ruff check, uvx ruff format et uv run --with mypy mypy app/ attrapent imports inutilisés, comparaisons is sur des littéraux et incompatibilités de types ; le plugin pyright-lsp remonte les mêmes diagnostics à l'agent après chaque édition.TP associé : TP 5 : Tester et Fiabiliser
➡️ Module suivant : Skills : Personnalisation et Automatisation