web-dev-qa-db-fra.com

Est-il judicieux d'écrire des tests pour le code hérité lorsqu'il n'y a pas de temps pour une refactorisation complète?

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 @VisibleForTesting public static méthodes et à 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 peut ne pas fonctionner correctement en premier lieu. Et l'écriture de tests pour cela rend les futures corrections et modifications plus difficiles, car les développeurs doivent également comprendre et modifier les tests.
  • Si c'est du code GUI avec une logique (~ 12 lignes, 2-3 bloc if/else, par exemple), un test ne vaut pas la peine car le code est trop trivial pour commencer.
  • De mauvais schémas similaires pourraient également exister dans d'autres parties de la base de code (ce que je n'ai pas encore vu, je suis plutôt nouveau); il sera plus facile de tout nettoyer en un seul gros refactoring. Extraire la logique pourrait compromettre cette possibilité future.

Dois-je éviter d'extraire des parties testables et d'écrire des tests si nous n'avons pas le temps de refactoriser complètement? Y a-t-il un inconvénient que je devrais considérer?

74
is4

Voici mon impression non scientifique personnelle: les trois raisons ressemblent à des illusions cognitives répandues mais fausses.

  1. Bien sûr, le code existant peut être incorrect. Cela pourrait aussi être juste. Étant donné que l'application dans son ensemble semble avoir de la valeur pour vous (sinon vous la rejetteriez simplement), en l'absence d'informations plus spécifiques, vous devez supposer qu'elle est principalement correcte. "Écrire des tests rend les choses plus difficiles car il y a plus de code impliqué dans l'ensemble" est une attitude simpliste et très erronée.
  2. Par tous les moyens, dépensez vos efforts de refactoring, de test et d'amélioration dans les endroits où ils ajoutent le plus de valeur avec le moins d'effort. Les sous-programmes GUI de mise en forme de valeur 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. Presque toutes les erreurs graves sont commises parce que les gens pensaient avoir compris quelque chose de mieux qu'ils ne l'ont fait.
  3. "Nous ferons tout cela d'un seul coup à l'avenir" est une belle pensée. Habituellement, le gros swoop reste fermement à l'avenir, tandis que dans le présent, rien ne se passe. Moi, je suis fermement de la conviction "gagne lentement et régulièrement la course".
100
Kilian Foth

Quelques réflexions:

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

Je suis d'accord que le code GUI pur est difficile à tester et peut-être pas un bon ajustement pour le refactoring " fonctionnant efficacement ...". Cependant, cela ne signifie pas que vous ne devez pas extraire un comportement qui n'a rien à voir dans la couche GUI et tester le code extrait. Et "12 lignes, 2-3 bloc if/else" n'est pas anodin. Tout le code avec au moins un peu de logique conditionnelle doit être testé.

D'après mon expérience, les grands refactorings ne sont pas faciles et ils fonctionnent rarement. Si vous ne vous fixez pas d'objectifs précis et minuscules, il y a un risque élevé que vous vous lanciez dans une reprise sans fin et épilante où vous n'atterrirez jamais sur vos pieds à la fin. Plus le changement est important, plus vous risquez de casser quelque chose et plus vous aurez de difficulté à découvrir où vous avez échoué.

Améliorer progressivement les choses avec des petits refactorings ad hoc ne "sape pas les possibilités futures", il les permet - solidifiant le terrain marécageux où se trouve votre application. Vous devriez certainement le faire.

50
guillaume31

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 l'impact. D'autres codes peuvent s'appuyer sur ce qui semble être un comportement défectueux ou des effets secondaires de l'implémentation actuelle. La couverture des tests de l'application existante devrait faciliter la refactorisation plus tard, car elle vous aidera à savoir quand vous avez accidentellement cassé quelque chose. Vous devez d'abord tester les parties les plus importantes.

17
Rory Hunter

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

Si un développeur veut changer (refactoriser, étendre, déboguer) du code, il doit le comprendre. Elle doit s'assurer que ses changements affectent exactement le comportement qu'elle souhaite (rien en cas de refactoring), et rien d'autre.

S'il y a des tests, elle doit aussi comprendre les tests, bien sûr. En même temps, les tests devraient l'aider à comprendre le code principal, et les tests sont de loin plus faciles à comprendre que le code fonctionnel (à moins que ce ne soient 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.

Cependant, cela nécessite que les tests soient documentés comme testant un comportement préexistant, et non comme une spécification.

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

  • L'antipattern à refacturer doit être facilement trouvé. Tous vos singletons sont-ils nommés XYZSingleton? Leur getter d'instance est-il toujours appelé getInstance()? Et comment trouvez-vous vos hiérarchies trop profondes? Comment recherchez-vous vos objets divins? Ceux-ci nécessitent une analyse des métriques de code, puis une inspection manuelle des métriques. Ou vous tombez simplement dessus pendant que vous travaillez, comme vous l'avez fait.
  • Le refactoring doit être mécanique. Dans la plupart des cas, la partie difficile de la refactorisation consiste à 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 signifie souvent comprendre le callgraph local afin que vous sachiez 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 à avoir besoin de 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? Si le refactoring n'est pas si mécanique qu'il nécessite peu ou pas de connaissance du code environnant, il ne sert à rien de le regrouper.
  • Le refactoring doit être automatisé. Ceci est quelque 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 donne 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 écrit. Si vous voulez faire un gros remaniement, il doit être largement automatisé 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 mauvais code (c'est-à-dire facilement trouvé), et automatiser le changement (c'est-à-dire mécanique).
  • L'amélioration progressive permet une meilleure analyse de rentabilisation. Le refactoring de gros swoop est incroyablement perturbateur. Si vous refactorisez un morceau de code, vous entrez invariablement dans des conflits de fusion avec d'autres personnes qui y travaillent, car vous venez de diviser la méthode qu'ils changeaient en cinq parties. Lorsque vous refactorisez un morceau de code de taille raisonnable, vous obtenez des conflits avec quelques personnes (1-2 lors du fractionnement de la mégafonction de 600 lignes, 2-4 lors de la décomposition de l'objet divin, 5 lors de l'extraction du singleton d'un module ), mais vous auriez quand même eu ces conflits en raison de vos modifications principales. Lorsque vous effectuez une refactorisation à l'échelle du code, vous êtes en conflit avec tout le monde. Sans oublier qu'il lie quelques développeurs pendant des jours. Une amélioration progressive fait que chaque modification de code prend un peu plus de temps. Cela le rend plus prévisible, et il n'y a pas une période de temps aussi visible où rien ne se passe sauf le nettoyage.
