développement logiciel piloté par les tests
sur

Les ingénieurs en systèmes embarqués apprécient particulièrement lorsque leur code fonctionne correctement sur le matériel dès la première tentative. Cependant, en pratique, des bogues et des erreurs rendent souvent cela difficile à atteindre. Et s’il existait une méthode permettant d'anticiper les problèmes avant qu’ils ne surviennent ? Dans cet article consacré au développement piloté par les tests, nous explorerons une approche visant à écrire du code comportant moins de bogues et d’erreurs imprévues.
Puisque le développement de micrologiciel est une branche de l’ingénierie logicielle, l’approche Test-Driven Development (TDD) est adopté, qui met l’accent sur l’écriture des tests avant celle du code. Cependant, certains développeurs de firmware suivent une approche Test-Later Development (TLD), où les tests — notamment les tests unitaires — sont reportés jusqu’à ce que le code soit considéré comme fonctionnel. Bien que cette approche puisse sembler efficace et intuitive, elle conduit souvent à une couverture de test insuffisante. Il en résulte une base de code plus vulnérable aux échecs d’intégration lors de l’ajout de nouvelles fonctions.
L’avantage du TDD est qu’il garantit une couverture de test complète lorsqu’il est appliqué rigoureusement. Les développeurs adoptent une logique « test d’abord », en écrivant les tests avant le code correspondant. Au début, le test échoue puisque le code n’existe pas (Rouge), voir figure 1. Ensuite, le code est écrit pour implémenter l’unité et passer le test (Vert). Enfin, le code est nettoyé et optimisé tout en maintenant le test réussi (Refactorisation). Ce cycle, Rouge, Vert, Refactorisation, constitue le cœur du TDD.

Le TDD inverse le processus traditionnel de développement du logiciel, ce qui le rend au départ peu intuitif et parfois déroutant. Une inquiétude fréquente porte sur l’application des tests unitaires au firmware tournant sur un microcontrôleur, notamment en raison de la forte dépendance aux SDK fournisseurs et aux chaînes d’outils spécifiques. Cet article examine ces défis et montre comment le TDD peut être intégré de manière efficace dans le développement embarqué.
Que vous ayez déjà entendu parler du TDD dans un livre, par un collègue ou lors d’une conférence — ou que vous découvriez ce concept pour la première fois — cet article est fait pour vous.
Comprendre les tests unitaires dans les systèmes embarqués
Le TDD ne peut être mis en œuvre sans une compréhension claire des tests unitaires. Bien que cet article n’ait pas vocation à enseigner en détail l’écriture de tests unitaires, il est essentiel de clarifier certains termes fondamentaux.
Le terme test double est largement utilisé dans les tests unitaires. Il désigne des objets qui remplacent des composants réels du système afin d’isoler le System Under Test (SUT) de ses dépendances. Dans les systèmes embarqués, l’utilisation de test doubles est crucial, car les dépendances vis-à-vis des SDK et des bibliothèques tierces sont souvent plus contraignantes que dans le développement logiciel général. Les tests doubles sont particulièrement essentiels dans les tests hors cible, où le compilateur hôte (généralement sur un PC) est utilisé pour générer les binaires, sans recours au SDK ni aux véritables périphériques du microcontrôleur.
Les tests doubles existent sous différentes formes, les plus courantes sont Fakes, Stubs et Mocks :
- Fakes fournissent une implémentation simplifiée d’une dépendance, facilitant ainsi les tests. Par exemple, remplacer une mémoire Flash NOR clé/valeurpar une implémentation en mémoire vive lors de tests hors cible sur l’hôte.
- Stubs retournent des réponses prédéfinies, généralement codées en dur, et possèdent une logique minimale. Par exemple, remplacer une fonction de lecture ADC par une fonction qui renvoie toujours une valeur fixe.
- Mocks permettent de vérifier les interactions, en s’assurant que certaines fonctions sont appelées avec les bons arguments. Contrairement aux Stubs, les Mocks agissent comme des espions sur les appels de fonction. Par exemple, lors du test d’un pilote de capteur I2C, un Mock peut valider que la bonne adresse de registre a été envoyée, alors qu’un Stub se contenterait de retourner une valeur sans vérification.
Il’est important de noter que les différences entre test doubles sont plus profondes, mais si vous ne connaissez pas ces concepts, cette explication constitue un bon point de départ. De plus, il est possible d’effectuer le remplacement des dépendances en C/C++ avec différentes techniques de test double. Celles-ci incluent : le remplacement basé sur l’interface, sur l’héritage, sur la composition, le remplacement au moment du linkage, le remplacement via le préprocesseur, et le remplacement basé sur Vtable. Plus de détails sont disponibles sur.
Les tests unitaires nécessitent un cadre logiciel (framework) composé d’une bibliothèque et d’un test runner. La bibliothèque fournit des assertions et la prise en charge des test doubles comme les mocks, tandis que le test runner exécute les tests. Il est important de noter que tous les frameworks ne prennent pas en charge les mocks de manière native. Le framework gère également les fixtures de test, en particulier les phases de setup et teardown, qui sont exécutées avant et après chaque test afin d’assurer un environnement contrôlé.
TDD en pratique
Le TDD suit une boucle itérative :
1. Écrire un test avant d’implémenter le code. Cela garantit l’existence de la définition de la fonction, bien qu’elle soit encore vide. Toute la suite de tests, y compris ceux qui ont déjà réussi, doit être exécutée, le nouveau test échouant comme prévu au départ.
2. Implémenter le code de façon minimale pour passer le test. Cette première version peut ne pas être optimale, mais exécuter toute la suite de tests prévient les régressions.
3. Refactoriser le code pour une qualité de production. Réexécuter les tests assure qu’aucune nouvelle erreur n’apparaît. Cette démarche permet de gagner du temps par rapport au débogage ultérieur.
Nous allons utiliser un projet de démonstration pour illustrer concrètement la boucle TDD. Bien que n’importe quel framework de test unitaire puisse convenir — tels que Unity ou GoogleTest — nous allons ici utiliser Ceedling. Ceedling est particulièrement convivial et offre des fonctionnalités de mocking intégrées via CMock.
Dans ce projet de démonstration, nous’allons concevoir un diviseur de tension contrôlable utilisant un potentiomètre numérique, comme l’AD5160, (un potentiomètre numérique SPI à 256 positions et une résistance totale de 10 kΩ), connecté via SPI (figure 2). Comme première étape, on peut décomposer le système en deux modules : ’un chargé de calculer la résistance nécessaire pour obtenir la tension de sortie souhaitée, et l’autre chargé de transmettre cette valeur à la puce via SPI. Afin de garder le focus sur l’illustration du TDD, plusieurs simplifications seront apportées.

