La pyramide des tests par la pratique (1/5)
Si vous lisez ce blog ainsi que nos publications, vous n’êtes pas sans savoir à quel point les tests sont indissociables de la qualité logicielle, et j’oserais même dire de la réussite logicielle. J’insiste sur ce point car trop souvent encore chez nos clients, les tests sont la cinquième roue du carrosse lorsqu’il s’agit de développement. Les conséquences, vous les connaissez : une quantité astronomique d’anomalies remontées en recette, des bugs bien mesquins en production et pire encore, un logiciel qui s'ankylose petit à petit…
Cet article est le premier d’une série et adressera essentiellement la théorie. Nous rentrerons dans la pratique dans les prochains articles (appliquée sur les technologies Java / Spring).
Aparté
Parfois, on décide de tester, on arrive à convaincre les “hautes sphères” du bénéfice et qu’il faut y consacrer du temps (et donc de l’argent), mais :
- “Les test unitaires, on n’y arrivera pas, notre code est trop complexe” (trop couplé)
- “Les tests d’intégration, c’est compliqué à mettre en place, autant vouloir déployer l’application au complet.”
- “Le mieux, ça serait quand même d’avoir des tests proches de ce que fait notre utilisateur ! Et ça sera plus simple à tester.”
Et nous voilà partis pour déployer l’artillerie lourde : les tests de bout en bout (généralement des tests d’interface graphique type Selenium):
- “Ça va être génial ! On va valider toute l’application en quelques tests, on pourra même montrer les rapports au métier et aux testeurs (pour peu qu’on mette du Cucumber ou du Serenity). Ils vont nous adorer ????.”
Après quelques semaines / mois, on se rend compte que ce n’était peut-être pas la meilleure idée qu’on ait eue :
- les tests sont lents: “4h pour 150 tests”,
- échouent par intermittence : “Pourquoi c’est rouge ? Probablement Selenium, relance pour voir...”,
- mais pire que tout, avoir un environnement opérationnel complet et des jeux de données stables relève du casse-tête, voire de la mission impossible ????.
On investit d’autant plus que l’on se dit qu’il ne doit pas manquer grand-chose et que ce serait dommage de tout jeter maintenant. Et effectivement, on peut améliorer les choses, mais à quel prix ? Et pour combien de temps ?! Globalement la stratégie de tests “en boîte noire” n’est ni des plus efficaces, ni très rentable.
J’en entends plus d’un sourire (jaune) en lisant cela, mais rassurez-vous : vous n’êtes pas seuls…
Stratégie de tests
Avant de vous parler de la sempiternelle pyramide des tests, rappelons quelques critères qu’il est important d’évaluer dès lors que l’on réfléchit à une stratégie de tests. Afin de lever une ambiguïté trop courante, précisons que nous parlons dans cet article de tests automatisés.
Feedback
Le test, quel qu’il soit, n’a d’autre intérêt que de vous donner un feedback : “mon programme fait-il bien ce qu’il doit ?” On peut juger de la qualité de ce feedback sur 3 aspects :
La précision du feedback. Si un test échoue, suis-je capable de déterminer précisément quelle partie du code ne fonctionne pas ? Combien de temps faut-il à un développeur pour identifier le morceau de code qui met le test en échec ? Plus un test est fin (au niveau de la méthode), plus le feedback sera précis. Au contraire, comment savoir si c’est l’accès à la base de données ou une erreur JavaScript qui provoque l’échec d’un test de bout en bout ?
Source : Culture Code, OCTO Tehnology
La fiabilité du feedback. La répétabilité d’un test est primordiale : Peut-on faire confiance à un test dont les résultats varient d’une exécution à l’autre, sans qu’aucune modification apparente n’ait été faite sur le code, la configuration ou une dépendance ? Encore une fois, les tests de bout en bout ont cette fâcheuse tendance à exploser pour des raisons obscures et souvent incontrôlables : une latence réseau, un passage de Garbage Collector qui ralentit la JVM, le navigateur qui fait des siennes, un compte désactivé, un schéma de base de données modifié…
“Les tests instables sont pires que pas de tests”, concluent les ingénieurs de LinkedIn après avoir calculé qu’ils avaient 13.4% de chance d’avoir un build stable avec 200 tests et seulement 1% de risque d’échec sur chaque test.
"Stop calling your tests flaky. Instead, call it ‘Random Success’ or ‘Sometimes Success.’ Would you want to release a product that ‘sometimes works’?"
La rapidité du feedback. La plupart d’entre nous n’étions pas nés lorsque l’on utilisait les cartes perforées pour programmer. Cette époque glorieuse, mais heureusement révolue, où il fallait des heures voire des jours pour savoir si la pile de cartes “compilait” et recommencer dans le cas contraire... Il n’est plus envisageable aujourd’hui d’attendre autant avant de savoir si notre code compile (c’est instantané dans l’IDE). Il en est de même pour les tests : plus rapidement vous saurez si un test échoue, plus vite vous pourrez corriger le problème et moins cher il vous en coûtera de le faire ^<a id="post-5-footnote-ref-1" href="#post-5-footnote-1">[1]</a>^ :
Source : Code complete, 2nd edition, by Steve McConnell ^<a id="post-5-footnote-ref-2" href="#post-5-footnote-2">[2]</a>^
On constate même un cercle très vertueux lorsque les tests sont rapides : ils sont confortables à exécuter^<a id="post-5-footnote-ref-3" href="#post-5-footnote-3">[3]</a>^ et fournissent une sérénité sans pareille, incitant à en écrire encore davantage.
La stratégie de tests visera donc à maximiser le nombre de tests qui remplissent ces trois critères (précision, rapidité, fiabilité).
Facilité de création et coût de maintenance
Sachant qu’il est peu probable que vous ayez un budget infini, la stratégie de tests va nécessairement en dépendre. Il vous faudra mettre le coût des différents types de test en regard de leur intérêt, ou dit plus simplement évaluer leur ROI :
- Les tests unitaires sont en général très simples à mettre en œuvre (à condition de ne pas attendre que le code soit complètement endetté). Fonctionnant en isolation, l’environnement d’exécution est relativement simple à mettre en place (stubs / mocks). Très proches du code, ils sont ensuite naturellement maintenus (à l’aide du refactoring).
- Les tests d’intégration sont assez simples également (on parle de tests d’intégration au sein d’une application, pas entre applications). On s’affranchit des contraintes majeures (IHM, certaines dépendances). Il sont également assez proches du code, ce qui facilite leur refactoring. Cependant, ils couvrent un spectre plus large de code et de ce fait, ont davantage de risques d’être impactés par une modification.
- Les tests de bout en bout ou d’IHM sont plus complexes à mettre en œuvre, car ils nécessitent le déploiement d’un environnement complet. La présence des dépendances et les jeux de données sont généralement un casse-tête : instabilités dues à des rechargements depuis la (pré)prod, modifications par d’autres équipes, … Ces éléments rendent ce type de test très fragile et induisent un coût de maintenance important (analyse des builds en erreur, maintien de l’environnement et des données).
Le tableau suivant synthétise les principaux critères en matière de choix du type de test à privilégier :
Vu comme cela, on pourrait se dire “chouette, je n’ai qu’à faire des tests unitaires”, mais il est bien évident que les autres types de tests existent pour une bonne raison : les tests unitaires ne valident pas tout.
Pyramide des tests
Nous en arrivons donc à la fameuse pyramide des tests (automatisés), initialement décrite par Mike Cohn dans le livre Succeeding with Agile et qui sera d’une aide précieuse dans la définition de votre stratégie de test.
- On investit massivement sur les tests unitaires qui vont fournir une base solide à la pyramide, avec un feedback précis et rapide. Couplés à l’intégration continue, les tests unitaires fournissent un véritable harnais contre les régressions, indispensable si l’on souhaite maîtriser notre composant à moyen/long terme.
- En haut de la pyramide, on réduit la quantité de tests de bout en bout ou IHM au strict minimum : validation de l’intégration du composant dans le système global (configuration, connectivité), composants graphiques faits maison, éventuellement quelques smoke tests (en guise de tests d’acceptance).
- Et au milieu de la pyramide, les tests d’intégration nous permettent de valider le composant (intégration interne) et ses frontières (intégration externe). Le principe est là encore de privilégier les tests internes au composant (en isolation du reste du système) par rapport aux tests externes, plus complexes à mettre en œuvre :
Voilà pour la théorie. Le prochain article traitera plus en profondeur de la base de la pyramide : les tests unitaires et leur mise en pratique sur un projet Java / Spring.