Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 528 minIntermédiaire

Corriger un bug sans régression : le test de non-régression

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

À la fin de ce chapitre, tu sauras

  • écrire le test qui reproduit un bug avant de le corriger ;
  • corriger la cause plutôt que le symptôme ;
  • gérer les erreurs et les exceptions au bon niveau ;
  • prouver l'absence de régression avec la suite de tests ;
  • livrer un correctif propre sur sa branche.

4 exercices corrigés1 schéma

Un test de non-régression vérifie qu'une modification du code n'a pas cassé ce qui fonctionnait déjà. En maintenance corrective, il a un deuxième rôle : le test écrit pour reproduire un bug reste dans la suite de tests, et empêche ce bug de revenir. Ce chapitre donne la méthode complète pour corriger un défaut sans rien casser : une branche dédiée, un test qui échoue, une correction de la cause, toute la suite au vert, puis un commit qui raconte ce qui s'est passé. Il traite aussi la gestion des erreurs et des exceptions, qui fait partie de presque toutes les corrections.

Régression et test de non-régression

Une régression est une fonctionnalité qui marchait et qui ne marche plus après une modification. La modification du ticket #219 a accéléré la liste des notes, et elle a cassé le résumé d'une note (chapitre 3) : régression. Elles arrivent parce que le code est interconnecté. Une fonction modifiée ici est appelée là-bas, par un code que tu n'as pas lu.

Un test de non-régression n'est pas une technique de test à part. C'est un usage : on garde tous les tests qui passaient et on les relance après chaque modification. Si l'un d'eux échoue, la modification a cassé quelque chose. On parle indifféremment de tests de régression ou de non-régression : le premier terme insiste sur ce que l'on cherche, le second sur ce que l'on veut garantir. Le chapitre Tests de non-régression du cours Tests reprend la méthode en JavaScript, en C# et en PHP.

Ces tests ne servent que s'ils sont automatisés. Rejouer à la main trente scénarios après chaque correction, personne ne le fait longtemps. Une suite lancée par npm test ou composer test, et relancée automatiquement à chaque push par l'intégration continue, le fait en quelques secondes, à chaque fois.

La méthode en cinq étapes

1. Branche2. Test rouge3. Correction4. Test vert5. Suite vertefix/231reproduit le bugde la causebug corrigénon-régressionun ancien test échoue : la correction a cassé autre chosepuis commit fix(…), pull request et livraison
Corriger un bug : du test rouge à la suite verte
  1. Créer une branche pour le correctif.
  2. Écrire un test qui reproduit le bug. Il doit échouer : il est rouge.
  3. Corriger la cause, à l'endroit de la cause.
  4. Relancer le test : il passe, il est vert.
  5. Relancer toute la suite : tous les anciens tests passent encore. Puis commit, relecture et fusion.

On reconnaît le cycle « rouge, vert » du développement piloté par les tests (TDD). Appliqué à un bug, il a un avantage décisif : le test rouge prouve que tu as bien reproduit ce bug. Un test qui passe du premier coup ne prouve rien : soit le bug n'est pas là où tu crois, soit ton test ne vérifie pas la bonne chose.

Étape 1 : une branche pour le correctif

bash
git switch main
git pull
git switch -c fix/231-total-note

Le nom de la branche dit le type de travail, le ticket et le sujet. Une branche par correctif permet de le relire seul, de le livrer seul, et de l'annuler seul si besoin. Si le bug touche une version déjà livrée qui doit recevoir un correctif urgent sans embarquer le travail en cours, la branche part du tag de cette version : c'est le hotfix, détaillé au chapitre 9.

Étape 2 : le test qui reproduit le bug

Le cas minimal trouvé au chapitre 3 devient un test. On le nomme d'après le comportement attendu, avec le numéro de ticket pour la traçabilité :

js
// tests/notes.test.js
const { totalNote } = require('../services/notes');

describe('totalNote', () => {
  test('additionne des montants reçus sous forme de chaînes (ticket #231)', () => {
    const lignes = [{ montant: '12.50' }, { montant: '19.90' }, { montant: '7.30' }];
    expect(totalNote(lignes)).toBe(39.7);
  });
});

