Comment gardez-vous vos tests unitaires efficaces lors de la refactorisation?

29

Dans une autre question, il a été révélé que l'un des problèmes avec TDD est de garder la suite de tests synchronisée avec la base de code pendant et après la refactorisation.

Maintenant, je suis un grand fan de refactoring. Je ne vais pas renoncer à faire du TDD. Mais j'ai également rencontré des problèmes de tests écrits de telle manière qu'une refactorisation mineure entraîne de nombreux échecs de test.

Comment éviter de casser les tests lors de la refactorisation?

  • Ecrivez-vous les tests «mieux»? Si oui, que devez-vous rechercher?
  • Évitez-vous certains types de refactoring?
  • Existe-t-il des outils de refactorisation des tests?

Edit: j'ai écrit une nouvelle question qui demandait ce que je voulais demander (mais j'ai gardé celle-ci comme une variante intéressante).

Alex Feinman
la source
7
J'aurais pensé qu'avec TDD, votre première étape dans la refactorisation consiste à écrire un test qui échoue, puis à refactoriser le code pour le faire fonctionner.
Matt Ellen
Votre IDE ne sait-il pas comment refactoriser les tests aussi?
@ Thorbjørn Ravn Andersen, oui, et j'ai écrit une nouvelle question qui demandait ce que je voulais demander (mais j'ai gardé celle-ci comme une variante intéressante; voir la réponse d'azheglov, qui est essentiellement ce que vous dites)
Alex Feinman
Avez-vous pensé à ajouter des informations à cette question?

Réponses:

35

Ce que vous essayez de faire n'est pas vraiment de refactoring. Avec le refactoring, par définition, vous ne changez pas ce que fait votre logiciel, vous changez comment il le fait.

