Les tests automatisés : du test unitaire à la non-régression
Alexandre Jeffroy
Ingénieur logiciel
Un projet que je n'oublierai pas : un simulateur aéronautique de plusieurs centaines de milliers de lignes de C++, une équipe entière de développeurs. Une modification mineure sur un module de navigation, un vendredi. Le lundi, plusieurs scénarios de vol en approche étaient cassés. Plusieurs jours de chasse au bug, et du retard sur la livraison.
En cause : aucun test automatisé pour détecter la régression. On s'en est aperçu en recette manuelle, bien trop tard. Depuis, sur tous mes projets, les tests automatisés ne sont pas optionnels. Ils sont le filet de sécurité qui permet de modifier le code en confiance.
Pourquoi automatiser les tests
La première raison est la détection immédiate : un test exécuté en CI révèle le problème en minutes, pas en semaines. Vient ensuite la confiance dans les modifications, car une couverture solide permet de refactoriser sans craindre de casser l'existant. Un bon test fait aussi office de documentation vivante, puisqu'il décrit comment le code doit se comporter et devient obsolète bien plus vite qu'un commentaire s'il n'est plus à jour. Enfin, il rend la régression impossible : une fois un bug corrigé, j'écris un test qui verrouille la correction, et le bug ne peut plus revenir sans que le test ne le signale.
La pyramide des tests
Tous les tests ne se valent pas. Plus on monte dans la pyramide, plus les tests sont coûteux à écrire, lents à exécuter et fragiles : l'essentiel de la couverture doit venir des **tests unitaires**, complétés par des **tests d'intégration**, et un nombre limité de **tests fonctionnels** de bout en bout.
Un test unitaire, en pratique
Un bon test unitaire est rapide, indépendant, reproductible et auto-validant. Exemple sur un calcul de distance de freinage ferroviaire :
1class BrakingCalculator {
2public:
3 // vitesse en km/h, décélération en m/s²
4 double computeBrakingDistance(double speedKmh, double decelMs2) const {
5 if (speedKmh < 0.0 || decelMs2 <= 0.0)
6 throw std::invalid_argument("Invalid parameters");
7 const double speedMs = speedKmh / 3.6;
8 return (speedMs * speedMs) / (2.0 * decelMs2); // d = v² / (2a)
9 }
10};
11
12TEST(BrakingCalculatorTest, StandardBrakingAt100KmH) {
13 BrakingCalculator calc;
14 // Attendu : environ 772 mètres, tolérance 1 mètre
15 EXPECT_NEAR(772.0, calc.computeBrakingDistance(100.0, 0.5), 1.0);
16}
17
18TEST(BrakingCalculatorTest, NegativeSpeedThrows) {
19 BrakingCalculator calc;
20 EXPECT_THROW(calc.computeBrakingDistance(-50.0, 1.0), std::invalid_argument);
21}Pour isoler l'unité testée, on remplace les dépendances réelles (un capteur, un actionneur) par des mocks. C'est particulièrement utile pour tester un comportement de sécurité sans matériel réel :
1TEST(SafetyMonitorTest, UnhealthySensorTriggersDanger) {
2 MockPositionSensor sensor;
3 sensor.setPosition(500.0); // position sûre
4 sensor.setHealthy(false); // mais capteur défaillant
5
6 SafetyMonitor monitor(sensor, /*dangerZone=*/900.0);
7 EXPECT_TRUE(monitor.isInDangerZone()); // principe fail-safe
8}Les tests d'intégration : vérifier les interactions
Les tests unitaires vérifient des unités isolées ; les tests d'intégration vérifient que ces unités fonctionnent correctement ensemble, par exemple un contrôleur, ses capteurs et ses actionneurs dans un scénario complet de freinage d'urgence. Ils sont plus lents, mais indispensables pour valider les interactions réelles.
Sur les systèmes critiques : couverture et traçabilité
Les normes de certification (DO-178C en aéronautique, CENELEC EN 50128 en ferroviaire) imposent des taux de couverture stricts selon la criticité, mesurés avec des outils comme gcov/lcov. Chaque anomalie détectée doit donner lieu à un test qui la reproduit, intégré durablement à la suite de non-régression. Et chaque exigence doit être couverte par au moins un test tracé :
1// REQ-042 : le système doit activer le freinage d'urgence
2// dans les 200 ms suivant la détection du danger
3TEST(BrakingSystemTest, REQ_042_EmergencyBrakingResponse) {
4 auto start = std::chrono::high_resolution_clock::now();
5 system_.detectDanger();
6 auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
7 std::chrono::high_resolution_clock::now() - start).count();
8
9 EXPECT_TRUE(system_.isEmergencyBraking());
10 EXPECT_LE(elapsed, 200);
11}Ce qu'il faut retenir
Les tests automatisés n'ont de valeur que s'ils s'exécutent systématiquement, à chaque commit, dans la CI. Écrire le test, l'intégrer, le garder vert : c'est cette discipline qui transforme une suite de tests en véritable garantie de non-régression, et qui permet de faire évoluer un système critique sans craindre de casser ce qui fonctionnait déjà.
Autres articles
Workbench : mon couteau suisse pour éliminer les actions répétitives
Workbench est l'outil qui sert de socle à toute ma démarche d'outillage interne. Une interface graphique unique qui regroupe les actions répétitives du quotidien et remplace une poignée de commandes fastidieuses par quelques clics.
SonarQube : maîtriser la qualité et la dette technique
Dans des secteurs où la fiabilité n'est pas négociable, comment s'assurer que des centaines de milliers de lignes de code respectent les standards les plus élevés ? SonarQube est devenu un allié indispensable sur mes projets.