Blog Arolla

Get the legacy code tested or die tryin’

Aujourd’hui nos applications répondent à de plus en plus de besoins, et ce jusqu’à devenir énormes, gigantesques, « monstrueuses » :). Et c’est exceptionnel d’avoir une couverture de tests permettant de refactorer et d’ajouter du fonctionnel sans crainte. Commencer des développements sur des nouveaux besoins n’est pas forcément chose simple (même en TDD), mais lorsqu’il s’agit de refactorer ou modifier du code existant, cela devient encore plus complexe. En effet, il ne faut surtout pas perdre le fonctionnement actuel, résultat parfois de plusieurs années de modifications, ce qui conduit à un comportement implicite de l’application, non conforme à la documentation, hélas devenue obsolète.
Il existe pourtant des techniques pour nous aider à tendre un filet de sécurité avant de refactorer une fonctionnalité non testée (ou pas assez). Ces techniques, que je vais introduire par la suite, se basent sur le principe suivant lequel le code de l’application est une « boîte noire »: on ne le comprend peu ou pas assez (certains diront même pas du tout) et nous allons tout faire pour en modifier le minimum ou bien le réécrire. On va donc utiliser un jeu de données en entrée et en sortie. Le but sera de comparer les outputs produits entre différentes implémentations à partir d’un même input. Dans notre cas, il s’agira de comparer les résultats d’exécution de la version de production avec ceux de la version en cours de réécriture/modification. Ces méthodes vont surtout varier dans la façon dont on génère le jeu d’inputs.

Golden Master (GM)

Avant de refactorer/modifier le code de la version « certifiée » (en règle générale, la production), on effectue les actions suivantes :

  • On créé un vaste jeu de données (de manière aléatoire ou non).
  • On passe en entrée à la méthode à modifier chacun des éléments du précédent jeu de données.
  • On stockera ensuite les entrées et les résultats de ces exécutions dans un fichier ou en base. Cet ensemble entrées-sorties constituera le Golden Master.

Après chaque modification de code, on ré-exécute le jeu de tests précédent. Dans le cas d’un refactoring, s’il y a une différence dans les résultats, on corrige le tir. Dans le cas d’une modification fonctionnelle, si différence il y a, on vérifie que le nouveau résultat corresponde bien à ce qui est attendu. Si c’est le cas, nous modifions le GM pour prendre en compte ce nouveau résultat et obtenons ainsi un nouveau GM, sinon on rectifie.
Attention ! En fonction de la granularité du système que vous testerez, vos états possibles pourront être nombreux. Le jeu d’entrées pourra rapidement devenir énorme. La génération et surtout la maintenance de ce dernier peuvent rapidement engendrer des coûts prohibitifs. C’est pourquoi il est préférable de n’utiliser ce genre de technique que pour des refactorings rapides, 1, 2 ou 3 sprints, en n’embarquant aucune modification fonctionnelle.

Record and Play (RaP) / Code and Play (CaP)

Autre technique : le Record and Play (RaP). Toujours dans le but de créer un harnais de tests, le moyen est ici d’enregistrer un ou plusieurs scénarios d’utilisation de l’application dans son IHM (web, mobile ou desktop). Une fois le code modifié, comme pour le GM, on rejoue les tests. Si nous sommes dans le cas d’un refactoring, le fait de relancer les tests doit conduire aux mêmes résultats, sinon on corrige les modifications effectuées. Dans le cas d’une modification, on vérifie que le résultat de la modification correspond bien à ce qui est attendu, et on réenregistre la séquence pour ce cas de tests.
Ces tests sont cependant assez fragiles car en plus du système sous-jacent, les résultats d’exécution dépendent de l’IHM. Pour que les résultats soient exploitables, il faut donc figer l’IHM et le système à tester. Plus il y a de personnes impactées par ce refactoring, plus la difficulté augmente. Leur maintenance s’avère d’ailleurs plus pénible que celle du GM car à la moindre modification, il faut soit réenregistrer toute une séquence, soit modifier la valeur de certains résultats dans les fichiers de tests.
Généralement, c’est Sélénium qui est utilisé pour enregistrer ces scénarios pour une application web. Il existe d’autres outils de Ra. Certains sont payants et permettent également de tester des applications desktop ou mobile (par exemple Ranorex).
Par ailleurs, il est possible d’utiliser Sélénium autrement qu’en RaP. Pour cela, nous écrivons les tests directement dans un langage comme java, ce que nous appellerons ici Code and Play (CaP). Si cela est convenablement fait, les tests sont plus facilement maintenables. Je vous renvoie pour cela vers l’utilisation du pattern PageObject dont une description est disponible ici.. Je conseillerais, dans ce cas, la librairie Simplelenium,, qui gère mieux certaines problématiques de timing, et propose davantage de méthodes permettant de rendre vos tests plus « human-readable ».

Technique d’Experiment

