Pourquoi écrire des tests de code que je vais refactoriser?

15

Je refactorise une énorme classe de code héritée. Refactoring (je présume) préconise ceci:

  1. écrire des tests pour la classe héritée
  2. refactoriser le diable hors de la classe

Problème: une fois que j'ai refactorisé la classe, mes tests de l'étape 1 devront être modifiés. Par exemple, ce qui était autrefois dans une méthode héritée, peut désormais être une classe distincte à la place. Ce qui était une méthode peut être maintenant plusieurs méthodes. Le paysage entier de la classe héritée peut être effacé en quelque chose de nouveau, et donc les tests que j'écris à l'étape 1 seront presque nuls et non avenus. Essentiellement, j'ajouterai l' étape 3. réécris abondamment mes tests

A quoi bon alors écrire des tests avant refactoriser? Cela ressemble plus à un exercice académique de création de plus de travail pour moi. J'écris des tests pour la méthode maintenant et j'apprends plus sur comment tester les choses et comment la méthode héritée fonctionne. On peut l'apprendre en lisant simplement le code hérité lui-même, mais écrire des tests revient presque à me frotter le nez et à documenter cette connaissance temporaire dans des tests séparés. Donc, de cette façon, je n'ai presque pas d'autre choix que d'apprendre ce que fait le code. J'ai dit temporaire ici, car je vais refactoriser le diable du code et toute ma documentation et mes tests seront nuls pour une grande partie, sauf que mes connaissances resteront et me permettront d'être plus frais sur le refactoring.

Est-ce la vraie raison d'écrire des tests avant refactor - pour m'aider à mieux comprendre le code? Il doit y avoir une autre raison!

S'il vous plaît, expliquez!

Remarque:

Il y a ce post: 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? mais il dit "écrire des tests avant refactoriser", mais ne dit pas "pourquoi", ni quoi faire si "écrire des tests" semble "un travail occupé qui sera bientôt détruit"

Dennis
la source
1
Votre prémisse est incorrecte. Vous ne changerez pas vos tests. Vous écrirez de nouveaux tests. L'étape 3 consistera à "supprimer tous les tests qui sont désormais disparus".
pdr
1
L'étape 3 peut alors lire "Ecrire de nouveaux tests. Supprimer les tests disparus". Je pense que cela revient toujours à détruire l'œuvre originale
Dennis
3
Non, vous voulez écrire les nouveaux tests lors de l'étape 2. Et oui, l'étape 1 est détruite. Mais était-ce une perte de temps? Non, car cela vous rassure sur le fait que vous ne cassez rien pendant l'étape 2. Vos nouveaux tests ne le font pas.
pdr
3
@Dennis - bien que je partage beaucoup des mêmes préoccupations que vous avez concernant les situations, nous pourrions considérer la plupart des efforts de refactoring comme "détruisant le travail original" mais si nous ne le détruisions jamais, nous ne nous éloignerions jamais du code spaghetti avec 10k lignes en une fichier. Il en va probablement de même pour les tests unitaires, ils vont de pair avec le code qu'ils testent. Au fur et à mesure que le code évolue et que les choses sont déplacées et / ou supprimées, les tests unitaires devraient évoluer avec lui.
DXM
"Comprendre le code" n'est pas un petit avantage. Comment comptez-vous refactoriser un programme que vous ne comprenez pas? C'est inévitable, et quelle meilleure façon de démontrer une véritable compréhension d'un programme que d'écrire un test approfondi. Il convient également de noter que plus les tests sont abstraits, moins il est probable que vous deviez les rayer plus tard, donc, si quoi que ce soit, respectez d'abord les tests de haut niveau.
Neil

Réponses:

46