Commencez par tous les tests verts (tous réussissent), puis apportez des modifications «sous le capot» (par exemple, déplacez une méthode d'une classe dérivée vers la base, extrayez une méthode ou encapsulez un composite avec un constructeur , etc.). Vos tests devraient toujours réussir.

Ce que vous décrivez ne semble pas être une refactorisation, mais une refonte, qui augmente également les fonctionnalités de votre logiciel testé. TDD et refactoring (comme j'ai essayé de le définir ici) ne sont pas en conflit. Vous pouvez toujours refactoriser (vert-vert) et appliquer TDD (rouge-vert) pour développer la fonctionnalité "delta".

azheglov
la source
7
Même code X copié 15 places. Personnalisé à chaque endroit. Vous en faites une bibliothèque commune et paramétrez le X ou utilisez un modèle de stratégie pour tenir compte de ces différences. Je garantis que les tests unitaires pour X échoueront. Les clients de X échoueront car l'interface publique change légèrement. Refonte ou refonte? Je l'appelle refactor mais de toute façon il casse toutes sortes de choses. L'essentiel est que vous ne pouvez pas refactoriser à moins que vous sachiez exactement comment tout cela s'emboîte. Ensuite, la fixation des tests est fastidieuse mais finalement triviale.
Kevin
3
Si les tests nécessitent un ajustement constant, c'est probablement un indice d'avoir des tests trop détaillés. Par exemple, supposons qu'un morceau de code doive déclencher les événements A, B et C dans certaines circonstances, sans ordre particulier. L'ancien code le fait dans l'ordre ABC et les tests attendent les événements dans cet ordre. Si le code refactorisé crache des événements dans l'ordre ACB, il fonctionne toujours selon la spécification mais le test échoue.
otto
3
@Kevin: Je pense que ce que vous décrivez est une refonte, car l'interface publique change. La définition de Fowler du refactoring ("altérer la structure interne [du code] sans changer son comportement externe") est assez claire à ce sujet.
azheglov
3
@azheglov: peut-être, mais d'après mon expérience, si la mise en œuvre est mauvaise, l'interface l'est également
Kevin
2
Une question parfaitement valable et claire aboutit à une discussion sur le «sens du mot». Peu importe comment vous l'appelez, nous allons avoir cette discussion ailleurs. En attendant, cette réponse omet complètement toute vraie réponse, mais a de loin le plus de votes positifs. Je comprends pourquoi les gens se réfèrent au TDD comme une religion.
Dirk Boer
21

L'un des avantages des tests unitaires est de pouvoir refaçonner en toute confiance.

Si le refactoring ne change pas l'interface publique, vous laissez les tests unitaires tels quels et assurez-vous qu'après le refactoring, ils réussissent tous.

Si le refactoring modifie effectivement l'interface publique, les tests doivent d'abord être réécrits. Refactorisez jusqu'à ce que les nouveaux tests réussissent.

Je n'éviterais jamais de refactoring car ça casse les tests. Écrire des tests unitaires peut être une douleur dans un cul, mais cela en vaut la peine à long terme.

Tim Murphy
la source
7

Contrairement aux autres réponses, il est important de noter que certaines méthodes de test peuvent devenir fragiles lorsque le système sous test (SUT) est refactorisé, si le test est en boîte blanche.

Si j'utilise un framework de mocking qui vérifie l' ordre des méthodes appelées sur les mocks (lorsque l'ordre n'est pas pertinent car les appels sont sans effet secondaire); puis si mon code est plus propre avec ces appels de méthode dans un ordre différent et que je refactorise, alors mon test se cassera. En général, les simulations peuvent introduire de la fragilité dans les tests.

Si je vérifie l'état interne de mon SUT en exposant ses membres privés ou protégés (nous pourrions utiliser "friend" dans Visual Basic, ou augmenter le niveau d'accès "internal" et utiliser "internalsvisibleto" en c #; dans de nombreuses langues OO, y compris c # une " sous-classe-test-spécifique " pourrait être utilisée), puis soudainement l'état interne de la classe importera - vous pouvez refactoriser la classe en boîte noire, mais les tests en boîte blanche échoueront. Supposons qu'un seul champ soit réutilisé pour signifier différentes choses (ce n'est pas une bonne pratique!) Lorsque le SUT change d'état - si nous le divisons en deux champs, nous devrons peut-être réécrire des tests défectueux.

Les sous-classes spécifiques aux tests peuvent également être utilisées pour tester des méthodes protégées - ce qui peut signifier qu'un refactorisateur du point de vue du code de production est un changement radical du point de vue du code de test. Déplacer quelques lignes dans ou hors d'une méthode protégée peut ne pas avoir d'effets secondaires sur la production, mais casser un test.

Si j'utilise des " hooks de test " ou tout autre code de compilation spécifique au test ou conditionnel, il peut être difficile de s'assurer que les tests ne se cassent pas en raison de fragiles dépendances de la logique interne.

Ainsi, pour éviter que les tests ne se couplent aux détails internes intimes du SUT, cela peut aider à:

  • Utilisez des talons plutôt que des simulacres, si possible. Pour plus d'informations, consultez le blog de Fabio Periera sur les tests tautologiques et mon blog sur les tests tautologiques .
  • Si vous utilisez des simulations, évitez de vérifier l'ordre des méthodes appelées, sauf si cela est important.
  • Essayez d'éviter de vérifier l'état interne de votre SUT - utilisez si possible son API externe.
  • Essayez d'éviter la logique spécifique au test dans le code de production
  • Essayez d'éviter d'utiliser des sous-classes spécifiques au test.

Tous les points ci-dessus sont des exemples de couplage en boîte blanche utilisés dans les tests. Donc, pour éviter complètement de refactoriser les tests de rupture, utilisez les tests de boîte noire du SUT.

Avertissement: Pour discuter de la refactorisation ici, j'utilise le mot un peu plus largement pour inclure la modification de l'implémentation interne sans aucun effet externe visible. Certains puristes peuvent être en désaccord et se référer exclusivement au livre Refactoring de Martin Fowler et Kent Beck - qui décrit les opérations de refactoring atomique.

Dans la pratique, nous avons tendance à prendre des étapes incessantes légèrement plus importantes que les opérations atomiques décrites ici, et en particulier les modifications qui laissent le code de production se comporter de manière identique de l'extérieur peuvent ne pas laisser passer les tests. Mais je pense qu'il est juste d'inclure "un algorithme de remplacement pour un autre algorithme qui a un comportement identique" en tant que refactor, et je pense que Fowler est d'accord. Martin Fowler lui-même dit que le refactoring peut casser les tests:

Lorsque vous rédigez un test simulateur, vous testez les appels sortants du SUT pour vous assurer qu'il parle correctement à ses fournisseurs. Un test classique ne se soucie que de l'état final - pas de la façon dont cet état a été dérivé. Les tests mockistes sont donc davantage couplés à la mise en œuvre d'une méthode. Changer la nature des appels aux collaborateurs provoque généralement la rupture d'un test de simulation.

[...]

Le couplage à l'implémentation interfère également avec le refactoring, car les changements d'implémentation sont beaucoup plus susceptibles de casser les tests qu'avec les tests classiques.

Fowler - Les simulacres ne sont pas des talons

perfectionniste
la source
Fowler a littéralement écrit le livre sur la refactorisation; et le livre le plus fiable sur les tests unitaires (xUnit Test Patterns de Gerard Meszaros) est dans la série "signature" de Fowler, donc quand il dit que le refactoring peut casser un test, il a probablement raison.
perfectionniste
5

Si vos tests échouent lors de la refactorisation, vous n'êtes pas, par définition, une refactorisation, ce qui "change la structure de votre programme sans changer le comportement de votre programme".

Parfois, vous devez changer le comportement de vos tests. Vous devez peut-être fusionner deux méthodes (par exemple, bind () et listen () sur une classe de socket TCP en écoute), de sorte que d'autres parties de votre code essaient et échouent à utiliser l'API désormais modifiée. Mais ce n'est pas du refactoring!

Frank Shearar
la source
Et s'il change simplement le nom d'une méthode testée par les tests? Les tests échoueront à moins que vous ne les renommiez également dans les tests. Ici, il ne change pas le comportement du programme.
Oscar Mederos
2
Dans ce cas, ses tests sont également en cours de refactorisation. Vous devez cependant être prudent: vous renommez d'abord la méthode, puis vous exécutez votre test. Il devrait échouer pour les bonnes raisons (il ne peut pas compiler (C #), vous obtenez une exception MessageNotUnderstood (Smalltalk), rien ne semble se produire (modèle de consommation nulle d'Objective-C)). Ensuite, vous modifiez votre test, sachant que vous n'avez introduit par erreur aucun bogue. «Si vos tests échouent» signifie «si vos tests échouent après avoir terminé la refactorisation», en d'autres termes. Essayez de garder les petits morceaux de changement!
Frank Shearar
1
Les tests unitaires sont intrinsèquement couplés à la structure du code. Par exemple, Fowler en a plusieurs dans refactoring.com/catalog qui pourraient affecter les tests unitaires (par exemple, masquer la méthode, la méthode en ligne, remplacer le code d'erreur par une exception, etc.).
Kristian H
faux. La fusion de deux méthodes est évidemment une refactorisation qui a des noms officiels (par exemple, une refactorisation de méthode en ligne correspond à la définition) et elle cassera les tests d'une méthode qui est en ligne - certains des cas de test devraient maintenant être réécrits / testés par d'autres moyens. Je n'ai pas à changer le comportement d'un programme afin de casser les tests unitaires, tout ce que je dois faire est de restructurer les internes qui ont des tests unitaires couplés avec eux. Tant que le comportement d'un programme n'est pas modifié, cela correspond toujours à la définition du refactoring.
KolA
J'ai écrit ce qui précède en supposant des tests bien écrits: si vous testez votre implémentation - si la structure du test reflète les internes du code sous test, bien sûr. Dans ce cas, testez le contrat de l'unité, pas la mise en œuvre.
Frank Shearar
4

Je pense que le problème avec cette question, c'est que différentes personnes prennent le mot «refactoring» différemment. Je pense qu'il est préférable de définir soigneusement certaines choses que vous voulez probablement dire:

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

Comme une autre personne l'a déjà noté, si vous conservez la même API et que tous vos tests de régression fonctionnent sur l'API publique, vous ne devriez avoir aucun problème. La refactorisation ne devrait poser aucun problème. Tout test échoué signifie que votre ancien code avait un bogue et que votre test est mauvais, ou que votre nouveau code a un bogue.

Mais c'est assez évident. Vous entendez donc PROBABLEMENT en refactorisant, que vous modifiez l'API.

Alors laissez-moi vous expliquer comment aborder cela!

  • Créez d'abord une NOUVELLE API, qui fait ce que vous voulez que votre comportement de NOUVELLE API soit. S'il arrive que cette nouvelle API porte le même nom qu'une ancienne API, j'ajoute le nom _NEW au nouveau nom de l'API.

    int DoSomethingInterestingAPI ();

devient:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

OK - à ce stade - tous vos tests de régression réussissent - en utilisant le nom DoSomethingInterestingAPI ().

SUIVANT, parcourez votre code et remplacez tous les appels par DoSomethingInterestingAPI () par la variante appropriée de DoSomethingInterestingAPI_NEW (). Cela inclut la mise à jour / réécriture de toutes les parties de vos tests de régression qui doivent être modifiées pour utiliser la nouvelle API.

SUIVANT, marquez DoSomethingInterestingAPI_OLD () comme [[obsolète ()]]. Restez dans l'API obsolète aussi longtemps que vous le souhaitez (jusqu'à ce que vous ayez mis à jour en toute sécurité tout le code qui pourrait en dépendre).

