Rédigez des tests qui comptent : abordez d'abord le code le plus complexe

Publié: 2022-03-11

Il y a beaucoup de discussions, d'articles et de blogs autour du thème de la qualité du code. Les gens disent - utilisez les techniques Test Driven ! Les tests sont un "must have" pour commencer tout refactoring ! C'est cool, mais nous sommes en 2016 et il y a un volume énorme de produits et de bases de code encore en production qui ont été créés il y a dix, quinze ou même vingt ans. Ce n'est un secret pour personne que beaucoup d'entre eux ont un code hérité avec une faible couverture de test.

Bien que j'aimerais être toujours à la pointe, voire saignante, du monde technologique - engagé dans de nouveaux projets et technologies sympas - malheureusement, ce n'est pas toujours possible et je dois souvent faire face à d'anciens systèmes. J'aime dire que lorsque vous développez à partir de zéro, vous agissez comme un créateur, maîtrisant une nouvelle matière. Mais lorsque vous travaillez sur du code hérité, vous ressemblez plus à un chirurgien – vous savez comment le système fonctionne en général, mais vous ne savez jamais avec certitude si le patient survivra à votre « opération ». Et comme il s'agit d'un code hérité, il n'y a pas beaucoup de tests à jour sur lesquels vous pouvez compter. Cela signifie que très souvent l'une des toutes premières étapes consiste à le couvrir de tests. Plus précisément, non seulement pour fournir une couverture, mais pour développer une stratégie de couverture des tests.

Couplage et complexité cyclomatique : métriques pour une couverture de test plus intelligente

Oubliez la couverture à 100 %. Testez plus intelligemment en identifiant les classes les plus susceptibles d'échouer.
Tweeter

Fondamentalement, ce que j'avais besoin de déterminer, c'était quelles parties (classes / packages) du système nous devions couvrir avec des tests en premier lieu, où nous avions besoin de tests unitaires, où les tests d'intégration seraient plus utiles, etc. Il existe certes de nombreuses façons de approcher ce type d'analyse et celle que j'ai utilisée n'est peut-être pas la meilleure, mais c'est une sorte d'approche automatique. Une fois mon approche implémentée, il faut un minimum de temps pour effectuer l'analyse elle-même et, ce qui est plus important, cela apporte du plaisir dans l'analyse du code hérité.

L'idée principale ici est d'analyser deux métriques - le couplage (c'est-à-dire le couplage afférent ou CA) et la complexité (c'est-à-dire la complexité cyclomatique).

Le premier mesure combien de classes utilisent notre classe, il nous indique donc essentiellement à quel point une classe particulière est proche du cœur du système ; plus il y a de classes qui utilisent notre classe, plus il est important de la couvrir de tests.

D'autre part, si une classe est très simple (par exemple, ne contient que des constantes), alors même si elle est utilisée par de nombreuses autres parties du système, il n'est pas aussi important de créer un test pour. Voici où la deuxième métrique peut aider. Si une classe contient beaucoup de logique, la complexité Cyclomatic sera élevée.

La même logique peut également être appliquée en sens inverse ; c'est-à-dire que même si une classe n'est pas utilisée par de nombreuses classes et ne représente qu'un cas d'utilisation particulier, il est toujours logique de la couvrir de tests si sa logique interne est complexe.

Il y a cependant une mise en garde : disons que nous avons deux classes - une avec le CA 100 et la complexité 2 et l'autre avec le CA 60 et la complexité 20. Même si la somme des métriques est plus élevée pour la première, nous devrions certainement couvrir le deuxième en premier. C'est parce que la première classe est utilisée par beaucoup d'autres classes, mais n'est pas très complexe. D'autre part, la deuxième classe est également utilisée par de nombreuses autres classes mais est relativement plus complexe que la première classe.

Pour résumer : nous devons identifier les classes à haute complexité CA et Cyclomatic. En termes mathématiques, une fonction de fitness est nécessaire qui peut être utilisée comme une évaluation - f(CA,Complexity) - dont les valeurs augmentent avec CA et Complexity.

De manière générale, les classes présentant les plus petites différences entre les deux métriques doivent recevoir la priorité la plus élevée pour la couverture des tests.

