~/projets/quizops50 minutes, exercice optionnel compris : 15 min pour l'exercice 1, 12 pour le 2, 13 pour le 3, 10 pour le 4.
Le tag tp5-depart reconstruit l'état de sortie du TP 4 : le classement GET /api/scores et le score des parties incomplètes corrigé. Il manque toute forme de validation des entrées : POST /api/answers accepte n'importe quel corps JSON.
cd ~/projets/quizops
outils/depart.sh 5
................... [100%]
19 passed in 0.20s
L'analyse statique est propre au départ. C'est la référence à laquelle vous comparerez à chaque étape.
$ uvx ruff check .
All checks passed!
Livrer la validation des entrées à partir de tests écrits avant elle, sans toucher à ces tests.
Le fichier tests/a-venir/test_validation.py contient sept tests. Ils décrivent ce que POST /api/answers doit refuser, et rien sur la façon de le faire.
grep '^def test_' tests/a-venir/test_validation.py
def test_une_partie_valide_est_acceptee():
def test_une_liste_de_reponses_vide_est_refusee():
def test_un_pseudo_vide_est_refuse():
def test_un_pseudo_trop_long_est_refuse():
def test_un_identifiant_de_question_inconnu_est_refuse():
def test_un_choix_hors_bornes_est_refuse():
def test_un_corps_qui_n_est_pas_du_json_est_refuse():
Deux d'entre eux passent déjà : une partie valide est acceptée, et un corps qui n'est pas du JSON est refusé par FastAPI lui-même. Les cinq autres décrivent des trous.
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
git mv tests/a-venir/test_validation.py tests/
uv run pytest -q
FAILED tests/test_validation.py::test_un_identifiant_de_question_inconnu_est_refuse
FAILED tests/test_validation.py::test_un_choix_hors_bornes_est_refuse - Asser...
5 failed, 21 passed in 0.21s
Le prompt part de la sortie réelle, pas d'un résumé. Collez le bloc ci-dessus dans votre demande.
Voici la sortie de uv run pytest -q après avoir ajouté tests/test_validation.py : <la trace>
Fais passer ces cinq tests. Contraintes : validation par des modèles Pydantic dans app/main.py,
pas de try/except générique, un identifiant de question inconnu répond 422, ne touche à aucun
fichier de tests, aucune nouvelle dépendance.
Lance uv run pytest -q puis uvx ruff check . et donne-moi les deux dernières lignes.
git diff --stat
git diff app/main.py
Trois questions sur ce diff : les refus renvoient-ils bien 422 et pas 400 ; le pseudo vide et le pseudo de 31 caractères sont-ils traités par une contrainte de champ et non par un if dans la route ; les tests sont-ils intacts ?
$ uv run pytest -q | tail -1
26 passed in 0.20s
$ uvx ruff check .
All checks passed!
$ git diff --stat -- tests/
La dernière commande n'affiche rien : les tests fournis n'ont pas bougé.
Deux modèles Pydantic déclarés dans app/main.py, et la route qui les prend en paramètre :
class Reponse(BaseModel):
question_id: int
choix: int | None = Field(default=None, ge=0, le=3)
class Partie(BaseModel):
pseudo: str = Field(min_length=1, max_length=30)
reponses: list[Reponse] = Field(min_length=1)
L'identifiant inconnu ne se déclare pas dans le modèle : il se vérifie contre les questions chargées, et lève un HTTPException en 422.
inconnus = [
r.question_id for r in partie.reponses if r.question_id not in QUESTIONS_PAR_ID
]
if inconnus:
raise HTTPException(status_code=422, detail=f"Questions inconnues : {inconnus}")
✅ Cinq contraintes de champ remplacent une dizaine de if dans la route, et le 422 arrive avant que le code métier ne s'exécute.
Q: Pourquoi deux tests passaient déjà ?
R: Une partie valide était déjà acceptée, et un corps qui n'est pas du JSON est rejeté par la couche HTTP de FastAPI avant d'atteindre le code. Un test qui passe avant le travail mesure ce qui existe, il ne mesure pas votre travail.
Q: Qu'est-ce qui interdit de modifier un test ?
R: Rien techniquement, et c'est justement le risque. Le test est le contrat : le déplacer sous prétexte qu'il rougit revient à déplacer la cible. La commande git diff --stat -- tests/ sert à le prouver.
Obtenir trois tests unitaires sur le tirage des questions, puis vérifier qu'ils servent à quelque chose.
quiz.tirer_questions est la fonction la moins couverte du projet : dix questions tirées au hasard, sans doublon, y compris quand la catégorie demandée en contient moins de dix. Un test qui ne rougit jamais ne protège rien, et le seul moyen de le savoir est de casser le code exprès.
quiz.tirer_questions dans tests/test_tirage.py.uv run outils/saboter.py 1 : le tirage se met à produire des doublons.git checkout -- app/ et vérifiez le retour au vert.Contraintes à imposer dans votre prompt
tests/test_tirage.py.sleep, aucun appel réseau, aucune graine aléatoire figée.app/.💡 Indice : tests/outils.py fournit déjà questions_avec_reponses(), qui charge les questions avec leur bonne réponse.
$ uv run pytest -q | tail -1
29 passed in 0.21s
$ uv run outils/saboter.py 1
Sabotage 1 appliqué dans app/quiz.py. Retour à la normale : git checkout -- app/
$ uv run pytest -q | tail -1
2 failed, 27 passed in 0.21s
Au moins un test rouge, dont un des vôtres. Puis :
$ git checkout -- app/ && uv run pytest -q | tail -1
29 passed in 0.21s
1. La demande
Écris trois tests unitaires de quiz.tirer_questions dans tests/test_tirage.py.
Un test : le tirage ne contient aucun doublon d'identifiant.
Un test : toutes les questions tirées appartiennent au jeu fourni.
Un test : une catégorie qui contient moins de dix questions rend toutes ses questions.
Contraintes : pas de client HTTP, pas de graine aléatoire figée, réutilise questions_avec_reponses()
de tests/outils.py, ne modifie rien dans app/.
Lance uv run pytest -q et donne-moi la dernière ligne.
2 et 3. Le sabotage tranche
Sur le dépôt de référence, saboter.py 1 fait rougir deux tests : celui du tirage sans doublon, et un test d'API qui vérifiait déjà l'unicité des identifiants. Votre décompte peut différer, la seule question est : au moins un de vos trois tests s'allume-t-il ?
4. La restauration
git checkout -- app/
✅ Un test se juge sur ce qu'il détecte, pas sur ce qu'il affirme. saboter.py est le juge.
Réparer un incident de production simulé sans ajouter de rustine autour du symptôme.
outils/panne.py 4 renomme une colonne dans l'ordre d'insertion de app/database.py. L'application démarre, l'interface s'affiche, les questions arrivent, et l'envoi d'une partie tombe en 500. C'est la panne la plus proche du réel : rien ne prévient au démarrage.
uv run outils/panne.py 4 et constatez l'ampleur des dégâts avec uv run pytest -q.uv run uvicorn app.main:app --reload dans un second terminal, envoyez une partie avec curl sur POST /api/answers, relevez le code HTTP.curl.Contraintes à imposer dans votre prompt
try/except ajouté autour de l'insertion.app/database.py.quizops.db.💡 Indice : la trace nomme la table, la colonne et la requête SQL fautive. Tout est dedans.
$ uv run outils/panne.py 4
Panne 4 appliquée dans app/database.py. Retour à la normale : git checkout -- app/database.py
$ uv run pytest -q | tail -1
8 failed, 21 passed in 1.52s
$ curl -s -o /dev/null -w '%{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}]}'
500
Dans le terminal d'uvicorn :
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
Après correction, le même curl répond 200, la suite est verte, et le diff ne sort pas de app/database.py.
$ uv run pytest -q | tail -1
29 passed in 0.21s
$ git diff --stat
app/database.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
1 à 3. La demande, trace comprise
L'envoi d'une partie répond 500. Voici la trace intégrale d'uvicorn : <la trace>
Trouve la cause dans app/database.py et corrige-la.
Contraintes : ne mets aucun try/except autour de l'insertion, ne touche qu'à app/database.py,
ne supprime pas quizops.db, ne modifie aucun test.
Lance uv run pytest -q et donne-moi la dernière ligne.
4. Le contrôle
La table est créée avec une colonne pseudo, l'insertion en réclamait une nommée joueur. La correction rétablit le nom de la colonne dans la requête. Si l'agent a rétabli la ligne d'origine à l'identique, git diff --stat est vide : c'est le meilleur des diffs.
⚠️ Une correction qui ajoute try/except sqlite3.OperationalError fait disparaître le 500 et laisse la partie non enregistrée. Le test 29 passed ne suffit alors plus, il faut relire le diff.
✅ La trace intégrale contient le fichier, la ligne, la requête et le message. Sa dernière ligne seule ne contient que le message.
Mesurer la couverture réelle de la suite en la confrontant à deux mutations de plus.
Deux sabotages restent à essayer : le score bloqué à 100 quel que soit le nombre de bonnes réponses, et la catégorie inconnue qui répond 200 au lieu de 404. Chacun correspond à un comportement décrit dans le cahier des charges.
uv run outils/saboter.py 2, relancez la suite, notez ce qui rougit, restaurez avec git checkout -- app/.uv run outils/saboter.py 3.Contraintes à imposer
git checkout -- app/.💡 Indice : uv run pytest -q --tb=no donne la liste des tests rouges sans les traces, plus lisible pour remplir la grille.
$ uv run outils/saboter.py 2
Sabotage 2 appliqué dans app/quiz.py. Retour à la normale : git checkout -- app/
$ uv run pytest -q | tail -1
2 failed, 27 passed in 0.21s
$ uv run outils/saboter.py 3
Sabotage 3 appliqué dans app/main.py. Retour à la normale : git checkout -- app/
$ uv run pytest -q | tail -1
1 failed, 28 passed in 0.21s
Chaque sabotage fait rougir au moins un test, et la suite revient à 29 passed après restauration.
3. La grille du dépôt de référence
| Sabotage | Attrapé | Par quel test |
|---|---|---|
| 2 : score toujours à 100 | oui | test_score_partiel.py et test_api.py::test_toutes_mauvaises_reponses_donnent_zero |
3 : catégorie inconnue en 200 |
oui | test_categories_lot2.py::test_une_categorie_inconnue_renvoie_404 |
4. S'il manque un test
Le sabotage suivant n'a fait rougir aucun test : <description du comportement cassé>.
Écris un seul test dans le fichier de tests concerné, qui vérifie le comportement attendu
et non la ligne modifiée. Ne touche pas à app/.
Lance uv run pytest -q et donne-moi la dernière ligne.
✅ Une suite verte face à un sabotage n'est pas une bonne nouvelle, c'est un trou de couverture qui vient de se signaler gratuitement.
tests/a-venir/ est un contrat ; on le déplace, on le fait passer, on ne le modifie pas.outils/saboter.py juge les tests que l'agent vient d'écrire, sans avoir à les relire ligne à ligne.try/except autour d'une insertion fait disparaître le 500 et perd les données.uvx ruff format . && uvx ruff check . && uv run pytest -q, dans cet ordre, avant chaque commit.Rendez-vous au TP 6 pour outiller ce geste : la grille de relecture et la chaîne de vérification deviennent des skills que l'agent déclenche seul ou sur commande.
➡️ Module associé : Validation, tests et fiabilisation · TP suivant : TP 6 : Skills et Automatisation