Dans le développement de logiciels modernes, ajouter rapidement des fonctionnalités tout en maintenant la qualité du code est une nécessité absolue. En particulier dans des langages complexes et exigeants en performances comme le C++, les erreurs de gestion de mémoire ou les comportements indéfinis (Undefined Behavior) conduisent facilement à des bugs fatals, ce qui rend l’importance des tests encore plus grande que dans d’autres langages.
Dans cet article, nous expliquerons de manière très détaillée et pratique comment introduire le développement dirigé par les tests (Test-Driven Development : TDD) dans un projet C++. Nous aborderons de manière exhaustive l’utilisation du framework de tests unitaires GoogleTest et du framework de mock GoogleMock, ainsi que la méthode de configuration moderne avec le système de build CMake, et comment mesurer la couverture de code.
1. La philosophie et les avantages du développement dirigé par les tests (TDD)
Le développement dirigé par les tests (TDD) est une méthode de développement logiciel qui consiste à « écrire des tests avant d’écrire l’implémentation ». Cela ne fonctionne pas seulement comme une méthode de test, mais aussi comme une méthode de conception. En écrivant les tests d’abord, les développeurs prennent naturellement conscience de créer une « interface facile à utiliser » et une « conception faiblement couplée ».
1.1 Le cycle Red-Green-Refactor
Au cœur du TDD se trouve le cycle suivant : « Red-Green-Refactor ».
flowchart TD
Start["Début du développement"] --> Red["Red : Écrire un test qui échoue"]
Red --> Green["Green : Faire l'implémentation minimale pour que le test réussisse"]
Green --> Refactor["Refactor : Améliorer le code (Refactoring)"]
Refactor --> Red
Refactor -.-> End["Fonctionnalité terminée"]
- Red (Rouge) : En l’absence d’implémentation, écrire un test qui définit le comportement attendu. À ce stade, comme il n’y a pas d’implémentation, le test échoue toujours (Red).
- Green (Vert) : Écrire le code minimal nécessaire uniquement pour faire réussir le test (Green). À cette étape, la beauté du code ou les performances ne sont pas la priorité absolue.
- Refactor (Refactoring / Remaniement) : Tout en maintenant le test au vert, éliminer les duplications et améliorer la conception du code. La présence des tests permet de modifier le code en toute sécurité.
1.2 L’augmentation des coûts due au retard dans la découverte des bugs
En génie logiciel, il est bien connu que plus la découverte d’un bug se produit tard dans le processus de développement, plus son coût de correction augmente de manière exponentielle. Ce modèle d’augmentation des coûts est parfois approximé par la formule mathématique suivante :
$$ Cost(t) = C_0 \times e^{k \cdot t} $$Ici, $Cost(t)$ est le coût de correction au temps $t$, $C_0$ est le coût de correction (ligne de base) immédiatement après l’introduction du bug, et $k$ est une constante. L’introduction du TDD permet de maintenir $t$ à un niveau minimal, empêchant ainsi l’augmentation exponentielle des coûts.
2. Le choix des outils de test en C++ et la configuration moderne avec CMake
Il existe de nombreux frameworks de test en C++, tels que Catch2, Boost.Test, doctest, etc., mais le plus largement utilisé comme standard de l’industrie est GoogleTest (gtest). GoogleTest séduit par la richesse de ses assertions, son puissant framework de mock (GoogleMock) et sa grande extensibilité.
2.1 Introduction de GoogleTest via FetchContent de CMake
Dans le développement C++ moderne, la gestion des dépendances externes se fait couramment en utilisant le module FetchContent de CMake. Cela permet d’éviter la gestion des sous-modules ou l’installation préalable de bibliothèques.
Le fichier CMakeLists.txt à la racine du projet s’écrit de la manière suivante :
| |
Grâce à cette configuration, CMake téléchargera automatiquement le code source de GoogleTest et l’intégrera au projet.
3. Pratique : Le cycle Red-Green-Refactor avec GoogleTest
À partir d’ici, nous allons mettre en pratique le cycle TDD en prenant comme exemple une simple classe Calculator.
3.1 Phase 1 : Red (Écrire un test qui échoue)
Tout d’abord, nous écrivons le squelette du fichier d’en-tête include/Calculator.h et le code de test.
include/Calculator.h (Squelette)
| |
tests/CalculatorTest.cpp (Code de test)
| |
Si l’on essaie de compiler à ce stade, on obtiendra une erreur d’édition de liens (link) car l’implémentation de Calculator::Add n’existe pas, ou si le test est exécuté, il échouera (Red).
3.2 Phase 2 : Green (Implémentation minimale)
Nous écrivons le code nécessaire uniquement pour faire réussir le test.
src/Calculator.cpp
| |
En compilant et en exécutant le test maintenant, le test réussira (Green).
3.3 Phase 3 : Refactor (Refactoring / Remaniement)
Dans cet exemple, le code est très simple, mais à mesure que les exigences se complexifient, la phase de refactoring permet d’améliorer la lisibilité du code ou d’optimiser les performances. Le code de test lui-même est aussi sujet au refactoring. Par exemple, on peut envisager d’introduire des “fixtures” de test (testing::Test) pour mutualiser la configuration.
4. Différence entre EXPECT_EQ et ASSERT_EQ
Lors de l’utilisation de GoogleTest, il existe deux types de macros d’assertion : EXPECT_* et ASSERT_*. Comprendre leurs différences est très important pour écrire des tests robustes.
EXPECT_EQ(expected, actual): Même si le test échoue, l’exécution de la fonction de test actuelle continue. C’est adapté lorsque l’on souhaite vérifier plusieurs états au sein d’un seul test.ASSERT_EQ(expected, actual): Si le test échoue, l’exécution de la fonction de test actuelle est interrompue (échec fatal) sur-le-champ. C’est utilisé lorsque les vérifications suivantes n’ont plus de sens (ex : déréférencer un pointeur juste après avoir vérifié qu’il n’est pasnullptr).
5. Injection de dépendances (DI) et Mocking avec GoogleMock
Dans un projet C++ réel, il y a toujours des dépendances envers des systèmes externes, comme l’accès aux bases de données, les communications réseau, le contrôle de matériel, etc. Si on laisse ces dépendances telles quelles, les tests unitaires deviennent très difficiles.
C’est là qu’interviennent l’injection de dépendances (Dependency Injection : DI) et le “mocking” d’interfaces avec GoogleMock.
flowchart LR
Test["Test unitaire"] -->|injecte le mock| Target["Service cible"]
Target -->|dépend de| Interface["Interface (IUserRepository)"]
Mock["MockUserRepository"] -.->|implémente| Interface
Test -->|configure| Mock
5.1 Définition de l’interface et implémentation de la classe cible
Tout d’abord, nous définissons une interface (une classe avec des fonctions virtuelles pures) qui fait l’abstraction du composant dont on dépend.
| |
Ensuite, nous créons la classe de service (cible du test) qui dépend de cette interface. Nous injectons la dépendance via le constructeur (Constructor Injection).
| |
5.2 Création de la classe mock avec GoogleMock et test
Nous utilisons la macro MOCK_METHOD de GoogleMock pour créer le mock de l’interface.
| |
En utilisant GoogleMock de cette manière, on peut vérifier précisément « si la classe cible interagit correctement avec ses dépendances (interaction) ».
6. Mesure et visualisation de la couverture de code
Après avoir écrit les tests, afin d’évaluer objectivement quelles parties du projet sont exécutées (couvertes) par les tests, on mesure la couverture de code. La couverture de code ($Coverage$) est représentée par la formule suivante :
$$ Coverage = \left( \frac{L_{executed}}{L_{total}} \right) \times 100 \ (\%) $$Ici, $L_{executed}$ est le nombre de lignes de code exécutées pendant les tests, et $L_{total}$ est le nombre total de lignes de code du projet.
Si vous utilisez GCC ou Clang, vous pouvez mesurer la couverture à l’aide des outils gcov et lcov.
6.1 Ajout des options de couverture à CMake
Pour mesurer la couverture, des drapeaux (flags) de compilation spécifiques sont nécessaires. Ajoutez la configuration suivante à CMakeLists.txt.
| |
6.2 Procédure de génération du rapport de couverture
Activez le flag lors de la compilation, puis utilisez lcov pour générer un rapport HTML après avoir exécuté les tests.
| |
En ouvrant coverage_report/index.html généré dans votre navigateur, les lignes exécutées seront visuellement mises en évidence en vert et en rouge au niveau du code source, ce qui sera utile pour identifier les omissions de tests (trous de couverture).
7. Défis du TDD dans les projets C++ et meilleures pratiques
L’introduction du TDD dans les projets C++ présente des défis spécifiques.
7.1 Augmentation du temps de build (temps de compilation)
En C++, les temps de compilation ont tendance à s’allonger en raison de l’utilisation intensive des templates et de l’inclusion massive d’en-têtes. Comme le cycle « Red-Green-Refactor » du TDD doit être effectué rapidement, un retard dans le temps de compilation est fatal. Solution : Minimisez les dépendances des fichiers d’en-tête en utilisant des déclarations anticipées (Forward Declarations) et l’idiome Pimpl (Pointer to implementation). L’introduction d’outils de cache de compilation tels que Ccache est également efficace.
7.2 Introduction du TDD sur du code legacy (hérité)
Appliquer rétroactivement le TDD à un énorme code monolithique existant est extrêmement difficile. Solution : Plutôt que de tout réécrire depuis le début, il est recommandé d’adopter une approche consistant à ajouter des tests de manière incrémentale sur les parties où de nouvelles fonctionnalités sont ajoutées ou là où des bugs sont corrigés (la règle du Boy Scout), plaçant ainsi progressivement la base de code sous le contrôle du TDD (Méthode de “Working Effectively with Legacy Code”).
8. Le TDD comme conception logicielle
Le TDD est un filet de sécurité pour maintenir la qualité du code, mais c’est aussi un moteur pour améliorer la conception du code C++. Étant donné que vous êtes contraint d’utiliser l’injection de dépendances (DI) pour pouvoir écrire des tests, le couplage (Coupling) entre les classes diminue et la cohésion (Cohesion) augmente.
Lors du refactoring, il est également important de prêter attention à la complexité cyclomatique (Complexité cyclomatique de McCabe).
$$ M = E - N + 2P $$($M$ : Complexité, $E$ : Nombre d’arêtes, $N$ : Nombre de nœuds, $P$ : Nombre de composantes connexes)
L’existence de tests permet de diviser les fonctions ou de les remplacer par du polymorphisme afin de réduire cette complexité, sans craindre de casser le code existant.
Résumé
Dans cet article, nous avons expliqué en détail comment introduire le développement dirigé par les tests (TDD) à l’aide de GoogleTest et GoogleMock dans un projet C++.
- Configuration de projet moderne en utilisant CMake FetchContent
- Pratique du cycle Red-Green-Refactor
- Mocking d’interfaces en utilisant GoogleMock et l’injection de dépendances (DI)
- Visualisation de la couverture de code par gcov/lcov
Bien que le TDD soit une approche qui demande du temps pour être maîtrisée, le retour sur investissement est inestimable dans la programmation système où l’équilibre entre performances et sécurité est requis, comme en C++. Nous vous invitons à pratiquer progressivement le TDD dès votre prochain projet pour obtenir un code C++ robuste et facile à maintenir.
