Tester améliore le design du code
Jeudi dernier, j’ai assisté à l’atelier en ligne « Good Enough Testing » (juste ce qu’il faut de tests), animé par Lucian Ghinda, qui rédige l’infolettre Short Ruby. Je suis content d’avoir vu l’inscription peu après sa publication, car l’atelier s’est rempli en moins d’une journée !
J’ai pris le temps de combiner les connaissances acquises lors de l’atelier avec ce que j’ai appris au fil des années, dans des blogs et des livres.
Samedi confession
Tout d’abord, j’ai un aveu à faire : je suis un programmeur autodidacte. Mon premier ordinateur était un Atari sur lequel tournait Cubase, et j’étais heureux d’écrire mes compositions et de stocker les fichiers MIDI sur disquettes. Il n’y avait pas d’Internet à l’époque, et je ne suis pas très friand de jeux vidéo, donc les ordinateurs étaient plutôt limités de toute façon !
Quelques années plus tard, j’ai créé un blog et appris à coder pour en changer le design et les fonctionnalités. Mon apprentissage a donc commencé par HTML et CSS, puis PHP et SQL, et enfin JavaScript. À ce jour, je continue à penser qu’il est indispensable de bien connaître les éléments de base d’Internet avant d’apprendre le développement web. J’ai rencontré trop de développeurs qui se compliquent énormément la vie, ou réinventent la roue, juste parce qu’ils n’ont jamais appris HTML et CSS…
D’un autre côté, je me suis toujours senti peu sûr de moi parce que je n’ai jamais suivi de formation classique : je n’ai jamais écrit d’algorithmes de tri, utilisé de matrices, construit un analyseur syntaxique, ou eu des cours formels sur les principes SOLID. Pourtant, je m’efforce de produire un code de haute qualité, tant en termes de lisibilité, que de réutilisabilité et de performance, et j’ai lu beaucoup, beaucoup de choses sur les design patterns et les principes SOLID. J’ai même commencé à tester en PHP avec Codeception.
Prendre le pli des tests
Mais le déclic c’est produit quand je suis passé à Ruby on Rails, qui considère les tests comme aussi importants que le code, et les génère en même temps. C’était la motivation dont j’avais besoin pour tester tous les aspects de mon code, plus tôt dans le processus, et mon style de codage a commencé à s’ajuster pour simplifier les tests. Mais assez de bavardages personnels, voici tout ce que j’ai appris sur les tests !
Tester, ce n’est pas (seulement) douter, c’est s’assurer que l’on tient la promesse que l’on a faite en prenant un ticket. Et cela améliore votre code en vous forçant à prendre en compte des situations qui auraient pu être négligées. Tout comme l’enseignement améliore la maîtrise du sujet, les tests vous obligent à regarder votre code sous un angle différent, ce qui peut révéler des bugs, des problèmes de performance ou des défauts architecturaux.
Tester, c’est s’assurer que le code fait ce qu’il est censé faire. Mais parfois, on ne sait pas vraiment ce qu’on est censé faire ! Il est préférable dans ce cas d’éliminer les incertitudes avant d’écrire du code, car des clarifications ultérieures pourraient ajouter de la complexité, nécessiter une refactorisation ou permettre d’emprunter des raccourcis.
L’écriture des tests aide aussi à rester concentré sur le problème en cours, ni plus ni moins. C’est d’ailleurs tout l’intérêt du TDD (Test-Driven Development) ! Si vous ne l’avez pas encore fait, je vous recommande de suivre le parcours Fundamental TDD sur la plateforme Upcase de Thoughtbot.
Couverture de code
La couverture de code est une mesure de la quantité de code exercée par les tests, exprimée en pourcentage.
Les tests peuvent prendre du temps et n’apportent pas de valeur directe, à part éviter des bugs qui ne se produiront jamais. Difficile dans ce cas de justifier d’avoir passé des heures à écrire des tests ! Par conséquent, les nouveaux développeurs devront rapidement décider de la bonne quantité de tests à écrire.
En faisant des recherches pour cet article, je suis tombé sur The Way of Testivus et j’ai été illuminé. Puis j’ai trouvé la réponse de Testivus à : How Much Unit Test Coverage Do You Need? et mon illumination elle-même en a été éclairée. Il n’y a donc pas de bonne réponse, exceptée le classique « Ça dépend ».
- Si le projet vient de commencer, écrivez d’abord des tests pour les fonctions cruciales. Gardez à l’esprit que certaines parties de l’application seront jetées et réécrites très prochainement.
- Si le projet est complexe et manque de tests, écrivez des tests au fur et à mesure que vous corrigez les bugs et ajoutez des fonctionnalités. Visez à améliorer la couverture de code de manière progressive et continue.
- Si le projet est sain et bien couvert, réjouissez-vous ! Et assurez-vous de toujours ajouter des tests pour tout nouveau code pour garder le projet aussi sain que vous l’avez trouvé.
Mais attention, une ligne de code peut contenir plusieurs branches.
Par exemple, il y a 4 branches dans if a && b
(bien que seules 3 importent, puisque lorsque a
est vrai, la valeur de b
est ignorée).
Les outils de couverture de code peuvent signaler que la ligne est couverte même si une seule condition est testée.
Entrées et Sorties
Les fonctions acceptent des données en entrée, et renvoient des valeurs en sortie. La plupart des entrées arrivent sous forme de paramètres, mais attention, en Programmation Orientée Objet, certaines entrées proviennent de l’état de l’objet, de l’environnement, ou - ô surprise ! - d’un autre objet.
Après avoir identifié toutes les entrées, listez la plage de valeurs de chaque entrée, faites-la correspondre à la sortie attendue, puis testez toutes les situations. S’il y a un trop grand nombre d’entrées et de sorties, Rubocop vous avertira que la complexité cyclomatique est trop élevée. Cherchez un moyen de réduire leur nombre, ou divisez la fonction pour simplifier les tests et améliorer la lisibilité lorsque c’est le cas.
Notez également que la plupart des fonctions peuvent lancer des exceptions, qu’il peut être nécessaire de prendre en compte dans les tests.
En fonction du type de fonctions et d’entrées, vous devrez utiliser différentes heuristiques pour écrire la bonne quantité de tests. L’atelier « Good Enough Testing » de Lucian s’est concentré sur ce point, et j’ai beaucoup appris en deux heures. Vous aurez l’occasion d’assister à cet atelier la semaine prochaine si vous allez à Euruko, sinon attendez que Lucian annonce une nouvelle date.
Isoler le Système en cours de test
Dans mon empressement à tester de manière approfondie, il m’est arrivé au début d’écrire des tests pour les validations, avant de réaliser qu’ils exerçaient finalement le framework. Il n’est généralement pas utile de tester les fonctions natives de Rails, car le framework a une couverture de code assez élevée. Dans un article de 2020, FastRuby.io a calculé que la couverture dans Rails 6.1 était d’environ 80%, la plupart des composants étant autour de 90%, même si quelques-uns n’étaient qu’à 30-40%.
Lorsque le projet est correctement couvert par des tests unitaires, il est préférable d’utiliser des mocks et des doubles au lieu d’objets externes. Les tests qui simulent des objets externes sont beaucoup plus rapides, utilisent beaucoup moins de mémoire, évitent d’accéder à la base de données et ont beaucoup moins de chances d’échouer en raison de changements distants dans le code.
Comme le souligne Sandi Metz dans Principles of Object-Oriented Design for Ruby, il est important que les mocks et les doubles échouent s’ils reçoivent des méthodes que l’objet réel n’accepte pas. Il y a tout un chapitre sur ce sujet, et j’ai trouvé que la conférence de Jared Norman à la RailsConf 2024 intitulée « Undervalued: the Most Useful Design Pattern » était un très bon complément, car elle montre une progression claire depuis un code fonctionnel vers un code réellement pérenne. Vous pouvez également suivre le parcours Test Double sur Upcase pour une approche plus pratique.
Fixtures et Factories
Lorsque j’ai commencé à utiliser Rails, mon mentor m’a conseillé l’utilisation de Rspec, FactoryBot, Faker et DatabaseCleaner. J’ai repris les mêmes outils sur mes projets suivants, et j’ai constaté que des spécifications claires et des factories bien organisées sont assez efficaces.
Les fixtures se chargent toutes en une fois et sont réutilisées par tous les tests. On évite donc beaucoup de duplication entre les tests, mais au prix d’un grand écart entre la définition des données et leur utilisation dans les différents tests.
Les factories ont leurs avantages et inconvénients, le plus grand étant probablement leur propension à ralentir les suites de tests à cause de leur trop grande interdépendance. Les Evil Martians ont récemment aidé l’un de leurs clients à augmenter la vitesse d’exécution des tests d’un facteur cinq (!), principalement en ajustant les factories. J’ai aussi vu des tests sans lien avec le code ajouté, qui se mettaient à échouer après avoir corrigé des factories mal configurées, et c’est assez ennuyeux quand ça arrive.
La plupart des équipes utilisent Rspec et FactoryBot, mais Rails lui-même est célèbre pour rester fidèle à Minitest et aux fixtures.
Ma première contribution à Rails (Permettre le passage de classes à dom_id
) a donc été l’occasion d’essayer cette configuration.
J’étais un peu mal à l’aise car la codebase de Rails est particulièrement vaste, et les fixtures sont partagées par d’autres tests, mais la syntaxe elle-même était facile à saisir.
Enfin, j’ai vu que Kasper Timm Hansen a récemment lancé Oaken, qui vise à apporter le meilleur des deux mondes (Factories et Fixtures). Je n’ai pas encore eu l’occasion de l’essayer dans un projet personnel, mais cela semble prometteur.
Pour aller plus loin
Le sujet des tests est vaste (méthodologie, type de tests, etc), aussi je me contente de ces trois références, qui vous amèneront probablement à d’autres. Bonne lecture !