Une fois Ceedling installé , il convient de créer le projet avec la commande suivante :
ceedling new sw_voltage_divider
Ensuite, nous créons un module avec la commande :
ceedling module:create[voltageDiv]
Ce module contiendra le code applicatif qui gère le calcul de la valeur de résistance requise. L’exécution de cette commande créera la structure de répertoires suivante.
├── project.yml
├── src
│ ├── voltageDiv.c
│ └── voltageDiv.h
└── test
└── test_voltageDiv.c
graphics team pls make sure that the special characters remain in the layout
Calcul de la résistance
Notre première étape est d’implémenter le code pour calculer la résistance requise. Il s’agira d’une fonction simple prenant deux paramètres — la tension d’entrée et la tension de sortie souhaitée — et retournant la valeur de résistance calculée. Conformément à l’approche TDD, nous allons commencer par écrire un premier test unitaire pour vérifier le résultat de cette fonction. Ce test échouera dans un premier temps, marquant le début du cycle TDD.
// in src/voltageDiv.h
int32_t get_Resistance(uint32_t Vin, uint32_t Vout );
// in src/voltageDiv.c
int32_t get_Resistance(uint32_t Vin, uint32_t Vout )
{
return -1;
}
// in test/test_voltageDiv.c
void test_whenValidVoutAndVinProvided_
thenReturnCorrectResistance(void)
{
TEST_ASSERT_EQUAL_INT32(get_Resistance(5000,2500),10000);
// Assuming R1 must be 10 k, when Vin = 5 V and Vout = 2.5 V
}
La fonction TEST_ASSERT... vérifie l’appel de la fonction get_Resistance avec les paramètres donnés, retourne 10000 au format int32.
L’implémentation initiale calculera la résistance en utilisant la règle du diviseur de tension, qui suffira à faire passer ce premier test.
// in src/voltageDiv.c
int32_t get_Resistance(uint32_t Vin, uint32_t Vout )
{
return 10000*Vout / (Vin - Vout);
}
Ensuite, nous allons procéder à une refactorisation du code en définissant R1 comme une macro dans le fichier d’en-tête, et en ajoutant des vérifications pour garantir que Vin et Vout ne sont pas égaux, afin d’éviter une division par zéro. Nous ajouterons également des garde-fous pour s’assurer que ni Vin ni Vout ne valent 0, et que Vin est toujours supérieur à Vout. L’exécution des tests unitaires permettra de confirmer que la refactorisation n’a pas introduit d’erreurs.
// in src/voltageDiv.c
int32_t get_Resistance(uint32_t Vin, uint32_t Vout )
{
if(Vin == 0 || Vout == 0) return -1;
if(Vin == Vout) return -1;
if(Vout > Vin) return -1;
uint32_t R2 = R1*Vout / (Vin - Vout);
return R2;
}
The potentiometer driver
Le driver du potentiomètre
Passons maintenant à la construction du pilote du potentiomètre.Nous allons créer une fonction qui configure le potentiomètre à la valeur de résistance souhaitée en l’envoyant via SPI à la puce. Comme le veut la démarche TDD, nous rédigerons d’abord le test unitaire correspondant — lequel échouera dans un premier temps. Créons un module pour cela en utilisant ceedling module:create[AD5160].
// in src/AD5160.h
uint8_t pot_set(uint32_t res_value);
// in src/AD5160.c
uint8_t pot_set(uint32_t res_value)
{
return 0;
}
// in test/test_AD5160.c
void test_AD5160_SetResistanceViaSpi(void)
{
TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);
}
En fonctionnement normal, la fonction pot_set retournera 1 pour confirmer que la configuration du potentiomètre a réussi ; cela est vérifié par la fonction TEST_ASSERT.... Comme on le voit, le test initial échouera.
L’implémentation de base de la fonction pot_set consistera à sélectionner le bon nombre de pas pour approcher la valeur de résistance souhaitée. Comme cela nécessite un transfert SPI avec le matériel réel, nous utiliserons un mock pour remplacer ce transfert SPI lors des tests. Sous Ceedling, cela se fait en appelant spi_transfer_ExpectAndReturn avant l’assertion qui teste pot_set. Il suffit d’inclure la déclaration spi_transfer dans SPI.h.
// in test/test_AD5160.c
void test_AD5160_SetResistanceValue(void)
{
spi_transfer_ExpectAndReturn(128,1);
// SPI Mock used inside pot_set
TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);
}
// in src/AD5160.c
uint8_t pot_set(uint32_t res_value)
{
uint8_t step = 10000/256;
uint8_t D = res_value / step;
return spi_transfer(D);
}
Enfin, on peut refactoriser le code en ajoutant des définitions de macros et en implémentant un contrôle pour s’assurer que la résistance demandée ne dépasse pas la valeur maximale du potentiomètre.
uint8_t pot_set(uint32_t res_value)
{
if(res_value > R2_MAX) return 0;
uint8_t D = res_value / POT_RESOLUTION ;
return spi_transfer(D);
}
Le code source complet du projet de démo est disponible sur le dépôt repository.
Évaluer l’efficacité du TDD
Comme toute approche d’ingénierie, le TDD n’est pas nécessairement la solution idéale dans tous les contextes. Toutefois, même lorsqu’il n’est pas appliqué de manière stricte, ses principes fondamentaux peuvent profondément influencer notre façon de concevoir le développement logiciel. Le TDD offre de nombreux avantages, tels que :
- Le TDD favorise la modularité et l’isolation des dépendances, deux éléments clés pour des tests unitaires efficaces. Cela conduit à une meilleure structuration du code et à une portabilité accrue. Par exemple, il dissuade d’imbriquer la logique applicative avec des accès matériels directs, même au nom de la performance.
- L’approche itérative du TDD aide les développeurs à se concentrer sur un seul problème à la fois, tout en mettant l’accent sur le comportement externe et la conception des interfaces. Cela conduit à des tests unitaires plus ciblés et plus robustes.
- L’exécution des tests unitaires à chaque étape aide à localiser rapidement les erreurs et offre un retour immédiat.
- Le TDD permet de commencer le développement même en l’absence du matériel cible, grâce à l’utilisation de mocks pour simuler le comportement des composants physiques. Cela permet de valider le code dès les premières étapes du projet.
- Le TDD autorise un développement parallèle et indépendant des différents modules. Par exemple, si un pilote matériel n’est pas encore prêt, son comportement peut être simulé par un mock, permettant à l’équipe logicielle d’avancer sans attendre.
- L’itération TDD permet de garantir aux développeurs une progression régulière. Avoir un ou plusieurs tests unitaires couvrant une fonction confirme sa bonne implémentation.
L’une des rares études scientifiques sur l’application du TDD dans le développement de logiciels embarqués, intitulée “Test-Driven Development and Embedded Systems: An Exploratory Investigation," a analysé son impact sur la qualité logicielle et la productivité des développeurs. Dans cette étude, neuf étudiants en Master ont mené des projets pilotes en appliquant à la fois des approches avec et sans TDD. Les résultats ont montré une amélioration de la qualité externe du code, bien qu’aucune différence significative en matière de productivité n’ait été observée. Si cette étude ne constitue pas une preuve définitive, ses conclusions restent pertinentes.
Il n’est pas nécessaire de rappeler les défis bien connus liés au TDD, comme la quantité de code supplémentaire requise pour les tests unitaires, ou encore la complexité inhérente à l’utilisation de test doubles (notamment les mocks) pour isoler les dépendances. Le choix d’adopter le TDD doit toujours résulter d’une évaluation équilibrée entre les efforts demandés et les bénéfices attendus, en tenant compte du contexte du projet et des ressources disponibles.
Enfin, il convient de rappeler que le TDD est une méthodologie de développement, non une stratégie d’implémentation — il n’enseignera pas aux développeurs comment structurer ou architecturer leur code. Le TDD est aligné avec l’Agilité, en particulier l’Extreme Programming (XP), qui encourage des livraisons fréquentes dans de courts cycles de développement et intègre une approche « test first ». Cependant, le TDD ne se limite pas à XP. De plus, il’est important de compléter les tests unitaires écrits lors du TDD par des tests systèmes et d’intégration.
Tests sur cible ou hors cible : lequel choisir ?
Les tests sur cible et hors cible désignent l’environnement d’exécution des tests unitaires (voir figure 3). Les tests hors cible s’exécutent sur la machine hôte utilisée pour le développement du firmware, tandis que les tests sur cible sont exécutés directement sur le matériel embarqué destiné à faire tourner le firmware final. Ce dernier cas implique d’exécuter les tests unitaires sur la carte cible tout en relayant les résultats vers l’hôte, généralement via des logs UART ou un protocole de communication équivalent.
Certains diront que le TDD hors cible est inefficace pour tester la fonctionnalité réelle du matériel, ce qui est en partie vrai et met en lumière la nécessité de tests sur cible ou bi-cible dans certains cas. Toutefois, le TDD hors cible peut s’avérer crucial dans des scénarios où les défaillances matérielles sont difficiles à déclencher ou à reproduire. Par exemple, lors du développement d’un pilote de mémoire flash, il est difficile de tester les cas d’échec comme les erreurs de bus SPI ou les défauts mémoire, car le matériel fonctionne généralement correctement en conditions normales.

