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:
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?
Voici mon impression non scientifique personnelle: les trois raisons ressemblent à des illusions cognitives répandues mais fausses.
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.
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.
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:
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.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à.
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.
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.
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.
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.
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.