Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 322 minIntermédiaire

Reproduire un bug et lire une stack trace

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • qualifier un ticket : bug, évolution, malentendu ou incident ;
  • reproduire un bug avec le plus petit cas possible ;
  • lire une stack trace JavaScript ou PHP et trouver la ligne à examiner ;
  • exploiter les codes HTTP, la console du navigateur et les journaux.

4 exercices corrigés1 schéma

Reproduire un bug, c'est recréer volontairement, sur ta machine, les conditions exactes dans lesquelles il se produit, jusqu'à le voir de tes propres yeux. C'est la première étape de toute correction : tant que tu ne l'as pas reproduit, tu ne sais ni le corriger ni prouver qu'il est corrigé. Ce chapitre montre comment lire et qualifier un ticket, réduire un bug à son cas minimal, puis exploiter les indices que l'application te donne : codes HTTP, console du navigateur, stack trace (pile d'appels) et journaux.

Ce qu'un bon ticket contient

Un bug arrive sous la forme d'un ticket. Voici celui que reçoit l'équipe de Sorbier Frais un lundi matin :

Ticket #231 — « Le total est faux »

Bonjour, le total de ma note ne veut rien dire. Merci de corriger rapidement. Julie

Impossible de travailler avec ça. Quelle note ? Quel total ? Faux comment ? Un ticket exploitable contient au minimum les éléments suivants :

RubriqueCe qu'elle ditPour le ticket #231, après échange avec Julie
TitreLe symptôme en une ligneLe total de la note d'octobre affiche 012.5019.90
EnvironnementVersion de l'application, navigateur, appareil, compteSorbier Frais 1.4.2, Firefox, compte salariée
Étapes pour reproduireLes actions exactes, numérotées1. Se connecter. 2. Ouvrir la note d'octobre 2026. 3. Lire le total en bas de page.
Résultat attenduCe qui aurait dû se passer32.40 € (12,50 € + 19,90 €)
Résultat obtenuCe qui s'est passé012.5019.90 €
FréquenceToujours, parfois, une foisSur toutes les notes depuis vendredi
Pièces jointesCapture d'écran, message d'erreur, heureCapture de l'écran de la note

La paire résultat attendu / résultat obtenu est le cœur du ticket : c'est elle qui prouve qu'il y a un écart, et c'est elle que ton test reprendra plus tard. Les étapes pour reproduire sont la deuxième rubrique essentielle : elles doivent être assez précises pour qu'une personne qui ne connaît pas le problème le déclenche du premier coup.

Qualifier le ticket

Avant d'ouvrir le code, tu qualifies la demande. Une grille aide à ne rien oublier : QQOQCP, pour Qui, Quoi, Où, Quand, Comment, Pourquoi.

QuestionCe que tu cherchesTicket #231
Qui ?Quel profil d'utilisateur, quels droitsUne salariée ; les comptables voient-ils la même chose ?
Quoi ?Le symptôme exactUn total qui ressemble à des nombres collés bout à bout
Où ?Quel écran, quelle route, quel serveurL'écran de la note, donc la route GET /api/notes/:id/resume
Quand ?Depuis quand, dans quelles conditionsDepuis vendredi, sur toutes les notes
Comment ?La séquence qui le déclencheOuvrir n'importe quelle note avec au moins une ligne
Pourquoi ?Une première hypothèse, à vérifier« Depuis vendredi » : qu'a-t-on livré vendredi ?

Le Quand est souvent la question la plus riche. « Depuis vendredi » renvoie à la dernière livraison ; « seulement le dernier jour du mois » oriente vers un calcul de dates ; « seulement pour les notes sans ligne » oriente vers un cas limite.

Tu décides ensuite de la nature de la demande :

  • un bug, si l'application ne fait pas ce que prévoit sa spécification ;
  • une évolution, si elle fait ce qui était prévu mais que le besoin a changé ;
  • un malentendu, si elle fait ce qui était prévu et que l'utilisateur ne le savait pas : la réponse est une explication, et peut-être une amélioration de l'aide ;
  • un incident d'exploitation, si le code n'y est pour rien : serveur arrêté, disque plein, certificat expiré ;
  • un doublon, si le même problème a déjà un ticket : tu les relies.

Enfin, tu fixes la priorité en croisant la gravité (à quel point c'est grave quand ça arrive) et la fréquence ou le nombre de personnes touchées. Un total faux sur toutes les notes, à quelques jours de la paie, est grave et général : c'est une priorité haute, à corriger dans la journée. La même logique, sous le nom de matrice impact × urgence, est détaillée dans le cours ITIL et support.

Reproduire le bug

Reproduire, c'est déclencher le bug toi-même, de façon fiable. Pour y arriver, rapproche ton environnement de celui de l'utilisateur.

  1. La même version du code. Si l'utilisateur est sur la 1.4.2, travaille sur la 1.4.2 : git switch --detach v1.4.2, ou une branche créée à partir de ce tag. Un bug peut avoir été corrigé ou déplacé entre-temps sur main.
  2. Des données comparables. Le bug dépend souvent des données : une note sans ligne, un libellé avec une apostrophe, un montant à zéro, une date le 31. Recrée-les dans ta base de développement, jamais en travaillant sur la base de production.
  3. Le même profil. Connecte-toi avec un compte qui a les mêmes droits que l'utilisateur.
  4. Le même client. Même navigateur, même taille d'écran si le bug est visuel.
  5. Les mêmes étapes, dans le même ordre.

Pour une API, reproduis directement la requête, sans passer par l'interface. Tu élimines ainsi le front des suspects :

bash
curl -s http://localhost:3000/api/notes/42/resume \
  -H "Authorization: Bearer $JETON"
json
{"mois":"2026-10","nbLignes":2,"total":"012.5019.90"}

Le front affiche fidèlement ce que l'API lui envoie : le problème est côté serveur. Tu as divisé la zone de recherche par deux en une commande. Les extensions de VS Code comme REST Client ou Thunder Client, ou un outil comme Bruno ou Postman, font la même chose avec une interface.

Réduire au cas minimal

Une fois le bug reproduit, réduis-le. Si la note de Julie a 37 lignes, cherche la plus petite note qui déclenche encore le bug. Une ligne ? Toujours faux ? Deux lignes ? Tu procèdes par moitiés : tu enlèves la moitié des données, tu regardes si le bug est toujours là, et tu recommences avec la moitié qui le contient.

Pour le ticket #231, deux lignes suffisent : 12,50 € et 19,90 €. Avec une seule ligne de 12,50 €, l'API renvoie "012.50", ce qui ressemble presque à un nombre et passerait inaperçu. Le cas minimal te donne trois choses : une cause plus facile à voir, un test de non-régression tout prêt pour le chapitre 5, et la certitude que tu parles bien du même bug que l'utilisateur.

Les premiers indices : codes HTTP et console du navigateur

Pour une application Web, le code de statut HTTP de la réponse te dit dans quelle couche chercher, avant même d'ouvrir un fichier. Ouvre les outils de développement du navigateur (F12), onglet Réseau, et clique sur la requête en cause.

CodeSensOù chercher en premier
200, 201, 204SuccèsSi le résultat est faux, dans la logique : service, requête SQL, affichage
400Requête invalideLa validation des entrées, ou le front qui envoie de mauvaises données
401Non authentifiéLe jeton ou la session : absent, expiré, mal transmis
403Authentifié mais pas autoriséLe contrôle des rôles et des droits
404Ressource introuvableL'identifiant, la route, ou une ligne absente en base
409, 422Conflit, règle métier refuséeLa règle de gestion : était-ce un refus normal ?
500Erreur interne du serveurUne exception non gérée : lis la stack trace dans les journaux du serveur
502, 503, 504Passerelle, service indisponible, délai dépasséL'infrastructure : application arrêtée, proxy, base trop lente

L'onglet Console affiche les erreurs JavaScript du front. Une erreur fréquente mérite d'être reconnue : SyntaxError: Unexpected token '<' (ou « JSON.parse: unexpected character ») quand le front attend du JSON. Elle signifie presque toujours que le serveur a renvoyé une page HTML à la place, souvent une page d'erreur 404 ou 500. Le < est le début de <!DOCTYPE html>. Regarde la réponse dans l'onglet Réseau : la vraie erreur est dedans.

Lire une stack trace JavaScript

Une stack trace, ou trace de la pile d'appels, est le rapport qu'un programme affiche quand une erreur n'est pas gérée : le type d'erreur, son message et la liste des fonctions en cours d'exécution au moment de l'erreur, de la plus récente à la plus ancienne. C'est l'indice le plus précis dont tu disposes.

Une semaine après le ticket #231, un nouveau ticket arrive : le résumé d'une note plante avec une erreur 500. Les journaux du serveur contiennent :

text
TypeError: Cannot read properties of undefined (reading 'length')
    at resumeNote (/app/services/notes.js:9:27)
    at getResume (/app/controllers/notes.controller.js:8:12)
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
TypeError: Cannot read properties of undefined (reading 'length')① le type d'erreur et son message : on a lu .length sur une valeur undefinedat resumeNote (/app/services/notes.js:9:27)② là où l'erreur éclate : la première ligne qui pointe vers ton codefonction resumeNote, fichier services/notes.js, ligne 9, colonne 27at getResume (/app/controllers/notes.controller.js:8:12)③ la fonction qui l'a appelée : c'est là qu'est née la note sans lignesat process.processTicksAndRejections (node:internal/…)④ du code interne de Node.js : à ignorerOn lit de haut en bas : de l'appel le plus récent au plus ancien.
Lire une stack trace : le message, puis la première ligne qui pointe vers ton code

Elle se lit en quatre temps.

  1. La première ligne donne le type et le message. TypeError : on a utilisé une valeur d'une façon que son type ne permet pas. Le message est précis : on a voulu lire la propriété length de quelque chose qui vaut undefined.
  2. La ligne juste en dessous dit où. resumeNote, fichier services/notes.js, ligne 9, colonne 27. Dans VS Code, un Ctrl+clic sur le chemin t'y emmène. La ligne 9 est nbLignes: note.lignes.length, : c'est donc note.lignes qui vaut undefined.
  3. Les lignes suivantes disent qui a appelé. getResume, dans le contrôleur, ligne 8. C'est là que note a été obtenue, en appelant le dépôt.
  4. Les lignes de Node.js ou des bibliothèques se sautent. node:internal/… est le moteur de Node.js ; une ligne dans node_modules/ est une bibliothèque. Cherche la première ligne qui pointe vers ton propre code : c'est là que commence ton enquête.

La stack trace te donne l'endroit où l'erreur éclate, pas forcément celui où elle naît. Ici, resumeNote plante parce qu'elle reçoit une note sans lignes. La vraie question devient : pourquoi le dépôt renvoie-t-il une note sans lignes ? Un git log -S "lignes" --oneline -- repositories/ répond : un commit récent, perf(notes): ne charge plus les lignes dans trouverParId (#219), a retiré la deuxième requête du dépôt pour accélérer la liste des notes, sans voir que le résumé en dépendait. C'est une régression.

Les types d'erreurs JavaScript à reconnaître :

ErreurCe qu'elle signifieExemple de cause
TypeErrorUne opération impossible sur ce type de valeurLire une propriété de undefined ou null, appeler quelque chose qui n'est pas une fonction
ReferenceErrorUn nom qui n'existe pasUne variable mal orthographiée, utilisée avant sa déclaration, ou un require oublié
SyntaxErrorDu code ou du JSON mal forméUne accolade manquante, JSON.parse sur du HTML
RangeErrorUne valeur hors des limites permisesUne récursion sans fin (Maximum call stack size exceeded), toFixed(200)

Lire une erreur PHP

PHP affiche ses erreurs fatales avec la même logique. Sur Sorbier Véhicules, la page des statistiques plante pour un véhicule neuf, qui n'a encore aucune réservation. Le journal d'erreurs contient :

text
PHP Fatal error:  Uncaught DivisionByZeroError: Division by zero in /var/www/vehicules/src/Service/Statistiques.php:18
Stack trace:
#0 /var/www/vehicules/src/Controleur/StatistiquesControleur.php(27): Sorbier\Vehicules\Service\Statistiques->kmMoyen(Array)
#1 /var/www/vehicules/public/index.php(21): Sorbier\Vehicules\Controleur\StatistiquesControleur->vehicule(12)
#2 {main}
  thrown in /var/www/vehicules/src/Service/Statistiques.php on line 18
  • Uncaught DivisionByZeroError: Division by zero : le type de l'exception et son message. Depuis PHP 8, une division par zéro lève une exception au lieu d'un simple avertissement.
  • in …/Statistiques.php:18 et thrown in … on line 18 : l'endroit où elle a été levée.
  • #0, #1, #2 : la pile, de l'appel le plus récent au point de départ ({main}, le script principal). #0 dit que kmMoyen a été appelée par la ligne 27 du contrôleur, avec un tableau ; #1, que le contrôleur a été appelé par index.php avec l'identifiant de véhicule 12.

La ligne 18 est return $total / count($reservations);. Avec un véhicule sans réservation, count($reservations) vaut 0. La cause est trouvée ; reste à décider ce que doit afficher la page dans ce cas, ce que fait le chapitre 5.

PHP distingue les erreurs fatales, qui arrêtent le script (une exception non attrapée, une classe introuvable), des avertissements (warnings), qui le laissent continuer avec une valeur souvent fausse. Les avertissements sont traîtres : la page s'affiche, mais avec un résultat erroné.

Message PHPCe qu'il signifie
Uncaught TypeError: … must be of type int, string givenUne fonction typée a reçu une valeur du mauvais type
Call to a member function format() on nullOn appelle une méthode sur une variable qui vaut null : un objet attendu n'a pas été trouvé
Warning: Undefined array key "motif"La clé n'existe pas dans le tableau : un champ de formulaire absent, une colonne mal nommée
Warning: Undefined variable $totalUne variable utilisée avant d'avoir reçu une valeur
Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violationLa base refuse l'opération : doublon sur une clé unique, clé étrangère invalide
Allowed memory size of … bytes exhaustedLe script a dépassé sa mémoire : une boucle sans fin, ou une requête qui charge trop de lignes

Les journaux de l'application

Quand le bug se produit chez un utilisateur et pas devant toi, les journaux (logs) sont ta seule mémoire de ce qui s'est passé.

ApplicationOù trouver ses journaux
API Node.jsLa sortie de la console (npm run dev), ou les fichiers et le service de journaux configurés (par exemple avec la bibliothèque pino)
Application PHPLe fichier désigné par error_log dans le php.ini, et le journal d'erreurs du serveur Web (/var/log/apache2/error.log, /var/log/nginx/error.log)
Conteneurs Dockerdocker compose logs -f api
Service systemd sous Linuxjournalctl -u sorbier-frais -f
bash
tail -f /var/log/apache2/error.log                 # suit le journal en direct
grep "DivisionByZeroError" /var/log/apache2/error.log | tail -5
docker compose logs --since 30m api                # les 30 dernières minutes de l'API

Une bonne ligne de journal dit quand (date et heure précises), quoi (le niveau et le message), où (le module ou la route) et pour qui (l'identifiant de l'utilisateur ou de la requête, jamais son mot de passe). Les niveaux standard, repris par les bibliothèques de journalisation comme Monolog en PHP, vont du plus bavard au plus grave : debug, info, notice, warning, error, critical, alert, emergency. En production, on garde en général info et au-dessus.

Pour un bug qui se reproduit mal, ajouter une ligne de journal ciblée est souvent la meilleure stratégie : tu notes les valeurs d'entrée de la fonction suspecte, tu attends que le bug se reproduise, et tu lis ce qu'elle a vraiment reçu.

Du ticket à la cause : le cas #231

Reprenons le ticket du début, avec toute la méthode.

  1. Qualifier. C'est un bug : un total doit être un nombre. Il touche toutes les notes depuis vendredi : priorité haute.
  2. Reproduire. Avec curl, l'API renvoie "total":"012.5019.90". Le front n'y est pour rien.
  3. Réduire. Deux lignes, 12,50 € et 19,90 €, suffisent.
  4. Lire le résultat. 012.5019.90, c'est 0, puis 12.50, puis 19.90, collés. Le + de totalNote a concaténé des chaînes au lieu d'additionner des nombres : 0 + "12.50" donne la chaîne "012.50", à laquelle on colle "19.90".
  5. Vérifier l'hypothèse. Un console.log(typeof lignes[0].montant) provisoire dans le service affiche string. Les montants arrivent bien sous forme de texte.
  6. Remonter à l'origine. Pourquoi du texte ? La colonne montant est en DECIMAL(8,2), et le pilote mysql2 renvoie par défaut les DECIMAL sous forme de chaînes, pour ne pas perdre de précision. Et pourquoi depuis vendredi ? git log --since=2026-10-29 --oneline liste les commits de la semaine, dont celui livré vendredi : le total, calculé jusque-là en SQL avec SUM(montant) et converti, est désormais calculé en JavaScript par totalNote.

Tu tiens la cause, son origine et la date d'apparition. La correction (convertir les montants et calculer en centimes) et le test qui la prouve font l'objet du chapitre 5.

Exercices

Exercice 1 sur 4 Réécrire un ticket Réussi À revoir

Un utilisateur de Sorbier Véhicules envoie : « Impossible de réserver, ça bug !!! » Après un appel, tu apprends qu'il s'appelle Karim, qu'il est commercial, qu'il a essayé ce matin vers 9 h de réserver la Clio GA-112-BC pour le jeudi 19 novembre 2026 de 14 h à 16 h, et que la page a affiché « Ce véhicule n'est pas disponible sur ce créneau ». Le planning montre pourtant que la Clio est rendue à 14 h par une collègue. Il utilise Chrome. L'application est en version 2.1.0.

Réécris le ticket avec les rubriques d'un bon rapport de bug.

Voir la réponseMasquer la réponse de l'exercice 1
  • Titre : Réservation refusée sur un créneau qui commence à l'heure exacte du retour précédent.
  • Environnement : Sorbier Véhicules 2.1.0, Chrome, compte commercial (Karim).
  • Étapes pour reproduire : 1. Avoir une réservation de la Clio GA-112-BC qui se termine le 19/11/2026 à 14 h 00. 2. Se connecter avec un compte commercial. 3. Demander la Clio GA-112-BC le 19/11/2026 de 14 h 00 à 16 h 00.
  • Résultat attendu : la réservation est acceptée, puisque le véhicule est rendu à 14 h 00.
  • Résultat obtenu : message « Ce véhicule n'est pas disponible sur ce créneau ».
  • Fréquence : à vérifier ; hypothèse : à chaque fois qu'un créneau commence exactement à la fin d'un autre.
  • Pièce jointe : capture du message et du planning.

Le ticket ne contient pas d'hypothèse sur la cause dans les rubriques factuelles : c'est le rôle de l'analyse, pas du rapport.

Tu avais trouvé ?

Exercice 2 sur 4 Lire une stack trace JavaScript Réussi À revoir

L'export CSV de Sorbier Frais renvoie une erreur 500. Le journal contient :

text
TypeError: lignes.map is not a function
    at versCsv (/app/services/export.js:14:22)
    at exporterMois (/app/services/export.js:31:10)
    at getExport (/app/controllers/export.controller.js:9:21)
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
  1. Dans quel fichier et à quelle ligne l'erreur se produit-elle ?
  2. Que signifie le message ?
  3. Dans quelle fonction chercheras-tu d'où vient la mauvaise valeur, et pourquoi ?
  4. Quelle valeur de lignes pourrait provoquer exactement ce message ? Donne deux possibilités.
Voir la réponseMasquer la réponse de l'exercice 2
  1. services/export.js, ligne 14, dans la fonction versCsv.
  2. On appelle lignes.map(…), mais lignes n'a pas de méthode map : ce n'est pas un tableau. Ce n'est pas non plus undefined ou null, sinon le message serait « Cannot read properties of undefined (reading 'map') ».
  3. Dans exporterMois, ligne 31 du même fichier : c'est elle qui a appelé versCsv et lui a passé lignes. Si la valeur vient elle-même de plus haut, tu remontes ensuite à getExport.
  4. Par exemple un objet au lieu d'un tableau (le résultat brut d'une requête mysql2, [lignes, champs], mal déstructuré, ou un objet { lignes: [...] } passé tel quel), une chaîne de caractères (un JSON pas encore décodé), ou un nombre.

Tu avais trouvé ?

Exercice 3 sur 4 Lire une erreur PHP Réussi À revoir

text
PHP Fatal error:  Uncaught Error: Call to a member function format() on null in /var/www/vehicules/src/Vue/Planning.php:42
Stack trace:
#0 /var/www/vehicules/src/Controleur/ReservationControleur.php(58): Sorbier\Vehicules\Vue\Planning->afficher(Array)
#1 /var/www/vehicules/public/index.php(19): Sorbier\Vehicules\Controleur\ReservationControleur->planning()
#2 {main}
  thrown in /var/www/vehicules/src/Vue/Planning.php on line 42

La ligne 42 est echo $reservation->retourEffectif()->format('H:i');.

  1. Que s'est-il passé ?
  2. Dans quel cas métier retourEffectif() peut-il renvoyer null ?
  3. Quelle question poses-tu avant de corriger ?
Voir la réponseMasquer la réponse de l'exercice 3
  1. retourEffectif() a renvoyé null, et l'on a appelé la méthode format() sur null.
  2. Pour une réservation dont le véhicule n'est pas encore rendu : la date de retour effectif n'existe pas encore. Le planning plante dès qu'une réservation est en cours, ce qui est le cas le plus courant.
  3. « Que doit afficher le planning pour un véhicule pas encore rendu ? » : l'heure de retour prévue, la mention « en cours », un tiret ? C'est une décision fonctionnelle, à valider avec le responsable du parc, avant d'écrire ?->format('H:i') ?? 'en cours' ou autre chose.

Tu avais trouvé ?

Exercice 4 sur 4 Quel indice, quelle couche ? Réussi À revoir

Pour chaque situation, dis où tu cherches en premier.

  1. Le bouton « Valider » ne fait rien ; l'onglet Réseau ne montre aucune requête.
  2. La requête de validation part et reçoit une réponse 403.
  3. La requête reçoit une réponse 200, mais la note reste à l'état « clôturée ».
  4. La console affiche SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON.
  5. La requête reçoit une 504 au bout de 60 secondes.
Voir la réponseMasquer la réponse de l'exercice 4
  1. Dans le front : l'événement du bouton n'est pas branché, ou une erreur JavaScript l'interrompt avant l'envoi. Regarde l'onglet Console.
  2. Dans le contrôle des droits côté serveur : le compte est authentifié mais son rôle n'autorise pas l'action. Est-ce normal (un salarié qui valide sa propre note) ou un bug dans la vérification du rôle ?
  3. Dans la logique du serveur : la route répond « succès » sans avoir modifié la base. Une requête UPDATE qui ne touche aucune ligne (mauvais identifiant, condition trop stricte), une transaction jamais validée, ou un await oublié.
  4. Le serveur a renvoyé une page HTML au lieu de JSON : ouvre la réponse dans l'onglet Réseau. Souvent une 404 (mauvaise URL d'API) ou une page d'erreur du serveur.
  5. Dans l'infrastructure ou la performance : le proxy a abandonné en attendant l'application. Une requête SQL très lente, un verrou en base ou un service externe qui ne répond pas.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Déboguer avec VS Code : points d'arrêt, pas à pas, XdebugRevoir le sommaire du cours