14
Sebastian Redl

Il existe une culture dans certaines entreprises où ils sont réticents à autoriser les développeurs à tout moment à améliorer le code qui n'apporte pas directement de valeur supplémentaire, par exemple nouvelle fonctionnalité.

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

Je souscris personnellement au principe du scoutisme mais pas aux autres (comme vous l'avez vu).

Cela dit, les logiciels souffrent d'entropie et accumulent une dette technique. Les développeurs précédents manquant de temps (ou peut-être simplement paresseux ou inexpérimentés) peuvent avoir mis en œuvre des solutions de buggy sous-optimales par rapport à celles bien conçues. Bien qu'il puisse sembler souhaitable de les refactoriser, vous risquez d'introduire de nouveaux bogues dans ce qui est (pour les utilisateurs de toute façon) du code de travail.

Certains changements présentent un risque plus faible que d'autres. Par exemple, là où je travaille, il y a généralement beaucoup de code dupliqué qui peut être transféré en toute sécurité vers un sous-programme avec un impact minimal.

En fin de compte, vous devez vous prononcer sur la mesure dans laquelle vous allez refactoriser, mais il est indéniablement utile d'ajouter des tests automatisés s'ils n'existent pas déjà.

12
Robbie Dee

D'après mon expérience, un test de caractérisation en quelque sorte fonctionne bien. Il vous offre une couverture de test large mais pas très spécifique relativement rapidement, mais peut être difficile à mettre en œuvre pour les applications GUI.

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

Cette approche vous donne une bonne idée si les changements affectent d'autres parties du système et vous permet de faire les changements nécessaires plus tôt.

5
jamesj

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

Les tests ne sont pas écrits dans la pierre. Ils peuvent être modifiés. Et si vous avez testé une fonctionnalité incorrecte, il devrait être facile de réécrire le test plus correctement. Après tout, seul le résultat attendu de la fonction testée aurait dû changer.

3
rem

Hé bien oui. Répondre en tant qu'ingénieur de test logiciel. Tout d'abord, vous devez tester tout ce que vous faites de toute façon. Parce que si vous ne le faites pas, vous ne savez pas si cela fonctionne ou non. Cela peut nous sembler évident, 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 qu'il fonctionne parce que vous l'avez testé.

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

Si je me souviens bien, le livre Refactoring dit de tester constamment de toute façon. Cela fait donc partie du processus.

3
RedSonja

Faites la couverture du test automatisé.

Méfiez-vous des vœux pieux, à la fois par vous-même et par vos clients et patrons. Même si j'aimerais 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 genre de pensée de la même manière que je traite les courriers électroniques frauduleux nigérians. Eh bien, surtout; Je ne suis jamais allé pour un e-mail frauduleux, mais récemment (quand on m'a crié dessus), j'ai renoncé à 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 plus sûr moyen de faire échouer votre superviseur est de suivre chacun de ses ordres sans question. "

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

3
Technophile

Si vous avez affaire à de grandes quantités de code hérité qui n'est pas actuellement testé, obtenir la couverture du test maintenant au lieu d'attendre une hypothétique réécriture à l'avenir est la bonne chose à faire. Commencer par écrire des tests unitaires ne l'est pas.

Sans test automatisé, après avoir apporté des modifications au code, vous devez effectuer un test manuel de bout en bout de l'application pour vous assurer que cela fonctionne. Commencez par écrire des tests d'intégration de haut niveau pour remplacer cela. Si votre application lit des fichiers, les valide, traite les données d'une certaine manière et affiche les résultats que vous souhaitez que les tests capturent tout cela.

Idéalement, vous aurez soit les données d'un plan de test manuel, soit vous pourrez obtenir un échantillon des données de production réelles à utiliser. Sinon, puisque l'application est en production, dans la plupart des cas, elle fait ce qu'elle devrait être, alors composez simplement des données qui atteindront tous les points forts et supposerez que la sortie est correcte pour l'instant. Ce n'est pas pire que de prendre une petite fonction, en supposant qu'il fait ce que son nom ou tout commentaire suggère qu'il devrait faire, et d'écrire 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 écrit suffisamment de ces tests de haut niveau pour capturer le fonctionnement normal des applications et les cas d'erreur les plus courants, le temps que vous devrez passer à frapper le clavier pour essayer de détecter les erreurs du code en faisant autre chose que vous pensiez que cela était censé se faire, ce qui diminuera considérablement, ce qui facilitera beaucoup la refactorisation future (ou même une réécriture importante).

Comme vous pouvez étendre la couverture des tests unitaires, vous pouvez réduire ou même retirer la plupart des tests d'intégration. Si votre application lit/écrit des fichiers ou accède à une base de données, tester ces parties isolément et les simuler ou faire commencer vos tests en créant les structures de données lues à partir du fichier/de la base de données est un point de départ évident. En fait, la création de cette infrastructure de test prendra beaucoup plus de temps que l'écriture 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 gagnez déjà beaucoup.