Abordons maintenant la dernière technique de cet article : l’Experiment. Lorsque GitHub a décidé de réécrire sa fonction de merge, il était impossible de prévoir à l’avance les différents cas qui allaient se présenter. Impossible donc de sélectionner des cas d’utilisation et de générer les inputs correspondants. Le plus sûr était donc de comparer ce qui se passait sur la production avec une version refactorée. Pour ce faire, il faut envoyer deux implémentations d’une même fonctionnalité en production : une effective (le contrôle), l’autre « dormante » (la candidate). Lorsque la fonctionnalité est utilisée, les inputs sont passés à chacune de ces deux versions. Les résultats des deux exécutions sont enregistrés et comparés directement. Si des exceptions ou des différences sont rencontrées entre les deux versions, celles-ci sont loguées dans l’interface web de leur framework. Seuls les résultats de la version legacy sont utilisés pour la suite de l’interaction avec l’utilisateur, le fait d’avoir cette double exécution étant parfaitement transparent pour lui.
En comparant les résultats obtenus par la version legacy et la version en cours de refactoring à chaque appel de la méthode de merge, il est possible de combler petit à petit les différences rencontrées. Au fur et à mesure de cette amélioration continue, les différences s’amenuisent jusqu’à ce que la nouvelle version devienne la version de production. Ceci, sans que jamais les utilisateurs n’en pâtissent. Je rappelle que lors de la phase de comparaison, la vérité est et reste le code legacy. Seule son exécution est prise en compte pour tout ce qui est traitement fait par l’utilisateur. Lors de la bascule, il suffit de remplacer l’appel à l’ancien code par un appel au nouveau.
Pour en savoir plus sur la façon dont a procédé GitHub, je vous invite à lire cet article présentant le framework Ruby nommé « Scientist » créé à l’occasion. Il existe un portage Java de ce framework : « Experiment4J », trouvable ici
.
Il faut aussi rajouter que Github peut utiliser cette méthodologie de test car il leur est possible de passer très rapidement leur nouveau code en production (à l’époque de leur article, ils déployaient leur application principale 60 fois par jour). On en déduit que Github bénéficie d’un processus de mise en production bien rodé.

Un petit retour d'expérience

Les différentes techniques détaillées dans cet article sont celles que j’ai trouvées afin de répondre aux besoins que j’ai rencontrés. J’ai utilisé le GM et le CaP en Java à plusieurs reprises et cela a donné de très bons résultats.

  • Pour le GM, c’était dans le cas d’un injecteur (trop) intelligent de documents. Des règles métiers le constituaient. J’ai donc dû créer un jeu de documents à injecter (mon GM) qui constituait mes tests. A chaque évolution, on relançait l’intégralité des tests. Cela m’a prémuni de certaines régressions que je n’avais pas constatées lors de l’écriture de code.
  • Pour le CaP, j’ai écrit des tests end-to-end de ce que faisait l’application. Cette dernière étant relativement petite, j’ai pu couvrir l’ensemble des cas nominaux. Ma chance était que dans ces deux cas, le nombre de tests à écrire était peu élevé par rapport à d’autres applications (mais nombreux quand même). Cela a permis de ne pas les jeter mais de les maintenir.

Dans d’autres cas, les applications étaient un peu plus grandes fonctionnellement parlant que les deux précédentes. L’écriture des tests et leur maintenance étaient un peu plus ardues du fait de la volumétrie « applicative », mais ils restaient maintenables.

En conclusion

Les différentes méthodes évoquées permettent d’avoir assez rapidement un jeu de tests convenable afin de refactorer voire légèrement modifier le code de nos applications. Chacune de ces méthodes possède ses avantages et inconvénients :

  • Pour le GM et le RaP, leur maintenance peut s’avérer coûteuse. Il est parfois moins onéreux de refaire un jeu de tests plutôt que de maintenir celui déjà créé.
  • Dans le cas de l’Experiment, un bon processus de mise en production est nécessaire. De plus, une double exécution peut aussi s’avérer lourde en termes de vélocité applicative.

Pourtant, malgré ces défauts, le fait d’avoir un filet de sécurité pour le refactoring/l’évolution du code, permet d’éviter des régressions en production. Le fait de fixer celles-ci s’avérera certainement plus coûteux que de générer ce filet de sécurité. Par ailleurs, il est conseillé de restreindre la surface applicative du code à modifier. Plus petite elle sera, plus aisés seront la généreration les jeux de tests et le refactoring. Pour ces méthodologies, une fois ce dernier effectué et validé, les jeux de tests sont purement et simplement supprimés.
Mais comme toutes les techniques, ce ne sont pas forcément des silver-bullets. Ce qui correspondait à mon contexte ne sera pas forcément le cas pour le vôtre.
Dans tous les cas, bon courage à tous pour la maintenance de votre legacy ! (joie et bonheur)

Plus de publications

Comments are closed.