Correction automatique d'exercices de programmation
Comment marche un autograder, comment écrire des tests qui attrapent les vraies erreurs, les pièges à éviter, et comment créer tes exercices C ou Python corrigés.
Corriger trente rendus d'un même exercice C à la main, c'est relancer trente fois le même programme sur les mêmes entrées et chercher les différences à l'œil. Une machine fait ça mieux que toi, plus vite et sans fatigue, à une condition : que les tests soient bons. Si tu enseignes, formes ou animes un atelier, voici comment marche une correction automatique et comment écrire des tests qui tiennent.
En bref
Une correction automatique compile le code rendu, l'exécute sur une série de tests et compare le résultat à ce qui est attendu : la sortie et le code de retour pour un programme, la valeur renvoyée pour une fonction appelée par un petit programme caché (le harnais), et la mémoire avec Valgrind si on le demande. Sa valeur dépend entièrement des tests : des cas limites, plusieurs tests pour qu'une réponse écrite en dur ne passe pas, des messages d'échec qui montrent l'attendu et l'obtenu. Le Studio de Grademe applique ce principe à des exercices en C ou en Python, que tu gardes privés, partages par lien ou proposes au catalogue public.
Comment fonctionne une correction automatique ?
Elle refait ce que tu ferais à la main : compiler, lancer, comparer. Un correcteur automatique, souvent appelé autograder, commence par compiler le fichier rendu, de préférence avec les warnings transformés en erreurs (-Wall -Wextra -Werror), puis exécute chaque test séparément, avec un délai maximal pour qu'une boucle infinie compte comme un échec au lieu de bloquer la correction. Ce qu'il compare dépend du type de test :
| Type de test | Ce que le test fournit | Ce qui est comparé |
|---|---|---|
| Programme | des arguments, une entrée standard | la sortie standard octet par octet, le code de retour |
| Fonction | un appel, par exemple clamp(42, 0, 10) | ce que le harnais affiche de la valeur renvoyée |
| Mémoire | un des tests précédents, lancé sous Valgrind | les accès invalides et les fuites |
Le harnais est le seul élément un peu technique. Pour corriger une fonction et non un programme, on compile le fichier de l'élève avec un second fichier, écrit par l'enseignant, qui contient le main. Pour une fonction int clamp(int v, int lo, int hi), ce main exécute par exemple printf("%d\n", clamp(42, 0, 10)); : la fonction devient un programme dont on compare la sortie, et l'élève n'a jamais besoin de voir ce fichier.
À quoi ressemble un correcteur minimal ?
À un script de quelques lignes : compiler, lancer chaque test, comparer octet par octet. Prends un exercice minuscule : le programme upper affiche son argument en majuscules, suivi d'un retour à la ligne, et sans argument il affiche seulement le retour à la ligne. Ce script compile une copie et la passe sur cinq tests :
#!/bin/bash# Compile la copie, puis compare sortie et code de retour aux valeurs attendues.gcc -Wall -Wextra -Werror "$1" -o upper || exit 1
echecs=0teste() { local nom=$1 attendu=$2 shift 2 timeout 2 ./upper "$@" > sortie.txt 2> /dev/null local code=$? if [ "$code" -eq 0 ] && cmp -s sortie.txt <(printf '%s' "$attendu"); then echo "OK $nom" else echo "ECHEC $nom (code de retour $code)" echo " attendu : $(printf '%s' "$attendu" | cat -e)" echo " obtenu : $(cat -e sortie.txt)" echecs=$((echecs + 1)) fi}
teste "un mot" $'BONJOUR\n' bonjourteste "déjà en capitales" $'ABC\n' ABCteste "chiffres, signes" $'A1-B2!\n' 'a1-b2!'teste "chaîne vide" $'\n' ''teste "sans argument" $'\n'exit "$echecs"cmp -s compare deux flux octet par octet, timeout 2 arrête le programme au bout de deux secondes, et cat -e affiche un $ à chaque fin de ligne pour rendre visibles les espaces et les retours à la ligne. Voici deux copies, la première oublie de vérifier qu'il y a un argument :
#include <ctype.h>#include <stdio.h>
int main(int argc, char **argv){ (void)argc; for (int i = 0; argv[1][i] != '\0'; i++) putchar(toupper((unsigned char)argv[1][i])); putchar('\n'); return 0;}#include <ctype.h>#include <stdio.h>
int main(int argc, char **argv){ if (argc > 1) for (int i = 0; argv[1][i] != '\0'; i++) putchar(toupper((unsigned char)argv[1][i])); putchar('\n'); return 0;}$ ./check.sh copie1.cOK un motOK déjà en capitalesOK chiffres, signesOK chaîne vide./check.sh: line 6: 30 Segmentation fault timeout 2 ./upper "$@" > sortie.txt 2> /dev/nullECHEC sans argument (code de retour 139) attendu : $ obtenu : $ echo $?1$ ./check.sh copie2.cOK un motOK déjà en capitalesOK chiffres, signesOK chaîne videOK sans argument$ echo $?0La première copie passe les quatre tests « normaux ». Seul le dernier, sans argument, l'attrape : argv[1] vaut alors NULL, le programme plante, et le code de retour 139 (128 + 11, le numéro de SIGSEGV) le dit sans ambiguïté. Un correcteur qui ne testerait que des mots ordinaires aurait mis 5 sur 5 à un programme qui plante. Le détail de ce crash est expliqué dans Segmentation fault (core dumped) en C.
La vérification mémoire repose sur une option de Valgrind faite pour ça, --error-exitcode : si Valgrind trouve une erreur, il renvoie ce code au lieu de celui du programme. Avec --leak-check=full, une fuite compte comme une erreur. Ce programme copie une chaîne et oublie de la libérer :
#include <stdio.h>#include <stdlib.h>#include <string.h>
int main(void){ char *copie = strdup("bonjour");
printf("%s\n", copie); return 0;}$ gcc -Wall -Wextra -g fuite.c -o fuite$ ./fuite; echo "code $?"bonjourcode 0$ valgrind -q --leak-check=full --error-exitcode=42 ./fuite; echo "code $?"bonjour==16== 8 bytes in 1 blocks are definitely lost in loss record 1 of 1==16== at 0x488545C: malloc (vg_replace_malloc.c:446)==16== by 0x495ED23: strdup (strdup.c:42)==16== by 0x400697: main (fuite.c:7)==16== code 42La sortie est juste, le code de retour du programme aussi ; seul le 42 de Valgrind signale les 8 octets jamais libérés, avec la ligne qui les a alloués. Pour lire ce genre de rapport ligne par ligne, voir lire un rapport Valgrind et la fiche valgrind(1).
Comment écrire de bons tests ?
En partant de l'énoncé et en cherchant ce qui casse. Un bon jeu de tests suit quelques règles simples :
un test au moins par phrase de l'énoncé, et aucun test sur un comportement que l'énoncé ne demande pas : l'élève ne peut pas deviner une règle qui n'est écrite nulle part ;
les cas limites d'abord : chaîne vide, un seul élément, aucun argument, la plus grande et la plus petite valeur (
INT_MAX,INT_MIN), un dépassement qui survient en cours de calcul ;plusieurs tests par comportement, dont certains cachés, pour qu'un programme qui affiche la réponse de l'exemple sans rien calculer ne passe pas ;
un nom par test qui dit ce qu'il vérifie (« sans argument » plutôt que « test 5 »), et en cas d'échec l'attendu et l'obtenu côte à côte.
La règle des cas limites est celle qui rapporte le plus. Dans l'exemple ci-dessus, c'est le seul test sur cinq qui distingue une copie juste d'une copie qui plante.
exit-status
Pour voir ces règles du côté de l'élève : le programme additionne ses arguments et signale chaque échec par son code de retour, 1 sans argument, 2 pour un argument invalide, 3 pour un dépassement, y compris quand c'est la somme en cours qui déborde. Les tests couvrent INT_MIN, INT_MAX, un signe seul, 12x, et chacun porte un nom qui dit ce qu'il vérifie.
Quels sont les pièges d'une correction automatique ?
Trois reviennent sans cesse : des tests plus stricts que l'énoncé, une sortie qui varie d'une exécution à l'autre, et les copies qui contournent les tests au lieu de résoudre l'exercice.
Des tests trop stricts sur les espaces
Une comparaison octet par octet ne pardonne rien. Voici une troisième copie, juste sur le fond, qui affiche une espace avant le retour à la ligne :
$ ./check.sh copie3.cECHEC un mot (code de retour 0) attendu : BONJOUR$ obtenu : BONJOUR $ECHEC déjà en capitales (code de retour 0) attendu : ABC$ obtenu : ABC $...ECHEC sans argument (code de retour 0) attendu : $ obtenu : $Cinq échecs pour une espace. C'est voulu si l'énoncé précise le format exact, et injuste sinon. Deux options honnêtes : écrire noir sur blanc ce qui est attendu, retour à la ligne final compris, et montrer les exemples avec cat -e ; ou normaliser les espaces avant de comparer, en le disant aussi. Le pire est le mélange, un énoncé flou corrigé par une comparaison stricte. À notre avis, la première option est la bonne en C : produire exactement le format demandé fait partie de l'exercice.
Une sortie qui change d'une exécution à l'autre
Un test ne peut comparer que ce qui est reproductible. Une adresse affichée avec %p, une variable lue avant d'être initialisée, un tirage aléatoire initialisé avec l'heure (srand(time(NULL))) ou l'heure elle-même peuvent donner une sortie différente à chaque lancement : ne les mets jamais dans la sortie attendue. Même chose pour l'ordre des lignes quand un programme mélange write et printf : la sortie de printf passe par un tampon et peut sortir après celle de write, alors que le code les appelle dans l'ordre inverse. La fiche putchar(3) montre le cas, et printf n'affiche rien avant un crash explique le tampon.
La triche, ou les tests qu'on contourne
Les deux contournements classiques sont prévisibles. Le premier consiste à afficher directement les réponses des exemples : des tests cachés, en plus de ceux montrés dans l'énoncé, suffisent à le rendre inutile. Le second, pour un exercice qui demande de réécrire une fonction de la bibliothèque, consiste à appeler la vraie. Les fonctions qu'un fichier appelle sans les définir apparaissent dans sa table des symboles, que nm(1) affiche :
$ gcc -c longueur.c$ nm -u longueur.o U strlen$ gcc -c copie2.c$ nm -u copie2.o U putchar U toupperU signifie « undefined » : le symbole est utilisé ici et défini ailleurs. Une longueur qui appelle strlen se trahit en une ligne, et un correcteur peut refuser toute copie qui appelle une fonction absente d'une liste autorisée.
Comment créer ses exercices corrigés avec le Studio de Grademe ?
Le Studio reprend tout ce qui précède, sans script à écrire ni serveur à maintenir. Tu crées un exercice de type programme ou fonction, en C ou en Python, et tu travailles dans le même espace que les élèves : l'énoncé à gauche, le code au centre, les tests à droite.
- Énoncéce que le code reçoit et doit produire
- Solution de référenceprivée, c'est elle qui fait foi
- Testssortie de la référence, acceptée par toi
- Code de départdifférent de la solution
- Partageprivé, lien ou public
La différence avec le script, c'est que tu n'as pas à écrire la sortie attendue à la main. Tu écris l'entrée d'un test (arguments et entrée standard pour un programme, un appel pour une fonction), le Studio exécute ta solution de référence, et tu acceptes sa sortie et son code de retour comme attendus. Le champ reste modifiable, mais un test ne compte comme réussi que si ta solution produit exactement ce qui est attendu. Si tu modifies ensuite la solution, les résultats déjà obtenus sont marqués comme périmés jusqu'à la prochaine exécution, pour qu'aucune coche verte ne survive à un changement. Pour une fonction C, le harnais est généré à partir de la signature et affiche la valeur renvoyée selon son type ; pour un type qu'il ne sait pas afficher, tu écris le test sous forme d'instructions.
Chaque test peut être montré dans l'énoncé comme exemple, ou rester caché : l'élève voit alors s'il passe et comment il échoue (plantage, délai dépassé, code de retour, verdict mémoire), mais ni l'entrée, ni la sortie attendue, ni sa propre sortie. En C, un interrupteur par test ajoute la vérification mémoire avec Valgrind (pour un exercice de fonction, elle porte sur l'ensemble des appels), et tu peux limiter les fonctions et les en-têtes autorisés. Les copies sont compilées et jugées par le même correcteur que les exercices du catalogue Grademe, avec les mêmes options, mais sans rapporter d'XP.
Avant de partager, le Studio vérifie quatre choses : un énoncé complet (titre, résumé, consignes, fichier attendu, et la signature pour une fonction), une solution, au moins deux tests, tous acceptés et réussis avec la version actuelle de la solution, et un code de départ différent de la solution, pour ne pas donner la réponse. Au moment du partage, toute la suite de tests est relancée sur le correcteur.
Un exercice démarre toujours privé. Tu peux le partager par lien dès qu'il est prêt, ou le proposer au catalogue public après avoir certifié que tu as le droit de diffuser son contenu.
Le partage a trois niveaux. Privé, toi seul l'ouvres. Par lien, toute personne qui a le lien peut l'ouvrir et le tenter, avec un compte gratuit pour lancer son code ; le lien n'est pas réservé aux élèves à qui tu l'envoies. Public, l'exercice est listé dans le catalogue de la communauté après une relecture automatique en cinq points (énoncé, tests, solution, code de départ, originalité) ; si tu penses qu'elle se trompe, tu peux la contester et demander l'avis d'une personne. Modifier un exercice public le ramène au partage par lien jusqu'à une nouvelle relecture. Et dès qu'une autre personne l'a réussi, ce qui définit la réussite est figé, pour qu'elle garde le même sens : les tests (entrées, sorties et codes attendus, option Valgrind), la signature, le fichier attendu, les fonctions et en-têtes autorisés. L'énoncé, le code de départ et la solution restent modifiables ; pour changer le reste, on duplique l'exercice.
Tu peux aussi assembler tes exercices en challenge : des niveaux (jusqu'à dix), chacun contenant jusqu'à cinq exercices parmi lesquels chaque tentative en tire un, une durée de 15 minutes à 4 heures, et le nombre de niveaux à réussir. Pour partager un challenge par lien, les exercices qu'il contient doivent être partagés ou publics ; pour le publier, ils doivent tous être publics.
Pour qu'il n'y ait pas de malentendu côté enseignant : pour chaque exercice partagé, le Studio t'indique combien de personnes connectées, toi excepté, l'ont ouvert, essayé et réussi. Il n'y a ni classes, ni groupes d'élèves, ni notes à exporter. Et le Studio fonctionne sur le site uniquement, pas dans l'outil en ligne de commande de Grademe.
Questions fréquentes
Quelle est la différence entre un test de programme et un test de fonction ?
Un test de programme lance l'exécutable avec des arguments et une entrée standard, puis compare sa sortie et son code de retour. Un test de fonction compile le code de l'élève avec un harnais, un main caché qui appelle la fonction et affiche ce qu'elle renvoie.
Combien de tests faut-il par exercice ?
Pas de nombre magique, mais jamais un seul : une réponse écrite en dur passe toujours un test unique. Le Studio en exige au moins deux. Compte au minimum un test par règle de l'énoncé, plus un par cas limite.
Comment empêcher un élève d'afficher directement les réponses attendues ?
Avec des tests cachés, en plus des exemples visibles. Un programme qui recopie les sorties de l'énoncé échoue sur des entrées qu'il n'a jamais vues.
Peut-on reprendre un exercice d'un manuel ou d'une autre école dans le Studio ?
Seulement si tu as le droit de le diffuser, y compris par lien. Pour un usage privé, l'exercice reste visible de toi seul. Avant une publication dans le catalogue public, le Studio te demande de certifier que tu l'as écrit ou que tu as le droit de le partager, et citer l'auteur ne remplace pas son autorisation.
Les tests peuvent-ils vérifier les fuites mémoire ?
Oui, en C, en lançant le test sous Valgrind avec --leak-check=full et --error-exitcode : une fuite fait alors échouer le test même si la sortie est juste. Dans le Studio, c'est un interrupteur par test.
Sources
