Est-il judicieux d'écrire des tests pour le code hérité lorsqu'il n'y a pas de temps pour un refactoring complet?


72

J'essaie généralement de suivre les conseils du livre Travailler efficacement avec Legacy Cod e . Je casse les dépendances, déplace des parties du code vers des @VisibleForTesting public staticméthodes et vers de nouvelles classes pour rendre le code (ou au moins une partie de celui-ci) testable. Et j'écris des tests pour m'assurer de ne rien casser lorsque je modifie ou ajoute de nouvelles fonctions.

Un collègue dit que je ne devrais pas faire ça. Son raisonnement:

  • Le code d'origine pourrait ne pas fonctionner correctement en premier lieu. Et écrire des tests pour cela rend plus difficiles les corrections et modifications futures car les développeurs doivent aussi comprendre et modifier les tests.
  • Si c'est du code GUI avec une certaine logique (~ 12 lignes, 2-3 si / else bloquent, par exemple), un test ne vaut pas la peine, car le code est trop trivial pour commencer.
  • Des motifs similaires similaires pourraient également exister dans d'autres parties de la base de code (que je n'ai pas encore vu, je suis plutôt nouveau); il sera plus facile de tout nettoyer en un seul et même refactoring. Extraire la logique pourrait saper cette possibilité future.

Devrais-je éviter d'extraire des pièces testables et d'écrire des tests si nous n'avons pas le temps de refactoriser complètement? Y at-il un inconvénient à cela que je devrais considérer?


29
On dirait que votre collègue ne fait que présenter des excuses parce qu'il ne fonctionne pas de cette façon. Les gens se comportent parfois de la sorte parce qu’ils sont trop tenaces pour changer leur façon de faire.
Doc Brown

3
ce qui devrait être considéré comme un bogue peut être invoqué par d'autres parties du code pour en faire une fonctionnalité
freak freak

1
Le seul argument valable contre ce que je peux penser est que votre refactoring lui-même pourrait introduire de nouveaux bogues si vous avez mal interprété / mal copié quelque chose. Pour cette raison, je suis libre de modifier et de modifier à tout moment la version en cours de développement. Toutefois, les corrections apportées aux versions antérieures se heurtent à un obstacle beaucoup plus important et risquent de ne pas être approuvées si elles ne constituent qu'un "nettoyage" esthétique / structurel, le risque est réputé dépasser le gain potentiel. Connaissez votre culture locale - pas seulement l'idée d'un vache-orker - et prévoyez des raisons EXTREMEMENT fortes avant de faire autre chose.
Keshlam