Les montants sont des chaînes, comme ceux que renvoie réellement mysql2. Un test écrit avec des nombres ({ montant: 12.5 }) passerait du premier coup et ne prouverait rien : c'est la donnée réelle qui déclenche le bug. On lance le test, et il échoue comme prévu :

text
 FAIL  tests/notes.test.js
  totalNote
    ✕ additionne des montants reçus sous forme de chaînes (ticket #231) (3 ms)

  ● totalNote › additionne des montants reçus sous forme de chaînes (ticket #231)

    expect(received).toBe(expected) // Object.is equality

    Expected: 39.7
    Received: "012.5019.907.30"

Ce message rouge est une bonne nouvelle : le bug est capturé dans un test exécutable. Tant qu'il est rouge, le bug existe ; quand il passera au vert, la correction sera prouvée.

Étape 3 : corriger la cause, pas le symptôme

Plusieurs corrections font disparaître le symptôme. Elles ne se valent pas.

Correction proposéeCe qu'elle faitVerdict
Dans le front, parseFloat(total) avant l'affichageparseFloat("012.5019.907.30") donne 12.5019, un nombre qui n'a plus rien à voir avec la noteMasque le symptôme, et produit un total faux sans erreur visible : pire qu'avant
L'option decimalNumbers: true du pool mysql2Toutes les colonnes DECIMAL de l'application arrivent désormais en nombresCorrige la cause, mais change le comportement de toutes les requêtes : un code qui comptait sur des chaînes peut casser ailleurs. À décider en équipe, avec toute la suite de tests
Dans le service, convertir chaque montant et calculer en centimesLe calcul accepte des chaînes ou des nombres, et ne cumule pas d'erreurs d'arrondiCorrige la cause là où elle agit, sans effet de bord sur le reste

On retient la troisième. Les montants sont convertis, contrôlés, puis additionnés en centimes, des entiers :

js
// services/notes.js
function enCentimes(montant) {
  const valeur = Number(montant);
  if (!Number.isFinite(valeur)) {
    throw new TypeError(`Montant invalide : ${montant}`);
  }
  return Math.round(valeur * 100);
}

function totalNote(lignes = []) {
  const centimes = lignes.reduce((total, ligne) => total + enCentimes(ligne.montant), 0);
  return centimes / 100;
}

module.exports = { enCentimes, totalNote };

Pourquoi des centimes ? Parce que les nombres à virgule de JavaScript (et de PHP, et de presque tous les langages) sont stockés en binaire, et que la plupart des décimaux n'y ont pas de valeur exacte. 12.5 + 19.9 + 7.3 vaut 39.699999999999996, et 0.1 + 0.2 vaut 0.30000000000000004. Sur un total de note, l'écart est minuscule, mais il suffit à faire échouer une comparaison avec 39.7 ou à afficher un centime de trop après arrondi. En convertissant chaque montant en un entier de centimes (Math.round(12.5 * 100) vaut 1250), on additionne des entiers, qui sont exacts, et on ne divise par 100 qu'à la fin.

Le contrôle Number.isFinite refuse un montant illisible, comme "12,50" avec une virgule (Number("12,50") vaut NaN), au lieu de produire un total faux en silence. C'est le principe de l'échec rapide : mieux vaut une erreur claire tout de suite qu'une donnée fausse découverte trois mois plus tard dans la comptabilité.

Étape 4 : toute la suite au vert

On relance d'abord le nouveau test, qui passe, puis toute la suite :

text
 PASS  tests/notes.test.js
 PASS  tests/recap.test.js
 PASS  tests/export.test.js
 PASS  tests/api/notes.api.test.js

Tests:       42 passed, 42 total

Les 41 anciens tests passent toujours, et le nouveau aussi : la correction n'a rien cassé de ce qui était testé. C'est ta preuve de non-régression.

Si un ancien test échoue, ne te précipite pas pour le modifier. Deux cas sont possibles. Soit ta correction a cassé un comportement voulu : c'est une régression, tu reprends la correction. Soit l'ancien test vérifiait un comportement faux (par exemple, il attendait la chaîne "012.50") : tu le corriges, et tu l'expliques dans le message de commit. Modifier un test pour qu'il passe, sans comprendre pourquoi il échouait, c'est débrancher l'alarme.

Que tester exactement ?

Le test du ticket ne suffit pas toujours. Pense aux cas autour du bug, ceux qu'une correction maladroite casserait :

CasPourquoi le testerRésultat attendu
Montants en chaînes (le ticket)Reproduit le bug39.7
Montants déjà numériquesLes autres appelants passent des nombres : il ne faut pas les casser15.25 pour 10 et 5.25
Note sans ligne, ou lignes absentUn cas limite classique0
Montant illisible ("12,50")La correction doit échouer clairementUne erreur « Montant invalide »
js
test('additionne toujours des montants numériques', () => {
  expect(totalNote([{ montant: 10 }, { montant: 5.25 }])).toBe(15.25);
});

test('renvoie 0 pour une note sans ligne', () => {
  expect(totalNote([])).toBe(0);
  expect(totalNote(undefined)).toBe(0);
});

test('refuse un montant illisible au lieu de calculer un faux total', () => {
  expect(() => totalNote([{ montant: '12,50' }])).toThrow('Montant invalide');
});

Ces quatre tests forment le filet de totalNote. Le chapitre Écrire un bon test unitaire et le cours Épreuve E6 SLAM détaillent les classes d'équivalence et les valeurs limites, qui aident à choisir ces cas.

Le correctif côté PHP : la division par zéro

Reprenons la page de statistiques de Sorbier Véhicules, qui plante pour un véhicule sans réservation (chapitre 3) :

php
<?php
// src/Service/Statistiques.php (avant)
public function kmMoyen(array $reservations): float
{
    $total = 0;
    foreach ($reservations as $r) {
        $total += $r['km_retour'] - $r['km_depart'];
    }
    return $total / count($reservations);
}

Avant de corriger, une question fonctionnelle : que doit afficher la page pour un véhicule sans réservation ? « 0 km » laisse croire que le véhicule a roulé zéro kilomètre en moyenne ; « aucune donnée » est plus honnête. Après échange avec le responsable du parc, la méthode renverra null, et la vue affichera un tiret. Le test d'abord :

php
<?php
// tests/StatistiquesTest.php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Sorbier\Vehicules\Service\Statistiques;

final class StatistiquesTest extends TestCase
{
    public function testKmMoyenSansReservationRenvoieNull(): void
    {
        $stats = new Statistiques();
        $this->assertNull($stats->kmMoyen([]));
    }

    public function testKmMoyenCalculeLaMoyenneDesTrajets(): void
    {
        $stats = new Statistiques();
        $reservations = [
            ['km_depart' => 10200, 'km_retour' => 10350],
            ['km_depart' => 10350, 'km_retour' => 10400],
        ];
        $this->assertSame(100.0, $stats->kmMoyen($reservations));
    }
}

Le premier test échoue avec la DivisionByZeroError du ticket ; le second, qui décrit le comportement existant (150 km puis 50 km, soit 100 km de moyenne), passe déjà : il protège ce qui marchait. Puis la correction :

php
<?php
// src/Service/Statistiques.php (après)
public function kmMoyen(array $reservations): ?float
{
    if ($reservations === []) {
        return null;
    }
    $total = 0;
    foreach ($reservations as $r) {
        $total += $r['km_retour'] - $r['km_depart'];
    }
    return $total / count($reservations);
}

Le type de retour devient ?float (un nombre ou null). Ce changement de signature a des conséquences : chaque appelant de kmMoyen doit gérer null. Maj+F12 les liste ; ici, le contrôleur et la vue, que l'on adapte dans le même commit. La division 150 + 50 par 2 donne l'entier 100, que PHP convertit en 100.0 grâce au type de retour : d'où le assertSame(100.0, …).

Gérer les erreurs et les exceptions au bon niveau

Beaucoup de bugs viennent d'une erreur mal gérée : ignorée, avalée, ou affichée telle quelle à l'utilisateur. Trois principes guident leur gestion.

Lever tôt, avec un message utile. Dès qu'une fonction reçoit une donnée qu'elle ne peut pas traiter, elle lève une exception qui dit quoi et pourquoi : Montant invalide : 12,50, et non Erreur. Plus l'erreur éclate près de sa cause, plus elle est facile à corriger.

Attraper tard, là où l'on sait quoi faire. Un service ne sait pas s'il tourne dans une API, un script ou un test : il lève, il n'attrape pas. C'est la couche qui parle à l'utilisateur (le contrôleur, ou un gestionnaire d'erreurs global) qui transforme l'exception en réponse : un code HTTP, un message compréhensible, une ligne de journal.

Ne jamais avaler une erreur. Un catch vide, ou qui renvoie une valeur par défaut sans rien journaliser, efface la seule trace du problème.

Avec Express, on distingue les erreurs métier (une règle de gestion refusée, que l'utilisateur peut corriger) des erreurs techniques (un bug, une base indisponible). Une classe d'exception dédiée les sépare :

js
// erreurs.js
class RegleMetierError extends Error {
  constructor(message) {
    super(message);
    this.name = 'RegleMetierError';
  }
}

module.exports = { RegleMetierError };
js
// app.js — le gestionnaire d'erreurs, déclaré après toutes les routes
const { RegleMetierError } = require('./erreurs');

app.use((err, req, res, next) => {
  if (err instanceof RegleMetierError) {
    return res.status(422).json({ erreur: err.message });
  }
  console.error(err);                                   // la stack trace, dans le journal du serveur
  res.status(500).json({ erreur: 'Erreur interne, réessayez plus tard.' });
});

Express reconnaît un gestionnaire d'erreurs à ses quatre paramètres. Depuis Express 5, une erreur levée dans un contrôleur async, ou une promesse rejetée, lui est transmise automatiquement. Avec Express 4, encore présent dans beaucoup de projets que tu reprendras, il faut l'y envoyer toi-même avec un try … catch et next(err) dans chaque contrôleur asynchrone : sans cela, la requête reste sans réponse, et une promesse rejetée non gérée arrête le processus Node.js.

En PHP, même logique : le service lève, le contrôleur attrape et décide.

php
<?php
// src/Controleur/ReservationControleur.php (extrait)
public function reserver(array $donnees): void
{
    try {
        $this->reservations->creer($donnees);
        $this->rediriger('planning', 'Réservation enregistrée.');
    } catch (CreneauIndisponibleException $e) {
        $this->afficherFormulaire($donnees, $e->getMessage());     // erreur métier : on l'explique
    } catch (PDOException $e) {
        $this->journal->error('Échec de la réservation', ['exception' => $e]);
        $this->afficherErreur('Le service est momentanément indisponible.');
    }
}

CreneauIndisponibleException est une exception métier, propre à l'application, que l'utilisateur peut résoudre en choisissant un autre créneau : on lui affiche son message. PDOException est une erreur technique : l'utilisateur ne peut rien y faire, on journalise les détails et on lui affiche un message neutre. Le cours Sécurité applicative explique pourquoi un message d'erreur trop détaillé est aussi une faille (catégorie A10 de l'OWASP).

Automatiser la non-régression

Une suite de tests que l'on oublie de lancer ne protège de rien. Deux habitudes la rendent systématique.

  • Lancer les tests avant chaque commit, au minimum ceux du module modifié, et toute la suite avant chaque push.
  • Les faire lancer par l'intégration continue à chaque push et à chaque pull request : un service comme GitHub Actions exécute npm ci && npm test sur une machine propre, et affiche une croix rouge sur la pull request si un test échoue. La fusion peut même être bloquée tant que la suite n'est pas verte. Le cours Git et GitHub montre comment écrire ce premier pipeline.

Livrer le correctif

Le commit raconte le correctif à la personne qui le lira dans deux ans :

bash
git add services/notes.js tests/notes.test.js
git commit -m "fix(notes): additionne les montants DECIMAL reçus en texte (#231)" \
           -m "mysql2 renvoie les colonnes DECIMAL sous forme de chaînes : le + de totalNote
les concaténait. Les montants sont convertis et additionnés en centimes ;
un montant illisible lève désormais une erreur au lieu de fausser le total."
git push -u origin fix/231-total-note

Le titre suit Conventional Commits : le type fix, la portée, ce que fait le commit, le ticket. Le corps explique la cause et la correction, ce que le diff ne dit pas. Un seul commit pour un correctif (sur une branche, on peut aussi commiter le test rouge juste avant, comme preuve, si la branche est fusionnée d'un bloc), qui ne contient que le correctif : si tu as repéré un nom de variable à améliorer en passant, ce sera un autre commit (refactor), voire une autre pull request.

La pull request reprend les mêmes éléments, plus la façon de vérifier :

markdown
## Ticket
#231 — le total d'une note affiche des montants collés (« 012.5019.90 »).

## Cause
Les colonnes DECIMAL arrivent en texte depuis mysql2 ; `totalNote` les concaténait.

## Correction
Conversion des montants et addition en centimes dans `services/notes.js`.

## Tests
- nouveau test du ticket (rouge avant, vert après) et trois cas limites ;
- suite complète : 42 tests verts ;
- vérifié à la main : résumé de note, export CSV, tableau de bord.

Une fois la pull request relue et fusionnée, le correctif part dans la prochaine version (1.4.3), avec une entrée Corrigé dans le changelog (chapitre 9). Tu fermes enfin le ticket en indiquant la cause, la version corrective, et tu préviens Julie.

Exercices

Exercice 1 sur 4 Trouver le bug et écrire le test : le libellé Réussi À revoir

Depuis la version 1.4.0, l'écran de détail d'une note plante avec TypeError: ligne.montant.toFixed is not a function. Voici la fonction en cause :

js
function libelleLigne(ligne) {
  return `${ligne.libelle} : ${ligne.montant.toFixed(2)} €`;
}
  1. Explique le bug, en lien avec le ticket #231.
  2. Écris le test Jest qui le reproduit.
  3. Corrige la fonction.
Voir la réponseMasquer la réponse de l'exercice 1
  1. toFixed est une méthode des nombres. ligne.montant arrive de la base sous forme de chaîne ("12.5"), et une chaîne n'a pas de méthode toFixed : d'où le message. C'est la même cause que le ticket #231 : les DECIMAL renvoyés en texte par mysql2.
  2. Le test, avec la donnée réelle, une chaîne :
js
test('affiche un montant reçu sous forme de chaîne', () => {
  expect(libelleLigne({ libelle: 'Repas client', montant: '12.5' })).toBe('Repas client : 12.50 €');
});
  1. La correction convertit avant de formater :
js
function libelleLigne(ligne) {
  return `${ligne.libelle} : ${Number(ligne.montant).toFixed(2)} €`;
}

Deux bugs de même cause en une semaine : c'est le signe qu'il faut traiter le problème à la source, par exemple en convertissant les montants une fois pour toutes dans le dépôt, juste après la requête SQL. C'est une décision d'équipe, à prendre avec toute la suite de tests.

Tu avais trouvé ?

Exercice 2 sur 4 Trouver le bug et écrire le test : l'heure de fin Réussi À revoir

Dans Sorbier Véhicules, une réservation de 2 heures créée pour 8 h s'enregistre de 10 h à 10 h. Voici le code :

php
function finReservation(DateTime $debut, int $heures): DateTime
{
    return $debut->modify("+{$heures} hours");
}

$debut = new DateTime('2026-11-19 08:00');
$fin = finReservation($debut, 2);
// on enregistre $debut et $fin
  1. Explique le bug.
  2. Écris un test PHPUnit qui le reproduit.
  3. Propose une correction.
Voir la réponseMasquer la réponse de l'exercice 2
  1. DateTime est mutable : modify() modifie l'objet lui-même, puis le renvoie. $debut et $fin désignent donc le même objet, qui vaut 10 h après l'appel.
  2. Le test vérifie que le début n'a pas bougé :
php
public function testLeDebutNEstPasModifie(): void
{
    $debut = new DateTime('2026-11-19 08:00');
    $fin = finReservation($debut, 2);
    $this->assertSame('08:00', $debut->format('H:i'));
    $this->assertSame('10:00', $fin->format('H:i'));
}

Il échoue sur la première assertion : $debut vaut 10:00. Après la correction en DateTimeImmutable (question 3), on remplace new DateTime par new DateTimeImmutable dans le test.

  1. Utiliser DateTimeImmutable, dont modify() renvoie un nouvel objet sans toucher à l'original :
php
function finReservation(DateTimeImmutable $debut, int $heures): DateTimeImmutable
{
    return $debut->modify("+{$heures} hours");
}

Si l'on ne peut pas changer le type (trop d'appelants), return (clone $debut)->modify("+{$heures} hours"); corrige aussi. Dans un code neuf, préfère toujours DateTimeImmutable.

Tu avais trouvé ?

Exercice 3 sur 4 Cause ou symptôme ? Réussi À revoir

La page des statistiques plante quand un véhicule n'a pas de réservation (DivisionByZeroError). Quatre corrections sont proposées. Pour chacune, dis si elle traite la cause ou le symptôme, et ce qu'elle risque.

  1. Entourer l'appel de kmMoyen d'un try … catch dans le contrôleur, qui affiche « 0 km » en cas d'erreur.
  2. Ajouter dans kmMoyen un test du tableau vide qui renvoie null, et adapter l'affichage.
  3. Créer une réservation fictive de 0 km pour chaque véhicule neuf.
  4. Remplacer $total / count($reservations) par $total / max(1, count($reservations)).
Voir la réponseMasquer la réponse de l'exercice 3
  1. Symptôme. Elle avale toutes les exceptions de kmMoyen, y compris de futurs vrais bugs, et affiche une valeur fausse (« 0 km ») sans rien journaliser.
  2. Cause. La fonction gère explicitement le cas limite, et l'affichage dit honnêtement qu'il n'y a pas de donnée. C'est la correction retenue dans ce chapitre.
  3. Symptôme, et elle corrompt les données : de fausses réservations faussent toutes les autres statistiques.
  4. Cause, mais avec une décision fonctionnelle discutable : la fonction ne plante plus et renvoie 0 pour un véhicule sans réservation. Acceptable si le métier veut « 0 », mais l'astuce max(1, …) cache l'intention. Un if explicite se lit mieux.

Tu avais trouvé ?

Exercice 4 sur 4 Un ancien test échoue Réussi À revoir

Après ta correction du ticket #231, toute la suite passe sauf un ancien test :

js
test('le résumé renvoie le total de la note', async () => {
  const res = await request(app).get('/api/notes/7/resume').set('Authorization', jeton);
  expect(res.body.total).toBe('32.40');
});

Il reçoit maintenant 32.4 (un nombre) au lieu de '32.40' (une chaîne). Que fais-tu ?

Voir la réponseMasquer la réponse de l'exercice 4

Ce test a été écrit avant la correction et fige un comportement : l'API renvoyait un total sous forme de chaîne. Deux questions à te poser.

  1. Ce comportement était-il voulu ? Si le front ou un autre client de l'API lit total comme une chaîne (par exemple pour l'afficher tel quel, avec ses deux décimales), le passage au nombre est un changement de contrat : le front afficherait 32.4 au lieu de 32.40. C'est une régression pour lui.
  2. Qui consomme ce champ ? Cherche les appels à cette route dans le front et dans les autres applications.

Si seul le front lit ce champ et qu'il formate déjà les montants (toFixed(2) ou Intl.NumberFormat), tu mets à jour le test (toBe(32.4)) et tu l'expliques dans le message de commit. Si d'autres clients dépendent de la chaîne, tu gardes le contrat : le résumé renvoie total.toFixed(2), et le test reste tel quel. Dans les deux cas, on ne modifie pas un test sans savoir pourquoi il échouait.

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 : Faire évoluer une fonctionnalité : la maintenance évolutiveRevoir le sommaire du cours