Chapitre 423 minIntermédiaire
Déboguer avec VS Code : points d'arrêt, pas à pas, Xdebug
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- mener un débogage par hypothèses plutôt qu'au hasard ;
- poser des points d'arrêt simples, conditionnels et des logpoints ;
- avancer pas à pas et lire variables, espions et pile d'appels ;
- déboguer une API Node.js et une application PHP avec Xdebug ;
- retrouver le commit fautif avec git bisect.
4 exercices corrigés1 schéma
Déboguer, c'est trouver la cause exacte d'un défaut en observant le programme pendant qu'il s'exécute. Le débogueur est l'outil qui le permet : il arrête le programme sur une ligne choisie (un point d'arrêt), montre la valeur de chaque variable à cet instant, puis le fait avancer instruction par instruction. Ce chapitre présente une méthode de débogage par hypothèses, le débogueur de VS Code pour Node.js et pour PHP avec Xdebug, les points d'arrêt conditionnels, et git bisect pour retrouver le commit qui a introduit un bug.
Déboguer avec méthode
Le mauvais débogage consiste à modifier le code un peu au hasard jusqu'à ce que le symptôme disparaisse. On finit par « réparer » sans comprendre, et le bug revient ailleurs. Le bon débogage ressemble à une démarche scientifique.
- Observer. Quel est le symptôme exact ? Le bug est reproduit et réduit au cas minimal (chapitre 3).
- Formuler une hypothèse. « Le préfixe du mois est mal construit », « la requête SQL ne renvoie pas les lignes », « le montant est une chaîne ».
- La tester par une expérience qui peut la contredire : un point d'arrêt pour lire une valeur, une requête SQL lancée à la main, un test unitaire sur la fonction suspecte.
- Conclure. L'hypothèse est confirmée : tu tiens la cause. Elle est réfutée : tu as éliminé une piste, tu en formules une autre.
Deux règles complètent la méthode. Une seule modification à la fois : si tu changes trois choses et que le bug disparaît, tu ne sais pas laquelle l'a corrigé, ni si les deux autres n'ont pas cassé autre chose. Écris ce que tu sais : un tableau « hypothèse, expérience, résultat » sur une feuille t'évite de tourner en rond et te servira à expliquer ta démarche.
| Hypothèse | Expérience | Résultat |
|---|---|---|
| Le front affiche mal un total juste | Appeler l'API avec curl | Réfutée : l'API renvoie déjà "012.5019.90" |
montant est une chaîne | Point d'arrêt dans totalNote, lire typeof ligne.montant | Confirmée : "string" |
console.log et var_dump : utiles mais limités
Afficher une valeur est le réflexe le plus simple. En JavaScript, console.log(valeur) ; pour un tableau d'objets, console.table(lignes) est plus lisible ; console.trace() affiche la pile d'appels sans arrêter le programme. En PHP, var_dump($valeur) affiche le type et la valeur, print_r($tableau) une version plus compacte, et error_log(print_r($tableau, true)) écrit dans le journal au lieu de la page.
console.log('lignes reçues', lignes); // l'objet complet
console.log({ annee, mois, prefixe }); // nom et valeur de chaque variable d'un coup
console.table(lignes, ['date', 'montant']); // un tableau lisible, deux colonnes
Ces affichages ont leur place, surtout dans un code qui tourne sur un serveur où tu ne peux pas brancher de débogueur. Mais ils ont trois défauts : il faut deviner à l'avance quoi afficher, chaque nouvelle question oblige à modifier le code et à relancer, et on finit par en oublier un dans un commit. Le débogueur répond à toutes les questions sans toucher au code.
Le débogueur de VS Code en cinq notions
VS Code intègre un débogueur pour JavaScript et Node.js, et pour PHP grâce à une extension. Cinq notions suffisent pour t'en servir.
- Le point d'arrêt. Clique dans la marge, à gauche du numéro de ligne (ou
F9sur la ligne) : un point rouge apparaît. Quand l'exécution atteint cette ligne, le programme s'arrête avant de l'exécuter. - Le pas à pas. Une fois arrêté, tu fais avancer le programme à ton rythme avec la barre de débogage.
- Les variables. Le panneau Variables montre toutes les variables locales à cet instant, avec leur valeur. Tu peux déplier les objets et les tableaux. Survoler une variable dans l'éditeur affiche aussi sa valeur.
- Les espions (Watch). Tu y écris des expressions qui sont recalculées à chaque pas :
prefixe.length,lignes.filter(l => l.montant > 100).length,typeof ligne.montant. - La pile d'appels (Call Stack). La liste des fonctions en cours, comme dans une stack trace, mais vivante : clique sur une ligne pour voir les variables de la fonction appelante.
| Commande | Raccourci (Windows, Linux) | Effet |
|---|---|---|
| Démarrer, continuer | F5 | Lance le débogage, ou repart jusqu'au prochain point d'arrêt |
| Pas à pas principal (Step Over) | F10 | Exécute la ligne courante, sans entrer dans les fonctions qu'elle appelle |
| Pas à pas détaillé (Step Into) | F11 | Entre dans la fonction appelée sur la ligne courante |
| Pas à pas sortant (Step Out) | Maj+F11 | Termine la fonction courante et s'arrête dans celle qui l'a appelée |
| Basculer un point d'arrêt | F9 | Pose ou retire un point d'arrêt sur la ligne |
| Arrêter | Maj+F5 | Termine la session de débogage |
La console de débogage (Debug Console), en bas de l'écran, accepte n'importe quelle expression pendant que le programme est arrêté : tu peux y appeler une fonction, tester une condition, ou essayer une correction (`${annee}-${String(mois).padStart(2, '0')}`) avant de l'écrire dans le fichier.
Déboguer une API Node.js
Le plus simple, sans aucune configuration : ouvre la palette de commandes (Ctrl+Maj+P), choisis Debug: JavaScript Debug Terminal, et lance ton application dans ce terminal comme d'habitude.
npm run dev # l'API démarre, les points d'arrêt sont actifs
npx jest recap # ou : les tests dont le nom contient « recap », sous débogueur
Tout programme Node.js lancé depuis ce terminal se connecte au débogueur. C'est aussi la façon la plus rapide de déboguer un test qui échoue.
Pour un lancement en un clic avec F5, crée le fichier .vscode/launch.json (onglet Exécuter et déboguer, « créer un fichier launch.json ») :
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Lancer l'API",
"program": "${workspaceFolder}/server.js",
"envFile": "${workspaceFolder}/.env",
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"]
}
]
}
Tu peux aussi écrire l'instruction debugger; dans ton code : quand un débogueur est connecté, l'exécution s'arrête dessus comme sur un point d'arrêt. C'est pratique dans un code généré ou difficile à atteindre, à condition de ne pas l'oublier dans un commit. Enfin, node --inspect server.js ouvre le serveur à n'importe quel débogueur, y compris les outils de développement de Chrome (adresse chrome://inspect).
Un cas complet : le récapitulatif mensuel
Ticket #238 de Sorbier Frais : « Mon récapitulatif de mars est vide alors que j'ai saisi deux repas. Et celui de janvier contient des frais d'octobre et de novembre ! Octobre, lui, est juste. »
Le récapitulatif s'appuie sur cette fonction :
// services/recap.js
function lignesDuMois(lignes, annee, mois) {
const prefixe = `${annee}-${mois}`;
return lignes.filter((ligne) => ligne.date.startsWith(prefixe));
}
module.exports = { lignesDuMois };
Les dates des lignes sont des chaînes au format AAAA-MM-JJ, comme '2026-03-17'. Le symptôme est déroutant : octobre marche, mars est vide, janvier déborde. Plutôt que de relire le code dix fois, tu poses un point d'arrêt sur la ligne 4 (return …), tu lances l'API depuis le JavaScript Debug Terminal (npm run dev) et tu ouvres le récapitulatif de mars, et le programme s'arrête.
Le panneau Variables affiche : annee = 2026, mois = 3, prefixe = "2026-3". La cause saute aux yeux : il manque le zéro. Aucune date ne commence par 2026-3 (elles commencent par 2026-03), d'où un mars vide. Pour janvier, le préfixe vaut 2026-1, et '2026-10-02' comme '2026-11-03' commencent bien par 2026-1 : d'où les frais d'octobre et de novembre, alors que les vrais frais de janvier ('2026-01-15') disparaissent. Pour octobre, 2026-10 est juste par chance, parce que le mois a déjà deux chiffres.
Tu testes la correction dans la console de débogage : `${annee}-${String(mois).padStart(2, '0')}` renvoie "2026-03". Tu peux alors corriger :
// services/recap.js
function lignesDuMois(lignes, annee, mois) {
const prefixe = `${annee}-${String(mois).padStart(2, '0')}`;
return lignes.filter((ligne) => ligne.date.startsWith(prefixe));
}
Et le test de non-régression couvre les trois situations du ticket :
// tests/recap.test.js
const { lignesDuMois } = require('../services/recap');
const lignes = [
{ date: '2026-01-15', montant: 12 },
{ date: '2026-03-04', montant: 18.4 },
{ date: '2026-03-17', montant: 42 },
{ date: '2026-10-02', montant: 89 },
{ date: '2026-11-03', montant: 9.5 },
];
test('un mois à un chiffre trouve ses lignes (ticket #238)', () => {
expect(lignesDuMois(lignes, 2026, 3)).toHaveLength(2);
});
test('janvier ne contient pas les lignes d\'octobre ni de novembre', () => {
expect(lignesDuMois(lignes, 2026, 1).map((l) => l.date)).toEqual(['2026-01-15']);
});
test('un mois à deux chiffres fonctionne toujours', () => {
expect(lignesDuMois(lignes, 2026, 10)).toHaveLength(1);
});
Avant la correction, les deux premiers tests échouent (0 ligne au lieu de 2 pour mars, les dates d'octobre et de novembre au lieu de celle de janvier) ; après, les trois passent.
Points d'arrêt conditionnels, logpoints et exceptions
Un point d'arrêt dans une boucle qui tourne mille fois s'arrête mille fois. Fais un clic droit sur le point rouge, ou dans la marge, pour choisir une variante.
| Variante | Quand s'en servir | Exemple |
|---|---|---|
| Point d'arrêt conditionnel | Ne s'arrêter que dans le cas qui pose problème | Condition ligne.date.startsWith('2026-10') dans la boucle du filtre |
| Nombre d'occurrences (Hit Count) | S'arrêter au n-ième passage | 37 pour la 37e ligne d'une note |
| Logpoint | Afficher une valeur sans arrêter le programme ni modifier le code | Message ligne {ligne.date} → préfixe {prefixe} |
| Arrêt sur exception | S'arrêter là où une erreur est levée, avant qu'elle ne remonte | Cocher Uncaught Exceptions dans le panneau Breakpoints |
Le logpoint est un console.log qui ne touche pas au fichier : impossible de l'oublier dans un commit. L'arrêt sur exception est idéal pour une erreur 500 : le programme s'arrête sur la ligne qui lève l'exception, avec toutes les variables visibles, au lieu de te laisser une stack trace après coup.
Déboguer du PHP avec Xdebug
PHP s'exécute dans le serveur Web, pas dans VS Code. Pour l'arrêter sur un point d'arrêt, il faut une extension de PHP, Xdebug, qui se connecte à l'éditeur, et l'extension PHP Debug dans VS Code, qui l'écoute.
1. Installer Xdebug. Selon ton environnement :
sudo apt install php8.4-xdebug # Debian ou Ubuntu, avec le paquet de ta version de PHP
pecl install xdebug && docker-php-ext-enable xdebug # dans le Dockerfile d'une image php officielle
Sous Windows, l'assistant du site officiel d'Xdebug (page Installation Wizard) indique la DLL à télécharger d'après la sortie de php -i. Les piles comme Laragon ou XAMPP fournissent souvent Xdebug, à activer dans leur configuration.
2. Le configurer dans le php.ini, ou dans un fichier xdebug.ini du dossier de configuration de PHP :
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_port=9003
xdebug.mode=debug active le débogage pas à pas (Xdebug a d'autres modes : develop pour des messages d'erreur enrichis, coverage pour la couverture des tests). xdebug.start_with_request=yes connecte chaque requête au débogueur ; si cela ralentit trop ton application, la valeur trigger ne le fait que pour les requêtes qui portent le déclencheur XDEBUG_TRIGGER (une extension de navigateur l'ajoute en un clic). Le port 9003 est celui d'Xdebug 3. Vérifie l'installation avec php -v, qui doit afficher « with Xdebug v3… », ou avec la fonction xdebug_info() dans une page de test.
3. Configurer VS Code. Installe l'extension PHP Debug (éditeur : Xdebug), puis crée .vscode/launch.json :
{
"version": "0.2.0",
"configurations": [
{
"name": "Écouter Xdebug",
"type": "php",
"request": "launch",
"port": 9003
}
]
}
4. Déboguer. Lance la configuration « Écouter Xdebug » avec F5 : VS Code attend. Pose un point d'arrêt dans src/Service/Statistiques.php, puis ouvre la page des statistiques dans le navigateur. La page reste en chargement : l'exécution est arrêtée dans VS Code, avec les variables, les espions et la pile d'appels, exactement comme pour Node.js.
Pour un script en ligne de commande (un test PHPUnit, un script d'import), lance-le dans le terminal pendant que VS Code écoute : vendor/bin/phpunit tests/StatistiquesTest.php s'arrêtera sur tes points d'arrêt.
Déboguer le front dans le navigateur
Le code JavaScript du front s'exécute dans le navigateur. Ses outils de développement (F12) ont leur propre débogueur, dans l'onglet Sources (Chrome, Edge) ou Débogueur (Firefox) : mêmes points d'arrêt, même pas à pas, mêmes variables. Pour une application React, l'extension React Developer Tools montre en plus les composants, leurs propriétés (props) et leur état. VS Code peut aussi piloter le débogueur du navigateur avec une configuration de type chrome ou msedge, si tu préfères rester dans l'éditeur.
Retrouver le commit fautif avec git bisect
Une régression a une particularité précieuse : il existe une version où tout marchait. Quand tu ne vois pas quelle modification a cassé quoi, git bisect cherche le commit fautif à ta place, par dichotomie : à chaque étape, il coupe l'intervalle de commits en deux.
git bisect start
git bisect bad # le commit actuel a le bug
git bisect good v1.3.0 # cette version ne l'avait pas
# Git te place sur un commit au milieu. Tu testes, puis tu réponds :
git bisect good # ce commit-ci n'a pas le bug
git bisect bad # ou : ce commit-ci a le bug
# … quelques étapes plus tard, Git affiche « <sha> is the first bad commit »
git bisect reset # revient là où tu étais
À chaque réponse, le nombre de commits suspects est divisé par deux. Sur 100 commits, 7 tests suffisent (2⁷ = 128) ; sur 1 000 commits, 10 (2¹⁰ = 1 024).
Si tu as un test automatique qui révèle le bug, Git peut même tout faire seul : git bisect run npm test -- recap lance la commande sur chaque commit testé, et lit son code de retour (0 : bon commit ; de 1 à 127, sauf 125 : mauvais commit ; 125 : commit impossible à tester, à sauter). Le cours Git et GitHub présente aussi cette commande.
Quand tu bloques
Il arrive de tourner une heure autour d'un bug. Avant de t'acharner, essaie dans l'ordre :
- Expliquer le problème à voix haute, ligne par ligne, à un collègue, ou à un canard en plastique posé sur ton bureau. C'est la méthode du canard en plastique (rubber duck debugging) : en formulant ce que fait chaque ligne, tu tombes souvent sur celle qui ne fait pas ce que tu crois.
- Relire tes certitudes. Le bug se cache souvent dans ce que tu n'as pas vérifié parce que « ça ne peut pas être ça ». Le fichier modifié est-il bien celui qui s'exécute ? Le serveur a-t-il redémarré ? Le cache du navigateur est-il vidé ?
- Faire une pause. Dix minutes loin de l'écran débloquent plus de bugs qu'on ne le croit.
- Demander de l'aide avec une reproduction minimale : le plus petit code et les plus petites données qui montrent le bug, ce que tu attends, ce que tu obtiens et ce que tu as déjà essayé. Souvent, préparer cette reproduction suffit à trouver la cause.
Exercices
Exercice 1 sur 4 Trouver le bug : la boucle Réussi À revoir
Cette fonction de Sorbier Frais plante avec TypeError: Cannot read properties of undefined (reading 'categorie') dès qu'on l'appelle :
function totalRepas(lignes) {
let total = 0;
for (let i = 0; i <= lignes.length; i++) {
if (lignes[i].categorie === 'repas') {
total += lignes[i].montant;
}
}
return total;
}
- Où poserais-tu un point d'arrêt, et quelles variables regarderais-tu ?
- Quel est le bug ?
- La fonction plante-t-elle aussi pour une note sans aucune ligne ? Justifie.
Voir la réponseMasquer la réponse de l'exercice 1
- Un point d'arrêt sur la ligne du
if, en regardanti,lignes.lengthetlignes[i]. Au dernier passage,ivautlignes.lengthetlignes[i]vautundefined. - La condition de boucle
i <= lignes.lengthfait un tour de trop : les indices d'un tableau vont de0àlignes.length - 1. Il fauti < lignes.length, ou mieux une bouclefor (const ligne of lignes)qui supprime le risque. - Oui. Avec un tableau vide,
lignes.lengthvaut 0 et la condition0 <= 0est vraie : la boucle fait un tour aveci = 0, etlignes[0]vautundefined. Le message est le même. Le nom de la propriété dans le message ('categorie') te dit quelle expression vautundefined: celle qui litcategorie, c'est-à-direlignes[i].
Tu avais trouvé ?
Exercice 2 sur 4 Trouver le bug : la condition toujours vraie Réussi À revoir
Dans Sorbier Frais, toutes les lignes d'une note sont plafonnées à 20 €, même les nuits d'hôtel à 89 €. Voici le code :
function plafonner(ligne) {
if (ligne.categorie = 'repas') {
return Math.min(ligne.montant, 20);
}
return ligne.montant;
}
- Explique le bug.
- Qu'afficherait le panneau Variables juste après le
if, pour une ligne d'hôtel ? - Comment éviter ce type de bug à l'avenir ?
Voir la réponseMasquer la réponse de l'exercice 2
ligne.categorie = 'repas'est une affectation, pas une comparaison. Elle remplace la catégorie par'repas'et vaut'repas', une chaîne non vide, donc vraie. Toutes les lignes entrent dans leif.ligne.categorievaudrait"repas": la ligne d'hôtel a été modifiée au passage. Le bug corrompt aussi les données, ce qui peut fausser d'autres calculs plus loin.- Toujours comparer avec
===. Un linter le signale : la règleno-cond-assignd'ESLint, active dans la configuration recommandée, refuse une affectation dans une condition. Un test sur une ligne d'hôtel de 89 € l'aurait aussi révélé.
Tu avais trouvé ?
Exercice 3 sur 4 Quel outil ? Réussi À revoir
Pour chaque situation, choisis l'outil le plus adapté : point d'arrêt simple, point d'arrêt conditionnel, logpoint, arrêt sur exception, git bisect, journal de production.
- Une boucle sur 5 000 réservations calcule un total faux, et tu soupçonnes la réservation n° 4 812.
- L'export marchait dans la version 1.2.0, il est vide dans la 1.6.0, et 140 commits les séparent.
- Une erreur 500 survient sur la route de validation, mais la stack trace est tronquée.
- Tu veux voir les valeurs successives d'une variable dans une boucle sans arrêter le programme ni modifier le fichier.
- Le bug ne se produit que chez un client, jamais sur ta machine.
- Combien d'étapes de
git bisectfaut-il au plus dans la situation 2 ?
Voir la réponseMasquer la réponse de l'exercice 3
- Un point d'arrêt conditionnel, avec la condition
reservation.id === 4812. git bisect, avecgit bisect good v1.2.0etgit bisect bad v1.6.0.- Un arrêt sur exception (Uncaught Exceptions) : le débogueur s'arrête sur la ligne qui lève l'erreur, avec les variables.
- Un logpoint.
- Le journal de production, avec au besoin une ligne de journal ciblée sur les valeurs d'entrée de la fonction suspecte. On ne branche pas de débogueur sur un serveur de production.
- 8 étapes au plus : 2⁷ = 128 est inférieur à 140, 2⁸ = 256 est supérieur. Chaque réponse divise l'intervalle par deux.
Tu avais trouvé ?
Exercice 4 sur 4 Diagnostiquer une configuration Xdebug Réussi À revoir
Une étudiante a installé Xdebug et l'extension PHP Debug. VS Code écoute, mais ses points d'arrêt restent gris (non vérifiés) et rien ne s'arrête. Son application PHP tourne dans un conteneur Docker, avec le code monté dans /var/www/html. Son xdebug.ini contient zend_extension=xdebug et xdebug.mode=debug.
Quelles vérifications et quelles corrections proposes-tu ?
Voir la réponseMasquer la réponse de l'exercice 4
- Vérifier qu'Xdebug est chargé dans le conteneur :
docker compose exec web php -vdoit afficher « with Xdebug v3… ». - Ajouter
xdebug.start_with_request=yes(outriggeravec le déclencheur dans le navigateur) : sans cela, Xdebug ne se connecte pas au débogueur. - Ajouter
xdebug.client_host=host.docker.internal: par défaut, Xdebug cherche le débogueur surlocalhost, c'est-à-dire le conteneur lui-même. Sous Linux, déclarer aussihost.docker.internaldansextra_hosts. - Vérifier le port : 9003 des deux côtés (
xdebug.client_portetlaunch.json). - Ajouter dans
launch.jsonla correspondance des chemins :"pathMappings": { "/var/www/html": "${workspaceFolder}" }. C'est la cause la plus probable des points d'arrêt gris.
Après chaque modification du .ini, redémarrer le conteneur pour que PHP relise sa configuration.
Tu avais trouvé ?