Avec cette approche, les échecs dans vos tests de régression sont simplement des bogues dans ce test de régression ou identifient les bogues dans votre code - exactement comme vous le souhaitez. Ce processus par étapes de révision d'une API en créant explicitement les versions _NEW et _OLD de l'API vous permet de faire coexister des bits du nouveau et de l'ancien code pendant un certain temps.

Lewis Pringle
la source
J'aime cette réponse car elle rend évident que les tests unitaires vers le SUT sont les mêmes que les clients externes d'un Api publié. Ce que vous prescrivez est très similaire au protocole SemVer pour gérer la bibliothèque / le composant publié afin d'éviter «l'enfer des dépendances». Cela se fait cependant à un coût de temps et de flexibilité, extrapoler cette approche à l'interface publique de chaque micro-unité signifie aussi extrapoler les coûts. Une approche plus flexible consiste à dissocier autant que possible les tests de la mise en œuvre, c'est-à-dire les tests d'intégration ou une DSL distincte pour décrire les entrées et sorties de test
KolA
1

Je suppose que vos tests unitaires sont d'une granularité que j'appellerais "stupide" :) c'est-à-dire qu'ils testent les minuties absolues de chaque classe et fonction. Éloignez-vous des outils générateurs de code et écrivez des tests qui s'appliquent à une plus grande surface, puis vous pouvez refactoriser les composants internes autant que vous le souhaitez, sachant que les interfaces de vos applications n'ont pas changé et que vos tests fonctionnent toujours.