Trouver des outils pour calculer le CA et la complexité pour l'ensemble de la base de code, et fournir un moyen simple d'extraire ces informations au format CSV, s'est avéré être un défi. Lors de mes recherches, je suis tombé sur deux outils qui sont gratuits donc il serait injuste de ne pas les citer :

  • Métriques de couplage : www.spinellis.gr/sw/ckjm/
  • Complexité : cyvis.sourceforge.net/

Un peu de maths

Le principal problème ici est que nous avons deux critères - CA et complexité cyclomatique - nous devons donc les combiner et les convertir en une seule valeur scalaire. Si nous avions une tâche légèrement différente - par exemple, trouver une classe avec la pire combinaison de nos critères - nous aurions un problème d'optimisation multi-objectif classique :

Nous aurions besoin de trouver un point sur le soi-disant front de Pareto (rouge dans l'image ci-dessus). Ce qui est intéressant à propos de l'ensemble de Pareto, c'est que chaque point de l'ensemble est une solution à la tâche d'optimisation. Chaque fois que nous nous déplaçons le long de la ligne rouge, nous devons faire un compromis entre nos critères – si l'un s'améliore, l'autre s'aggrave. C'est ce qu'on appelle la scalarisation et le résultat final dépend de la façon dont nous le faisons.

Il y a beaucoup de techniques que nous pouvons utiliser ici. Chacun a ses propres avantages et inconvénients. Cependant, les plus populaires sont la scalarisation linéaire et celle basée sur un point de référence. Le linéaire est le plus simple. Notre fonction de fitness ressemblera à une combinaison linéaire de CA et de complexité :

f(CA, Complexité) = A×CA + B×Complexité

où A et B sont des coefficients.

Le point qui représente une solution à notre problème d'optimisation se trouvera sur la ligne (bleue dans l'image ci-dessous). Plus précisément, il se trouvera à l'intersection de la ligne bleue et du front de Pareto rouge. Notre problème initial n'est pas exactement un problème d'optimisation. Nous devons plutôt créer une fonction de classement. Considérons deux valeurs de notre fonction de classement, essentiellement deux valeurs dans notre colonne Rank :

R1 = A∗CA + B∗Complexité et R2 = A∗CA + B∗Complexité

Les deux formules écrites ci-dessus sont des équations de lignes, de plus ces lignes sont parallèles. En tenant compte de plus de valeurs de rang, nous obtiendrons plus de lignes et donc plus de points où la ligne de Pareto croise les lignes bleues (en pointillés). Ces points seront des classes correspondant à une valeur de rang particulière.

Malheureusement, il y a un problème avec cette approche. Pour toute ligne (valeur de rang), nous aurons des points avec une très petite CA et une très grande complexité (et vice versa) qui s'y trouvent. Cela place immédiatement les points avec une grande différence entre les valeurs métriques en haut de la liste, ce qui est exactement ce que nous voulions éviter.

L'autre façon de faire la mise à l'échelle est basée sur le point de référence. Le point de référence est un point avec les valeurs maximales des deux critères :

(max(CA), max(Complexité))

La fonction de fitness sera la distance entre le point de référence et les points de données :

f(CA,Complexité) = √((CA−CA ) 2 + (Complexité−Complexité) 2 )

Nous pouvons considérer cette fonction de fitness comme un cercle avec le centre au point de référence. Le rayon dans ce cas est la valeur du rang. La solution au problème d'optimisation sera le point où le cercle touche le front de Pareto. La solution au problème d'origine sera des ensembles de points correspondant aux différents rayons de cercle, comme indiqué dans l'image suivante (les parties de cercles pour différents rangs sont représentées par des courbes bleues en pointillés) :

Cette approche traite mieux les valeurs extrêmes, mais il reste encore deux problèmes : Premièrement, j'aimerais avoir plus de points près des points de référence pour mieux surmonter le problème auquel nous sommes confrontés avec la combinaison linéaire. Deuxièmement, la complexité CA et Cyclomatic sont intrinsèquement différentes et ont des valeurs différentes, nous devons donc les normaliser (par exemple, pour que toutes les valeurs des deux mesures soient comprises entre 1 et 100).