6
Le premier point est un peu hilarant: «Ne le testez pas, cela pourrait être un buggy.» Eh bien, oui? Ensuite, il est bon de savoir que nous voulons résoudre ce problème ou nous ne voulons pas que quiconque modifie le comportement réel conformément à certaines spécifications de conception. Dans les deux cas, les tests (et l'exécution des tests dans un système automatisé) sont bénéfiques.
Christopher Creutzig

3
Trop souvent, le "grand refactoring" qui est sur le point d'arriver et qui guérira tous les maux est un mythe, inventé par ceux qui veulent simplement pousser des choses qu'ils considèrent ennuyeuses (écrire des tests) dans un avenir lointain. Et si cela se concrétise, ils regretteront sérieusement de l'avoir laissé devenir si gros!
Julia Hayward

Réponses:


100

Voici mon impression personnelle non scientifique: les trois raisons semblent être des illusions cognitives répandues mais fausses.

  1. Bien sûr, le code existant peut être faux. Cela pourrait aussi être juste. Étant donné que l'application dans son ensemble semble avoir de la valeur pour vous (sinon vous vous en débarrasseriez tout simplement), en l'absence d'informations plus spécifiques, vous devez supposer qu'elle est essentiellement correcte. "Ecrire des tests rend les choses plus difficiles parce qu'il y a plus de code impliqué" est une attitude simpliste et très fausse.
  2. Dépensez certainement vos efforts de refactorisation, de test et d’amélioration dans les endroits où ils ajoutent le plus de valeur avec le moins d’effort. Les sous-routines d'interface graphique de formatage de valeurs ne sont souvent pas la première priorité. Mais ne pas tester quelque chose parce que "c'est simple" est aussi une très mauvaise attitude. Pratiquement toutes les erreurs graves sont commises parce que les gens pensaient comprendre quelque chose de mieux qu’ils ne l’avaient réellement compris.
  3. "Nous ferons tout cela d'un seul coup dans le futur" est une bonne idée. Habituellement, le grand swoop reste fermement dans le futur, alors que rien ne se passe dans le présent. Moi, je suis fermement convaincu de la conviction "lent et régulier gagne la course".

23
+1 pour "Pratiquement toutes les erreurs graves sont commises parce que les gens pensaient comprendre quelque chose de mieux qu'ils ne le faisaient réellement."
rem

Re point 1 - avec BDD , les tests sont auto-documentés ...
Robbie Dee

2
Comme @ guillaume31 l'a fait remarquer, une partie de l'intérêt d'écrire des tests est de montrer comment le code fonctionne réellement - ce qui peut ou non être conforme aux spécifications. Mais il se peut que la spécification soit "fausse": les besoins de l'entreprise peuvent avoir changé et le code reflète les nouvelles exigences, mais la spécification ne le fait pas. Assumer simplement que le code est "faux" est trop simpliste (voir le point 1). Et encore une fois, les tests vous diront ce que le code fait réellement, pas ce que quelqu'un pense / dit qu'il fait (voir point 2).
David

même si vous faites un swoop, vous devez comprendre le code. Les tests vous aideront à détecter les comportements inattendus, même si vous ne refacturez pas mais réécrivez (et si vous les refacturez, ils vous aideront à vous assurer que votre refactoring ne casse pas le comportement hérité - ou uniquement là où vous souhaitez le faire). N'hésitez pas à incorporer ou non - comme vous le souhaitez.
Frank Hopkins

50

Quelques réflexions:

Lorsque vous refactorisez du code hérité, peu importe si certains des tests que vous écrivez contrediront les spécifications idéales. Ce qui compte, c'est qu'ils testent le comportement actuel du programme . Le refactoring consiste à prendre de petites étapes iso-fonctionnelles pour rendre le code plus propre; vous ne voulez pas vous engager dans la correction de bugs pendant votre refactoring. De plus, si vous repérez un bogue flagrant, il ne sera pas perdu. Vous pouvez toujours écrire un test de régression et le désactiver temporairement, ou insérer une tâche de correction de bug dans votre backlog pour plus tard. Une chose à la fois.

Je conviens que le code d'une interface graphique pure est difficile à tester et qu'il ne convient peut-être pas au refactoring de style " Travailler efficacement ... ". Toutefois, cela ne signifie pas que vous ne devez pas extraire un comportement qui n'a rien à voir avec la couche d'interface graphique et tester le code extrait. Et "12 lignes, 2-3 if / else block" n'est pas anodin. Tout code avec au moins un peu de logique conditionnelle doit être testé.

D'après mon expérience, les grandes refactorisations ne sont pas faciles et fonctionnent rarement. Si vous ne vous fixez pas d'objectifs précis et infimes, vous courez un risque élevé de retravailler sans cesse et de tirer les cheveux en poils, sans jamais tomber sur vos pieds. Plus le changement est important, plus vous risquez de casser quelque chose et plus vous aurez de difficulté à trouver où vous avez échoué.

Améliorer progressivement les choses avec de petites refactorisations ad hoc ne «mine pas les possibilités futures», mais leur permet de consolider le terrain marécageux où se situe votre application. Vous devriez certainement le faire.


5
+1 pour "les tests que vous écrivez testent le comportement actuel du programme "
David

17

Voir aussi: "Le code d'origine pourrait ne pas fonctionner correctement" - cela ne signifie pas que vous modifiez simplement le comportement du code sans vous soucier de son impact. D'autres codes peuvent s'appuyer sur ce qui semble être un comportement défectueux ou sur les effets secondaires de la mise en œuvre actuelle. La couverture de test de l’application existante devrait faciliter la refactorisation ultérieure, car elle vous aidera à savoir quand vous avez accidentellement cassé quelque chose. Vous devriez d'abord tester les parties les plus importantes.


Malheureusement vrai. Nous avons quelques problèmes évidents qui se manifestent dans les cas critiques et ne peuvent pas être résolus car notre client préfère la cohérence à la correction. (Ils sont dus au code de collecte de données autorisant
certaines

14

La réponse de Kilian couvre les aspects les plus importants, mais je voudrais développer les points 1 et 3.

Si un développeur souhaite modifier le code (refactorisation, extension, débogage), il doit le comprendre. Elle doit s'assurer que ses changements affectent exactement le comportement qu'elle souhaite (rien dans le cas du refactoring), et rien d'autre.

S'il y a des tests, alors elle doit aussi les comprendre, bien sûr. Dans le même temps, les tests devraient l'aider à comprendre le code principal. De toute façon, les tests sont beaucoup plus faciles à comprendre que le code fonctionnel (à moins qu'il s'agisse de mauvais tests). Et les tests aident à montrer ce qui a changé dans le comportement de l'ancien code. Même si le code d'origine est incorrect et que le test teste ce comportement incorrect, c'est toujours un avantage.

Toutefois, cela nécessite que les tests soient documentés en tant que tests de comportement préexistant, et non de spécification.

Quelques réflexions également sur le point 3: outre le fait que le "grand coup" se produit rarement, il y a aussi une autre chose: ce n'est pas vraiment plus facile. Pour être plus facile, plusieurs conditions devraient s'appliquer:

  • L'anti-modèle à refactoriser doit être facile à trouver. Tous vos singletons sont-ils nommés XYZSingleton? Est-ce que leur instance getter est toujours appelée getInstance()? Et comment trouvez-vous vos hiérarchies trop profondes? Comment recherchez-vous vos objets divins? Celles-ci nécessitent une analyse des métriques du code, puis une inspection manuelle des métriques. Ou vous tombez sur eux pendant que vous travaillez, comme vous le faisiez.
  • Le refactoring doit être mécanique. Dans la plupart des cas, la refactorisation nécessite de bien comprendre le code existant pour savoir comment le modifier. Singletons encore: si le singleton est parti, comment obtenez-vous les informations requises à ses utilisateurs? Cela implique souvent de comprendre le graphe des appels local afin de savoir où obtenir les informations. Maintenant, qu'est-ce qui est plus facile: rechercher les dix singletons dans votre application, comprendre les utilisations de chacun (ce qui conduit à devoir comprendre 60% de la base de code) et les extraire? Ou prendre le code que vous comprenez déjà (parce que vous y travaillez actuellement) et déchirer les singletons utilisés là-bas? Si la refactorisation n'est pas tellement mécanique qu'elle nécessite peu ou pas de connaissance du code environnant, il n'est pas utile de la regrouper.
  • Le refactoring doit être automatisé. Ceci est un peu basé sur l'opinion, mais voilà. Un peu de refactoring est amusant et satisfaisant. Beaucoup de refactoring est fastidieux et ennuyeux. Laisser le morceau de code sur lequel vous venez de travailler dans un meilleur état vous procure une sensation agréable et chaleureuse avant de passer à des choses plus intéressantes. Essayer de refactoriser une base de code entière vous laissera frustré et en colère contre les programmeurs idiots qui l’ont écrite. Si vous souhaitez effectuer une refactorisation à grande échelle, elle doit être largement automatisée afin de minimiser la frustration. C'est en quelque sorte un mélange des deux premiers points: vous ne pouvez automatiser le refactoring que si vous pouvez automatiser la recherche du code défectueux (c'est-à-dire facile à trouver) et automatiser sa modification (c'est-à-dire mécanique).
  • L’amélioration progressive conduit à une meilleure analyse de rentabilisation. La refactorisation à grande échelle est incroyablement perturbatrice. Si vous refactorisez un morceau de code, vous entrez invariablement dans des conflits de fusion avec d'autres personnes qui y travaillent, car vous divisez simplement la méthode qu'ils modifiaient en cinq parties. Lorsque vous refactorisez un morceau de code de taille raisonnable, vous obtenez des conflits avec quelques personnes (1 à 2 lors de la division de la mégafonction de 600 lignes, 2 à 4 lors de la suppression de l'objet divin, 5 lors de l'extraction du singleton d'un module ), mais vous auriez eu ces conflits de toute façon à cause de vos modifications principales. Lorsque vous effectuez un refactoring à l’échelle de la base de code, vous entrez en conflit avec tout le monde.. Sans oublier que cela lie quelques développeurs pendant des jours. L'amélioration progressive rend chaque modification de code un peu plus longue. Cela le rend plus prévisible, et il n'y a pas une telle période de temps visible où rien ne se passe sauf le nettoyage.

12

Dans certaines entreprises, certaines sociétés sont réticentes à permettre aux développeurs d’optimiser à tout moment un code qui n’apporte pas directement de valeur ajoutée, par exemple de nouvelles fonctionnalités.

Je prêche probablement aux convertis ici, mais c'est clairement une fausse économie. Un code propre et concis profite aux développeurs suivants. C'est simplement que le retour sur investissement n'est pas immédiatement évident.

Je souscris personnellement au principe du scoutisme, mais d'autres (comme vous l'avez vu) ne le font pas.

Cela dit, les logiciels souffrent d'entropie et créent une dette technique. Les développeurs précédents manquant de temps (ou peut-être simplement paresseux ou inexpérimenté) ont peut-être implémenté des solutions de buggy non optimales par rapport à des solutions bien conçues. Bien que cela puisse sembler souhaitable de les refactoriser, vous risquez d’introduire de nouveaux bogues dans le code qui fonctionne (pour les utilisateurs de toute façon).

Certains changements sont moins risqués que d'autres. Par exemple, là où je travaille, il y a souvent beaucoup de code dupliqué qui peut être transféré en toute sécurité à un sous-programme avec un impact minimal.

En fin de compte, vous devez vous prononcer sur le degré d'avancement de la refactorisation, mais l'ajout de tests automatisés, s'ils n'existent pas déjà, présente un intérêt indéniable.


2
Je suis tout à fait d'accord sur le principe, mais dans de nombreuses entreprises, cela prend du temps et de l'argent. Si la partie "rangement" ne prend que quelques minutes, alors c'est très bien, mais une fois que l'estimation du rangement commence à grossir (pour une définition du mot "grande"), vous, la personne en charge du code doit déléguer cette décision à votre Gestionnaire de projet. Ce n'est pas à vous de décider de la valeur de ce temps passé. Travailler sur le correctif X, ou la nouvelle fonctionnalité Y pourrait avoir une valeur beaucoup plus élevée pour le projet / la société / le client.
ozz

2
Vous pouvez également ne pas être au courant de problèmes plus importants tels que le projet mis au rebut dans 6 mois ou tout simplement que la société valorise davantage votre temps (par exemple, vous faites quelque chose qu’ils jugent plus important, et l’autre personne peut faire le travail de refacturation). Le travail de refactoring peut également affecter les tests. Une refactorisation volumineuse déclenchera-t-elle une régression complète du test? La société dispose-t-elle de ressources pour déployer ces ressources?
ozz

Oui, comme vous l'avez dit, il existe une multitude de raisons pour lesquelles une intervention chirurgicale majeure peut ou peut ne pas être une bonne idée: autres priorités de développement, durée de vie du logiciel, ressource de test, expérience de développeur, couplage, cycle de publication, familiarité avec le code base, documentation, criticité de mission, culture d'entreprise, etc. etc. etc. C'est un jugement
Robbie Dee

4

D'après mon expérience, un test de caractérisation fonctionne bien. Il vous donne une couverture de test étendue, mais pas très spécifique, assez rapidement, mais peut être difficile à implémenter pour les applications à interface graphique.

J'écrirais ensuite des tests unitaires pour les pièces que vous souhaitez modifier, et ce, chaque fois que vous souhaitez effectuer un changement, augmentant ainsi la couverture de vos tests unitaires au fil du temps.

Cette approche vous donne une bonne idée si les modifications affectent d’autres parties du système et vous permettent d’apporter les modifications nécessaires plus tôt.


3

Re: "Le code d'origine pourrait ne pas fonctionner correctement":

Les tests ne sont pas gravés dans le marbre. Ils peuvent être changés. Et si vous avez testé une fonctionnalité incorrecte, il devrait être facile de réécrire le test plus correctement. Seul le résultat attendu de la fonction testée devrait avoir changé, après tout.


1
OMI, les tests individuels doivent être gravés dans la pierre, au moins jusqu’à ce que la fonctionnalité qu’ils testent soit morte et disparue. Ce sont eux qui vérifient le comportement du système existant et aident à assurer aux responsables que leurs modifications ne casseront pas le code hérité pouvant déjà reposer sur ce comportement. Modifier les tests pour une fonctionnalité en direct, et vous supprimez ces assurances.
cHao

3

Hé bien oui. Répondre en tant qu'ingénieur de test logiciel. Tout d’abord, vous devriez quand même tester tout ce que vous faites. Parce que si vous ne le faites pas, vous ne savez pas si cela fonctionne ou non. Cela peut sembler évident pour nous, mais j'ai des collègues qui voient les choses différemment. Même si votre projet est un petit projet qui ne sera peut-être jamais livré, vous devez regarder l'utilisateur en face et dire que vous savez que cela fonctionne parce que vous l'avez testé.

Le code non trivial contient toujours des bugs (citant un gars de l'université; et s'il n'y a pas de bugs, c'est trivial) et notre travail consiste à les rechercher avant le client. Le code hérité a des bogues hérités. Si le code d'origine ne fonctionne pas comme il se doit, vous voulez le savoir, croyez-moi. Les bugs sont acceptables si vous les connaissez, n’ayez pas peur de les trouver, c’est à cela que servent les notes de publication.

Si je me souviens bien, le livre sur le refactoring dit de tester constamment de toute façon. C'est donc une partie du processus.


3

Faites la couverture de test automatisé.

Méfiez-vous des rêves illusoires, de vos propres clients, de ceux de vos clients et de vos supérieurs hiérarchiques. Même si j'aimerais beaucoup croire que mes modifications seront correctes la première fois et que je n'aurai à tester qu'une seule fois, j'ai appris à traiter ce type de réflexion de la même manière que je traite les courriels frauduleux nigérians. Eh bien, surtout; Je ne suis jamais allé pour un courriel frauduleux, mais récemment (quand crier dessus), j'ai cédé pour ne pas utiliser les meilleures pratiques. Ce fut une expérience douloureuse qui a traîné (cher) encore et encore. Plus jamais!

J'ai une citation préférée de la bande dessinée Web Freefall: "Avez-vous déjà travaillé dans un domaine complexe où le superviseur n'a qu'une idée approximative des détails techniques? ... Alors vous savez que le moyen le plus sûr de faire échouer son superviseur est de suivez chacun de ses ordres sans poser de questions. "

Il est probablement approprié de limiter le temps que vous investissez.


1

Si vous avez affaire à de grandes quantités de code hérité qui ne sont pas actuellement testés, obtenir la couverture de test maintenant plutôt que d'attendre une grande réécriture hypothétique à l'avenir est la bonne solution. Commencer par écrire des tests unitaires n'est pas.

Sans test automatisé, après avoir apporté des modifications au code, vous devez effectuer des tests manuels complets de l'application pour vous assurer de son bon fonctionnement. Commencez par écrire des tests d'intégration de haut niveau pour remplacer cela. Si votre application lit les fichiers, les valide, les traite d'une manière ou une autre et affiche les résultats souhaités pour les tests.

Idéalement, vous disposez de données issues d'un plan de test manuel ou vous pouvez obtenir un échantillon des données de production réelles à utiliser. Si ce n'est pas le cas, puisque l'application est en production, dans la plupart des cas, elle fait ce qu'elle devrait être, alors créez simplement des données qui atteindront tous les points forts et supposerez que la sortie est correcte pour le moment. Ce n'est pas pire que de prendre une petite fonction, en supposant qu'elle porte le nom ou les commentaires suggérant qu'elle le devrait, et en écrivant des tests en supposant que cela fonctionne correctement.

IntegrationTestCase1()
{
    var input = ReadDataFile("path\to\test\data\case1in.ext");
    bool validInput = ValidateData(input);
    Assert.IsTrue(validInput);

    var processedData = ProcessData(input);
    Assert.AreEqual(0, processedData.Errors.Count);

    bool writeError = WriteFile(processedData, "temp\file.ext");
    Assert.IsFalse(writeError);

    bool filesAreEqual = CompareFiles("temp\file.ext", "path\to\test\data\case1out.ext");
    Assert.IsTrue(filesAreEqual);
}

Une fois que vous avez suffisamment écrit de ces tests de haut niveau pour capturer le fonctionnement normal des applications et les cas d'erreur les plus courants, vous aurez besoin de passer beaucoup de temps à cogner sur le clavier pour essayer de récupérer les erreurs du code en effectuant autre chose. vous pensiez que cela était supposé faire va considérablement réduire la future refactorisation (ou même une grosse réécriture) beaucoup plus facile.

Comme vous pouvez étendre la couverture des tests unitaires, vous pouvez réduire, voire annuler, la plupart des tests d'intégration. Si votre application lit / écrit des fichiers ou accède à une base de données, il est évident de commencer par tester ces parties séparément, puis de les imiter ou de faire en sorte que vos tests commencent par créer les structures de données lues à partir du fichier / de la base de données. En réalité, la création de cette infrastructure de test prendra beaucoup plus de temps que la rédaction d'un ensemble de tests rapides et sales; et chaque fois que vous exécutez un ensemble de tests d'intégration de 2 minutes au lieu de passer 30 minutes à tester manuellement une fraction de ce que les tests d'intégration couvraient, vous gagniez déjà beaucoup.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.