Les tests sur cible peuvent sembler préférables et plus réalistes, mais il existe des cas où les tests hors cible sont plus pratiques :
- La cible matérielle est encore en développement ou indisponible.
- Les contraintes de mémoire de la cible ne permettent pas l’exécution de l’ensemble des tests unitaires.
- L’absence de ports de débogage ou d’interfaces de sortie rend difficile l’observation des résultats de test.
- Le matériel est partagé ou rare, limitant son accès aux différents membres de l’équipe.
- Certaines erreurs (comme les erreurs de bus) ne peuvent être induites de façon fiable sur la cible réelle.
- La suite de tests est trop longue à exécuter sur cible, notamment à cause du temps de flash et de récupération des résultats.
Dans de telles situations, le TDD hors cible permet de préserver la dynamique du développement, tandis que des tests sur cible, ponctuels ou partiels, peuvent être réalisés au besoin : c’est ce qu’on appelle une approche bi-cible.
Il est également courant de penser que les frameworks de test unitaire comme Unity ne fonctionnent que sur hôte,mais ce n’est pas le cas. Unity peut être adapté pour s’exécuter directement sur la cible, avec les résultats des tests affichés via des interfaces telles que l’UART. Toutefois, l’automatisation des tests sur cible reste plus complexe et exige souvent une infrastructure spécifique.
En revanche, les tests hors cible présentent certains risques, notamment les différences entre les toolchains du compilateur hôte et celles de la cible. Par exemple, un int mesure généralement 4 octets sur la plupart des cibles ARM Cortex-M, mais peut en mesurer 4 ou 8 sur une architecture x86-64. Une excellente manière de tirer parti des avantages des deux approches consiste à appliquer le TDD tout en exécutant le code avec le toolchain cible, de manière rapide et transparente, grâce à des émulateurs comme QEMU.
Le TDD à travers le regard de la communauté des développeurs firmware
Collecter les retours de la communauté des développeurs fournit des informations précieuses. Des plateformes en ligne comme Reddit permettent de recueillir l’avis de professionnels du secteur, y compris d’employés de sociétés qui ne communiquent généralement pas publiquement sur leur utilisation du TDD. Les témoignages anonymes partagent des retours concrets du terrain. Voici quelques opinions représentatives, sans jugement personnel.
Un développeur estime que le TDD en vaut réellement la peine, tout en reconnaissant qu’il peut paraître inconfortable au départ et qu’il ne s’agit pas de la méthode de développement la plus rapide. Un autre souligne qu’avec un bon usage des mocks et des fakes, il est possible de réaliser des tests unitaires sans aucune interaction avec le matériel.
Un développeur exprime une certaine réticence à l’égard du TDD, notamment pour les projets comportant de nombreuses zones d’incertitude qui ne se révèlent qu’au fil du développement. Selon lui, cela conduit à perdre du temps à adapter ou supprimer des tests. Il décrit une approche fréquente : prototyper rapidement jusqu’à obtenir un état stable validé par des tests fonctionnels ou d’intégration de haut niveau, puis procéder à une refonte du code en y ajoutant des tests unitaires sur les parties stabilisées.
Un autre cas d’usage pertinent du TDD mentionné par un développeur concerne les algorithmes d’analyse de signaux en plusieurs étapes, où chaque étape peut être testée indépendamment. Par ailleurs, les tests unitaires écrits dans le cadre du TDD permettent parfois de détecter des problèmes rares dès le poste de développement — tels que des défaillances matérielles ou des erreurs de synchronisation — qui sont difficiles à reproduire sur le matériel cible.
En résumé
Certaines personnes recommandent le TDD sans en discuter les avantages et les limites, tandis que d’autres le rejettent totalement. Pourtant, apprendre et expérimenter le TDD peut considérablement améliorer vos pratiques de développement, même sans une application stricte. Le TDD se révèle particulièrement utile pour les projets de grande envergure impliquant plusieurs développeurs et une forte complexité fonctionnelle, mais il peut également bénéficier aux petites équipes et aux projets plus simples.
Les développeurs réticents au TDD rencontrent souvent des difficultés liées à la conception de l’architecture du firmware ou à la rédaction de cas de test efficaces. En réalité, nombre d’idées reçues à propos du TDD proviennent d’un manque d’expérience dans la création de bons tests unitaires. Des approches comme « tester l’interface, pas l’implémentation » ou encore le Behavior-Driven Development (BDD) peuvent contribuer à limiter la duplication inutile de tests. Par ailleurs, le mutation testing permet d’évaluer la qualité des tests en introduisant volontairement de petites modifications dans le code et en vérifiant si les tests les détectent.
Enfin, l’ouvrage de référence recommandé est celui de James Grenning, Test-Driven Development for Embedded C, qui, au moment de la rédaction de cet article, reste le seul livre spécifiquement consacré à l’application du TDD dans les systèmes embarqués. Je recommande également le chapitre intitulé Embedded and Real-Time System Development: A Software Engineering Perspective, qui offre une analyse approfondie du TDD embarqué, accompagnée de recommandations concrètes. De nombreuses ressources ont été consultées pour la rédaction de cet article. Elles sont répertoriées dans un fichier README disponible sur le dépôt GitHub par souci de concision.
Note de l'éditeur : cet article (250092-01) est paru dans Elektor Septembre/Octobre 2025.
Questions ou commentaires ?
Envoyez un courriel à l’auteur yt@atadiat.com ou contactez Elektor sur editor@elektor.com.
Discussion (0 commentaire(s))