La refactorisation consiste à nettoyer un morceau de code (par exemple, améliorer le style, la conception ou les algorithmes), sans modifier le comportement (visible de l'extérieur). Vous écrivez des tests pour ne pas vous assurer que le code avant et après refactoring est le même, mais vous écrivez des tests comme indicateur que votre application avant et après refactoring se comporte de la même manière: le nouveau code est compatible et aucun nouveau bogue n'a été introduit.

Votre principale préoccupation devrait être d'écrire des tests unitaires pour l'interface publique de votre logiciel. Cette interface ne devrait pas changer, donc les tests (qui sont une vérification automatisée de cette interface) ne devraient pas non plus changer.

Cependant, les tests sont également utiles pour localiser les erreurs, il peut donc être judicieux d'écrire des tests pour les parties privées de votre logiciel. Ces tests devraient changer tout au long du refactoring. Si vous souhaitez modifier un détail d'implémentation (comme le nom d'une fonction privée), vous mettez d'abord à jour les tests pour refléter vos attentes modifiées, puis assurez-vous que le test échoue (vos attentes ne sont pas satisfaites), puis vous changez le code réel et vérifiez que tous les tests réussissent à nouveau. À aucun moment, les tests de l'interface publique ne doivent échouer.

Cela est plus difficile à réaliser lors de modifications à plus grande échelle, par exemple en repensant plusieurs pièces codépendantes. Mais il y aura une sorte de limite, et à cette limite, vous pourrez écrire des tests.

amon
la source
6
+1. Lisez mon esprit, a écrit ma réponse. Point important: vous devrez peut-être écrire des tests unitaires pour montrer que les mêmes bugs sont toujours là après refactoring!
david.pfx
Question: pourquoi dans votre exemple de changement de nom de fonction, modifiez-vous d'abord le test pour vous assurer qu'il échoue? Je veux dire que bien sûr, il échouera lorsque vous le modifierez - vous avez rompu la connexion que les éditeurs de liens utilisent pour lier le code ensemble! Vous attendez-vous peut-être à ce qu'il y ait une autre fonction privée existante du nom que vous venez de choisir et vous devez vérifier que ce n'est pas le cas au cas où vous l'auriez manqué? Je vois que cela vous donnera une certaine assurance à la limite du TOC, mais dans ce cas, cela ressemble à une surpuissance. Y a-t-il jamais une raison possible pour laquelle le test de votre exemple n'échouera pas?
Dennis
^ suite: en tant que technique générale, je vois qu'il est bon de faire un test de vérification pas à pas de votre code pour détecter les problèmes qui se produisent le plus tôt possible. Un peu comme si vous ne tombiez pas malade si vous ne vous lavez pas les mains à chaque fois, mais simplement vous laver les mains comme une habitude vous gardera globalement en meilleure santé, que vous soyez en contact avec des choses contaminées ou non. Ici, vous pouvez parfois vous laver les mains de manière superflue ou tester le code de manière superflue, mais cela vous aide à garder votre code et vous en bonne santé. Est-ce là votre point de vue?
Dennis
@Dennis en fait, je décrivais inconsciemment une expérience scientifiquement correcte: nous ne pouvons pas dire quel paramètre a réellement affecté le résultat lors du changement d'un paramètre. N'oubliez pas que les tests sont du code et que chaque code contient des bogues. Allez-vous aller à l'enfer du programmeur pour ne pas avoir exécuté les tests avant de toucher au code? Certainement pas: bien que l'exécution des tests soit idéale, c'est votre jugement professionnel de savoir si cela est nécessaire. Notez en outre qu'un test a échoué s'il ne se compile pas, et que ma réponse est également applicable aux langues dynamiques, pas seulement aux langues statiques avec un éditeur de liens.
amon
2
De la correction de diverses erreurs lors de la refactorisation, je me rends compte que je n'aurais pas fait les mouvements de code aussi facilement sans avoir de tests. Les tests m'informent des "différences" comportementales / fonctionnelles que j'introduis en changeant mon code.
Dennis
7

Ah, maintenir les anciens systèmes.

Idéalement, vos tests traitent la classe uniquement via son interface avec le reste de la base de code, d'autres systèmes et / ou l'interface utilisateur. Interfaces. Vous ne pouvez pas refactoriser l'interface sans affecter ces composants en amont ou en aval. Si tout cela est un désordre étroitement couplé, vous pourriez aussi bien considérer l'effort comme une réécriture plutôt que une refactorisation, mais c'est en grande partie de la sémantique.

Edit: Disons qu'une partie de votre code mesure quelque chose et qu'il a une fonction qui renvoie simplement une valeur. La seule interface appelle la fonction / méthode / ainsi de suite et reçoit la valeur retournée. Il s'agit d'un couplage lâche et d'un test facile à l'unité. Si votre programme principal a un sous-composant qui gère un tampon, et tous les appels à celui-ci dépendent du tampon lui-même, de certaines variables de contrôle, et il repousse les messages d'erreur via une autre section de code, alors vous pourriez dire que c'est étroitement couplé et c'est test difficile à réaliser. Vous pouvez toujours le faire avec des quantités suffisantes d'objets fictifs et ainsi de suite, mais cela devient désordonné. Surtout en c. Toute quantité de refactorisation du fonctionnement du tampon cassera le sous-composant.
Fin de la modification

Si vous testez votre classe via des interfaces qui restent stables, vos tests doivent être valides avant et après le refactoring. Cela vous permet d'apporter des modifications en toute confiance que vous ne l'avez pas cassé. Au moins, plus de confiance.

Il vous permet également d'effectuer des modifications incrémentielles. Si c'est un gros projet, je ne pense pas que vous allez vouloir tout démolir, construire un tout nouveau système puis commencer à développer des tests. Vous pouvez en modifier une partie, la tester et vous assurer que cette modification n'entraîne pas le reste du système. Ou si c'est le cas, vous pouvez au moins voir le désordre géant emmêlé se développer plutôt que d'être surpris par lui lorsque vous relâchez.

Bien que vous puissiez diviser une méthode en trois, ils feront toujours la même chose que la méthode précédente, vous pouvez donc passer le test de l'ancienne méthode et la diviser en trois. L'effort d'écriture du premier test n'est pas perdu.

De plus, traiter la connaissance du système hérité comme une «connaissance temporaire» ne se fera pas bien. Savoir comment cela fonctionnait auparavant est essentiel pour les systèmes hérités. Vraiment utile pour la question séculaire de "pourquoi diable fait-il cela?"

Philippe
la source
Je pense que je comprends, mais tu m'as perdu sur les interfaces. c'est-à-dire que les tests que j'écris maintenant vérifient si certaines variables ont été correctement remplies, après avoir appelé method-under-test. Si ces variables sont modifiées ou refactorisées, mes tests le seront également. La classe héritée existante avec laquelle je travaille n'a pas d'interfaces / getters / setters en soi, ce qui apporterait des changements variables ou moins intensifs en travail. Mais encore une fois, je ne suis pas certain de ce que vous entendez par interfaces en ce qui concerne le code hérité. Peut-être que je peux en créer? Mais ce sera refactoring.
Dennis
1
Oui, si vous avez une classe divine qui fait tout, alors il n'y a vraiment aucune interface. Mais si elle appelle une autre classe, la classe supérieure s'attend à ce qu'elle se comporte d'une certaine manière, et les tests unitaires peuvent vérifier qu'elle le fait. Pourtant, je ne dirais pas que vous n'aurez pas à mettre à jour vos tests unitaires lors de la refactorisation.
Philip
4

Ma propre réponse / réalisation:

De la correction de diverses erreurs lors de la refactorisation, je me rends compte que je n'aurais pas fait les mouvements de code aussi facilement sans avoir de tests. Les tests m'informent des "différences" comportementales / fonctionnelles que j'introduis en changeant mon code.

Vous n'avez pas besoin d'être hyper conscient lorsque vous avez de bons tests en place. Vous pouvez modifier votre code dans un comportement plus détendu. Les tests font les vérifications et les vérifications d'intégrité pour vous.

De plus, mes tests sont restés à peu près les mêmes que ceux que j'ai refactorisés et n'ont pas été détruits. J'ai en fait remarqué des opportunités supplémentaires pour ajouter des assertions à mes tests en approfondissant le code.

MISE À JOUR

Eh bien, maintenant je change beaucoup mes tests: / Parce que j'ai refactorisé la fonction d'origine (supprimé la fonction et créé une nouvelle classe plus propre à la place, en déplaçant le fluff qui était à l'intérieur de la fonction en dehors de la nouvelle classe), alors maintenant le code en cours de test que j'ai exécuté auparavant prend différents paramètres sous un nom de classe différent et produit des résultats différents (le code original avec le fluff avait plus de résultats à tester). Et donc mes tests doivent refléter ces changements et, fondamentalement, je réécris mes tests en quelque chose de nouveau.

Je suppose qu'il y a d'autres solutions que je peux faire pour éviter de réécrire les tests. c'est-à-dire conserver l'ancien nom de la fonction avec le nouveau code et le contenu à l'intérieur ... mais je ne sais pas si c'est la meilleure idée et je n'ai pas encore beaucoup d'expérience pour faire un jugement sur ce qu'il faut faire.

Dennis
la source
Cela ressemble plus à une refonte de l'application avec refactorisation.
JeffO
Quand est-il refactorisé et quand est-il repensé? c'est-à-dire lors de la refactorisation, il est difficile de ne pas diviser les classes plus lourdes en classes plus petites et de les déplacer également. Alors oui, je ne suis pas exactement sûr de la distinction, mais je fais peut-être les deux.
Dennis
3

Utilisez vos tests pour piloter votre code pendant que vous le faites. Dans le code hérité, cela signifie écrire des tests pour le code que vous allez modifier. De cette façon, ils ne sont pas un artefact séparé. Les tests doivent porter sur ce que le code doit réaliser et non sur les entrailles de la façon dont il le fait.

Généralement, vous voulez ajouter des tests sur du code qui n'en a aucun) pour le code que vous allez refactoriser pour vous assurer que le comportement des codes continue de fonctionner comme prévu. Ainsi, l'exécution continue de la suite de tests tout en refactorisant est un filet de sécurité fantastique. L'idée de changer le code sans suite de tests pour confirmer que les changements n'affectent pas quelque chose d'imprévu est effrayante.

Pour ce qui est du détail de la mise à jour d'anciens tests, de l'écriture de nouveaux tests, de la suppression d'anciens tests, etc.

Michael Durrant
la source
Votre premier paragraphe semble préconiser d'ignorer l'étape 1 et d'écrire des tests au fur et à mesure; votre deuxième paragraphe semble contredire cela.
pdr
Mis à jour ma réponse.
Michael Durrant
2

Quel est l'objectif de refactoring dans votre cas spécifique?

Supposons, pour les besoins de ma réponse, que nous croyons tous (dans une certaine mesure) au TDD (Test-Driven Development).

Si le but de votre refactoring est de nettoyer le code existant sans changer le comportement existant, alors l'écriture de tests avant le refactoring est la façon dont vous vous assurez que vous n'avez pas changé le comportement du code, si vous réussissez, alors les tests réussiront avant et après vous refactorisez.

  • Les tests vous aideront à vous assurer que votre nouveau travail fonctionne réellement.

  • Les tests découvriront probablement également des cas où l'œuvre originale ne fonctionne pas .

Mais comment faites-vous vraiment une refactorisation importante sans affecter le comportement dans une certaine mesure?

Voici une courte liste de quelques choses qui pourraient se produire lors de la refactorisation:

  • renommer la variable
  • renommer la fonction
  • ajouter une fonction
  • supprimer la fonction
  • diviser la fonction en deux fonctions ou plus
  • combiner deux ou plusieurs fonctions en une seule fonction
  • classe séparée
  • combiner des cours
  • renommer la classe

Je vais faire valoir que chacune de ces activités énumérées change le comportement d'une manière ou d'une autre.

Et je vais faire valoir que si votre refactoring change de comportement, vos tests vont toujours être la façon dont vous vous assurez que vous n'avez rien cassé.

Peut-être que le comportement ne change pas au niveau macro, mais le point de test unitaire n'est pas d'assurer le comportement macro. Ce sont des tests d' intégration . Le but des tests unitaires est de s'assurer que les morceaux individuels à partir desquels vous construisez votre produit ne sont pas cassés. Chaîne, maillon le plus faible, etc.

Que diriez-vous de ce scénario:

  • Supposons que vous ayez function bar()

  • function foo() fait un appel à bar()

  • function flee() effectue également un appel à la fonction bar()

  • Juste pour la variété, flam()fait un appel àfoo()

  • Tout fonctionne à merveille (apparemment, au moins).

  • Vous refactorisez ...

  • bar() est renommé en barista()

  • flee() est changé pour appeler barista()

  • foo()n'est pas changé pour appelerbarista()

De toute évidence, vos tests pour les deux foo()et flam()maintenant échouent.

Peut - être que vous ne réalisais pas foo()appelé bar()en premier lieu. Vous ne saviez certainement pas que cela flam()dépendait bar()de foo().

Peu importe. Le fait est que vos tests permettront de découvrir le comportement nouvellement rompu des deux foo()et flam(), de manière incrémentielle lors de votre travail de refactoring.

Les tests finissent par vous aider à bien refactoriser.

Sauf si vous n'avez aucun test.

C'est un peu un exemple artificiel. Il y a ceux qui diraient que si changer les bar()pauses foo(), alors foo()c'était trop complexe au départ et devrait être décomposé. Mais les procédures peuvent appeler d'autres procédures pour une raison et il est impossible d'éliminer toute complexité, non? Notre travail consiste à gérer raisonnablement bien la complexité.

Prenons un autre scénario.

Vous construisez un bâtiment.

Vous construisez un échafaudage pour vous assurer que le bâtiment est construit correctement.

L'échafaudage vous aide, entre autres, à construire une cage d'ascenseur. Ensuite, vous démontez l'échafaudage, mais la cage d'ascenseur reste. Vous avez détruit "l'œuvre originale" en détruisant l'échafaudage.

L'analogie est ténue, mais le fait est qu'il n'est pas rare de créer des outils pour vous aider à créer un produit. Même si les outils ne sont pas permanents, ils sont utiles (voire nécessaires). Les charpentiers fabriquent des gabarits tout le temps, parfois uniquement pour un seul travail. Ensuite, ils déchirent les gabarits, utilisant parfois les pièces pour construire d'autres gabarits pour d'autres travaux, parfois non. Mais cela ne rend pas les gabarits inutiles ou inutiles.

Craig
la source