Voici une petite astuce que nous pouvons appliquer pour résoudre le premier problème - au lieu de regarder le CA et la complexité cyclomatique, nous pouvons regarder leurs valeurs inversées. Le point de référence dans ce cas sera (0,0). Pour résoudre le deuxième problème, nous pouvons simplement normaliser les métriques en utilisant la valeur minimale. Voici à quoi ça ressemble :

Complexité inversée et normalisée – NormComplexity :

(1 + min(Complexité)) / (1 + Complexité)∗100

CA inversé et normalisé – NormCA :

(1 + min(CA)) / (1+CA)∗100

Remarque : j'ai ajouté 1 pour m'assurer qu'il n'y a pas de division par 0.

L'image suivante montre un tracé avec les valeurs inversées :

Classement final

Nous arrivons maintenant à la dernière étape - le calcul du rang. Comme mentionné, j'utilise la méthode du point de référence, donc la seule chose que nous devons faire est de calculer la longueur du vecteur, de le normaliser et de le faire monter avec l'importance d'une création de test unitaire pour une classe. Voici la formule finale :

Rang(NormComplexity , NormCA) = 100 − √(NormComplexity 2 + NormCA 2 ) / √2

Plus de statistiques

Il y a une autre réflexion que j'aimerais ajouter, mais examinons d'abord quelques statistiques. Voici un histogramme des métriques Coupling :

Ce qui est intéressant dans cette image, c'est le nombre de classes avec un faible CA (0-2). Les classes avec CA 0 ne sont pas utilisées du tout ou sont des services de niveau supérieur. Ceux-ci représentent des points de terminaison d'API, donc c'est bien que nous en ayons beaucoup. Mais les classes avec CA 1 sont celles qui sont directement utilisées par les points de terminaison et nous avons plus de ces classes que de points de terminaison. Qu'est-ce que cela signifie du point de vue de l'architecture / du design ?

En général, cela signifie que nous avons une sorte d'approche orientée script – nous scénarisons chaque analyse de rentabilisation séparément (nous ne pouvons pas vraiment réutiliser le code car les analyses de rentabilisation sont trop diverses). Si tel est le cas, alors c'est définitivement une odeur de code et nous devons faire une refactorisation. Sinon, cela signifie que la cohésion de notre système est faible, auquel cas nous avons également besoin d'un refactoring, mais d'un refactoring architectural cette fois.

Des informations utiles supplémentaires que nous pouvons obtenir à partir de l'histogramme ci-dessus sont que nous pouvons filtrer complètement les classes à faible couplage (CA dans {0,1}) de la liste des classes éligibles à la couverture par les tests unitaires. Les mêmes classes, cependant, sont de bons candidats pour les tests d'intégration / fonctionnels.

Vous pouvez trouver tous les scripts et ressources que j'ai utilisés dans ce référentiel GitHub : ashalitkin/code-base-stats.

Est-ce que ça marche toujours ?

Pas nécessairement. Tout d'abord, il s'agit d'analyse statique, pas d'exécution. Si une classe est liée à de nombreuses autres classes, cela peut être un signe qu'elle est fortement utilisée, mais ce n'est pas toujours vrai. Par exemple, nous ne savons pas si la fonctionnalité est vraiment très utilisée par les utilisateurs finaux. Deuxièmement, si la conception et la qualité du système sont suffisamment bonnes, il est fort probable que différentes parties / couches de celui-ci soient découplées via des interfaces, de sorte que l'analyse statique de l'AC ne nous donnera pas une image fidèle. Je suppose que c'est l'une des principales raisons pour lesquelles CA n'est pas si populaire dans des outils comme Sonar. Heureusement, cela nous convient parfaitement puisque, si vous vous en souvenez, nous souhaitons appliquer cela spécifiquement aux anciennes bases de code laides.

En général, je dirais que l'analyse d'exécution donnerait de bien meilleurs résultats, mais malheureusement, c'est beaucoup plus coûteux, long et complexe, donc notre approche est une alternative potentiellement utile et moins coûteuse.

En relation : Principe de responsabilité unique : une recette pour un excellent code