Si vous voulez avoir des tests unitaires qui testent chaque méthode, alors attendez-vous à devoir les refactoriser en même temps.

gbjbaanb
la source
1
La réponse la plus utile qui répond réellement à la question - ne construisez pas votre couverture de test sur une base fragile de trivialités internes, ou ne vous attendez pas à ce qu'elle s'effondre constamment - mais la plus dévalorisée car TDD prescrit de faire exactement le contraire. C'est ce que vous obtenez pour souligner une vérité gênante sur une approche trop hypée.
KolA
1

synchroniser la suite de tests avec la base de code pendant et après le refactoring

Ce qui rend difficile le couplage . Tous les tests sont accompagnés d'un certain degré de couplage avec les détails de l'implémentation, mais les tests unitaires (qu'ils soient TDD ou non) sont particulièrement mauvais car ils interfèrent avec les internes: plus de tests unitaires équivalent à plus de code couplé à des unités, c'est-à-dire des signatures de méthodes / toute autre interface publique d'unités - au moins.

Les «unités» par définition sont des détails d'implémentation de bas niveau, l'interface des unités peut et doit changer / diviser / fusionner et autrement muter à mesure que le système évolue. L'abondance des tests unitaires peut en fait entraver cette évolution plus qu'elle ne contribue.

Comment éviter de casser les tests lors de la refactorisation? Évitez le couplage. En pratique, cela signifie éviter autant de tests unitaires que possible et préférer des tests de niveau supérieur / d'intégration plus agnostiques des détails d'implémentation. N'oubliez pas qu'il n'y a pas de solution miracle, les tests doivent encore être couplés à quelque chose à un certain niveau, mais idéalement, il devrait s'agir d'une interface explicitement versionnée à l'aide de la version sémantique, c'est-à-dire généralement au niveau de l'api / de l'application publiée (vous ne voulez pas faire SemVer pour chaque unité de votre solution).

KolA
la source
0

Vos tests sont trop étroitement liés à la mise en œuvre et non à l'exigence.

pensez à écrire vos tests avec des commentaires comme celui-ci:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

de cette façon, vous ne pouvez pas refactoriser le sens des tests.

mcintyre321
la source