Dans notre post précédent, nous avons repris la définition de Michael Feathers (depuis son livre « Working Effectively with Legacy Code ») selon lequel l’absence de tests unitaires est le facteur déterminant d’une application Legacy. Il propose le concept de test de caractérisation afin de comprendre le comportement de l’application, c’est-à-dire ce qu’elle fait réellement, et non pas de chercher à découvrir à travers le code ce qu’elle est censée faire.
Mais qu’en est-il lorsque notre application Legacy ne dispose pas déjà de tests unitaires ? La réponse à l’un de nos 3 scénarios – plan de transfert de l’application à une autre équipe – peut-elle passer par l’écriture de tests ? Est-il possible de faciliter ce transfert de connaissances avec des tests de ce type ?
Tests de caractérisation
Michael Feathers préconise dans son livre de réaliser pour ce faire des tests destinés, non pas seulement à vérifier que le code est correct, mais aussi et surtout à caractériser son comportement, c’est-à-dire à découvrir ce que fait réellement ce code. Ceci présente donc le double avantage de :
- Permettre, sinon le transfert, du moins l’acquisition de la connaissance applicative par une nouvelle équipe.
- Développer des tests qui seront valides pour notre future opération de refactoring ou de re-engineering puisque le comportement de notre application doit rester constant suite à cette opération.
Michael Feathers recommande de procéder de la manière suivante :
- Pour un bloc de code que l’on souhaite tester / documenter, écrire un test dont on sait qu’il échouera.
- Lancer le test et noter la réponse attendue par le bloc de code, correspondant donc au comportement attendu.
- Modifier le test de manière à refléter le comportement correct (donc un test qui retourne un résultat positif).
- Répétez autant de fois que souhaité pour ce bloc de code.
L’exemple que donne Michael Feathers correspond à du langage Java, et peut s’avérer différent pour du code Legacy en C. Dans son implémentation tout au moins, mais cela dépendra de toutes façons de la manière dont vous souhaitez procéder, notamment si vous utilisez un framework de tests, en fonction des possibilités et des fonctionnalités de ce dernier. Je ne vais pas m’étendre sur ce point : les développeurs C/C++ en savent plus que moi sur ce sujet et comprendront ce dont il s’agit.
Exemple de la fonction RTFOUT
Sur le fond, la méthode préconisée par Michael Feathers reste la même, quelque soit la technologie (C, Java, etc) que vous souhaitez ‘caractériser’ et le(s) outil(s) que vous allez employer. Voyons en un exemple avec notre application Word Opus 1.1a.
Nous savons que la fonction la plus complexe, avec 355 points de CC (Complexité Cyclomatique) compte 2 063 LOCs (Line Of Code) !
Un coup d’œil dans le dashboard SonarQube m’apprend que cette fonction se trouve dans un fichier du même nom ‘Opus\RTFOUT.c’, dont elle représente la quasi-totalité du code (il existe une autre fonction comportant 2 points de CC), le tout avec 1 124 instructions et un taux de commentaires de 17.5%.
Les 200 premières lignes constituent une accumulation d’includes et de variables aux noms ésotériques, et non documentées ou mal commentées :
Inutile même d’essayer d’y comprendre quoi que ce soit.
Par contre, après quelques if .. else imbriqués, je tombe rapidement sur le switch suivant :
Je comprends alors rapidement que nous sommes en présence de la fonction chargée de produire le format RTF (Rich Text Format) du texte entré sous Word, et ce premier switch va spécifier le type de police – Modern, Roman, Swiss, etc. – utilisé dans ce format. Et que la variable ‘fmc’ va gérer ces valeurs.
Un autre switch, assez long, concerne les propriétés du document, lorsqu’elles existent : le titre, le sujet, l’auteur, etc.,
… la date de dernière édition du document, le nombre de pages, le nombre de mots ou de caractères :
Je constate donc que je peux déjà repérer tous les blocs de code suffisamment simples et lisibles pour développer des tests de caractérisation, un pour chaque valeur possible rencontrée dans une structure conditionnelle (end..if, switch) ou de boucle (qui comporte également une condition). En l’espèce, nous pouvons écrire un ou plusieurs tests afin de vérifier des valeurs existantes ou non.
Par exemple, je testerai les différents états possible de la variables ‘fmc’ avec les valeurs que nous pouvons découvrir dans le code : ‘FF_ROMAN’, ‘FF_MODERN’, … ou une valeur inexistante ‘FF_WRONG’ afin de voir comment réagit l’application.
De même pour la variable ‘flt’ qui gère les propriétés du document : je peux tester toutes sortes de valeurs ‘anormales’, afin de voir encore une fois comment se comporte l’application dans ce cas. Que se passe-t-il par exemple si j’effectue un test :
- Avec un nombre de pages égal à 999 999 ?
- Avec des caractères spéciaux (@, #, !, ¿, …) dans le nom de l’auteur ?
- Avec des formats de date différents pour la date de dernière mise à jour ?
Bien sûr, certains blocs de code, comme la gestion en mémoire de la table des bookmarks dans un document Word, vont s’avérer trop complexes pour qu’il soit possible de les comprendre et de les tester correctement sans aucune aide. Mais rappelons que l’objectif premier n’est pas de comprendre ce qu’est censée faire l’application à travers son code, sinon de caractériser son comportement.
Je rencontre également très rapidement des blocs de code qui sont répétés avant chaque boucle ou chaque switch. Je suppose qu’il s’agit de variables initialisées avant chaque traitement et mise à jour durant celui-ci. Je note de vérifier dans la documentation (si elle existe) ou de demander à l’équipe actuelle à quoi correspondent ces variables. Si elles sont répétées si souvent, cela peut avoir un impact en termes de design lors d’un refactoring ou d’un re-engineering.
NOTE : un des problèmes avec le C est que certaines fonctions seront externalisées dans des includes ou une macro. Comme le décrit Michael Feathers dans son livre, il est tout à fait possible de modifier le code de manière à créer notre propre include avec un appel à ces fonctions, afin de tester rapidement si celles-ci sont appelées avec le bon nombre et le bon type de paramètres. N’hésitez pas à vous référez à son livre si vous avez des questions : je ne vais pas évoquer toutes celles-ci dans ce post.
Autre problème que j’ai rencontré dans le code : beaucoup de directives de compilation #IFNDEF ou pour des plateformes différentes (Mac). Donc à prendre en compte au niveau du reengineering.
Synthèse
L’avantage de la démarche préconisée par Michael Feathers est de nous intéresser non pas à ce que l’application est censée faire, à travers son code, ce qui est toujours très long, voire parfois impossible, mais à ce que l’application fait réellement. D’autant que l’application ne se comporte pas toujours comme elle est censée le faire.
Il est possible de créer très rapidement des tests sur des blocks de code impliquant des conditions, au sein de structures conditionnelles (if..else, switch) ou de boucle. N’oublions pas que chaque ‘path’ ou ‘chemin’ dans ces structures correspond à une règle de logique métier (ou technique) et donc doit normalement être couverte par un test correspondant.
Ce qui nous amène à la question suivante : combien de tests de caractérisation sont nécessaires afin d’assurer un transfert de connaissances (un de nos 3 scénarios) ? Quelle couverture de code assurer par ces tests avant de commencer un refactoring ou un re-engineering ? Peut-on estimer l’effort de tests que cela représente ? C’est ce que nous verrons dans notre prochain post.
Cette publication est également disponible en Leer este articulo en castellano : liste des langues séparées par une virgule, Read that post in english : dernière langue.