Refactorisation - est-il approprié de simplement réécrire le code, tant que tous les tests réussissent?

9

J'ai récemment regardé "Toutes les petites choses" de RailsConf 2014. Au cours de cette conférence, Sandi Metz refactorise une fonction qui comprend une grande instruction if imbriquée:

def tick
    if @name != 'Aged Brie' && @name != 'Backstage passes to a TAFKAL80ETC concert'
        if @quality > 0
            if @name != 'Sulfuras, Hand of Ragnaros'
                @quality -= 1
            end
        end
    else
        ...
    end
    ...
end

La première étape consiste à diviser la fonction en plusieurs plus petites:

def tick
    case name
    when 'Aged Brie'
        return brie_tick
    ...
    end
end

def brie_tick
    @days_remaining -= 1
    return if quality >= 50

    @quality += 1
    @quality += 1 if @days_remaining <= 0
end

Ce que j'ai trouvé intéressant, c'est la façon dont ces petites fonctions ont été écrites. brie_tick, par exemple, n'a pas été écrit en extrayant les parties pertinentes de la tickfonction d' origine , mais à partir de zéro en se référant aux test_brie_*tests unitaires. Une fois tous ces tests unitaires réussis, il a brie_tickété considéré comme terminé. Une fois toutes les petites fonctions terminées, la tickfonction monolithique d'origine a été supprimée.

Malheureusement, le présentateur ne semblait pas conscient que cette approche conduisait à trois des quatre *_tickfonctions étant erronées (et l'autre était vide!). Il existe des cas marginaux dans lesquels le comportement des *_tickfonctions diffère de celui de la tickfonction d' origine . Par exemple, @days_remaining <= 0in brie_tickdevrait être < 0- donc brie_tickne fonctionne pas correctement lorsqu'il est appelé avec days_remaining == 1et quality < 50.

Qu'est-ce qui a mal tourné ici? Est-ce un échec des tests - car il n'y avait pas de tests pour ces cas de bord particuliers? Ou un échec de refactoring - parce que le code aurait dû être transformé étape par étape plutôt que réécrit à partir de zéro?

user200783
la source
2
Je ne suis pas sûr d'avoir compris la question. Bien sûr, il est correct de réécrire le code. Je ne sais pas exactement ce que vous entendez par «est-il correct de simplement réécrire le code». Si vous demandez "Est-ce correct de réécrire du code sans y penser beaucoup", la réponse est non, tout comme il n'est pas correct d' écrire du code de cette manière.
John Wu
Cela se produit souvent en raison de plans de test principalement axés sur les tests de cas d'utilisation réussis et très peu (ou pas du tout) sur la couverture des cas d'utilisation d'erreur ou des cas de sous-utilisation. C'est donc principalement une fuite de couverture. Une fuite de tests.
Laiv
@JohnWu - J'avais l'impression que le refactoring se faisait généralement comme une série de petites transformations du code source ("méthode d'extraction" etc.) plutôt qu'en réécrivant simplement le code (j'entends par là le réécrire à partir de zéro sans même en regardant le code existant, comme dans la présentation liée).
user200783
@JohnWu - La réécriture à partir de zéro est-elle une technique de refactorisation acceptable? Sinon, il est décevant de voir une présentation aussi bien considérée sur le refactoring adopter cette approche. OTOH si cela est acceptable, alors des changements de comportement involontaires peuvent être imputés aux tests manquants - mais existe-t-il un moyen d'être sûr que les tests couvrent tous les cas de bord possibles?
user200783
@ User200783 Eh bien, c'est une question plus importante, n'est-ce pas (comment puis-je m'assurer que mes tests sont complets?) De manière pragmatique, j'exécuterais probablement un rapport de couverture de code avant d'apporter des modifications, et j'examinerais attentivement les zones de code qui ne le font pas. faire de l'exercice, en veillant à ce que l'équipe de développement en soit consciente lors de la réécriture de la logique.
John Wu

Réponses:

11

Est-ce un échec des tests - car il n'y avait pas de tests pour ces cas de bord particuliers? Ou un échec de refactoring - parce que le code aurait dû être transformé étape par étape plutôt que réécrit à partir de zéro?

Tous les deux. La refactorisation en utilisant uniquement les étapes standard du livre original de Fowlers est nettement moins sujette aux erreurs que de faire une réécriture, il est donc souvent préférable de n'utiliser que ce type d'étapes pour bébés. Même s'il n'y a pas de tests unitaires pour chaque cas de bord, et même si l'environnement ne fournit pas de refactorings automatiques, un seul changement de code comme «introduire la variable explicative» ou «extraire la fonction» a une chance beaucoup plus petite de changer les détails de comportement du code existant qu'une réécriture complète d'une fonction.

Parfois, cependant, réécrire une section de code est ce dont vous avez besoin ou que vous voulez faire. Et si tel est le cas, vous avez besoin de meilleurs tests.

Notez que même lorsque vous utilisez un outil de refactoring, il y a toujours un certain risque d'introduire des erreurs lorsque vous changez de code, indépendamment de l'application d'étapes plus ou moins importantes. C'est pourquoi le refactoring a toujours besoin de tests. Notez également que les tests ne peuvent que réduire la probabilité de bogues, mais ne prouvent jamais leur absence - néanmoins l'utilisation de techniques telles que l'examen du code et de la couverture de branche peut vous donner un niveau de confiance élevé, et en cas de réécriture d'une section de code, c'est vaut souvent la peine d'appliquer de telles techniques.

Doc Brown
la source
1
Merci, c'est logique. Donc, si la solution ultime aux changements indésirables de comportement est d'avoir des tests complets, est-il possible d'avoir la certitude que les tests couvrent tous les cas de bord possibles? Par exemple, il serait possible d'avoir une couverture de 100% brie_ticktout en ne testant jamais le @days_remaining == 1cas problématique en testant par exemple avec @days_remainingset to 10et -10.
user200783
2
Vous ne pouvez jamais être absolument certain que les tests couvrent tous les cas de bord possibles, car il n'est pas possible de tester avec toutes les entrées possibles. Mais il existe de nombreuses façons de gagner en confiance dans les tests. Vous pouvez examiner les tests de mutation , ce qui est un moyen de tester l'efficacité des tests.
bdsl
1
Dans ce cas, les branches manquées auraient pu être détectées avec un outil de couverture de code lors du développement des tests.
cbojar
2

Qu'est-ce qui a mal tourné ici? Est-ce un échec des tests - car il n'y avait pas de tests pour ces cas de bord particuliers? Ou un échec de refactoring - parce que le code aurait dû être transformé étape par étape plutôt que réécrit à partir de zéro?

L'une des choses qui est vraiment difficile à travailler avec le code hérité: acquérir une compréhension complète du comportement actuel.

Le code hérité sans tests qui contraignent tous les comportements est un modèle courant dans la nature. Ce qui vous laisse une supposition: cela signifie-t-il que les comportements non contraints sont des variables libres? ou des exigences qui ne sont pas spécifiées?

De la conférence :

Maintenant, c'est du vrai refactoring selon la définition du refactoring; Je vais refactoriser ce code. Je vais changer son arrangement sans altérer son comportement.

C'est l'approche la plus conservatrice; si les exigences peuvent être sous-spécifiées, si les tests ne capturent pas toute la logique existante, alors vous devez être très très prudent sur la façon dont vous procédez.

Pour certains, vous pouvez affirmer que si les tests ne décrivent pas correctement le comportement du système, vous avez un "échec de test". Et je pense que c'est juste - mais pas vraiment utile; c'est un problème courant à avoir dans la nature.

Ou un échec de refactoring - parce que le code aurait dû être transformé étape par étape plutôt que réécrit à partir de zéro?

Le problème n'est pas tout à fait que les transformations auraient dû être progressives; mais plutôt que le choix de l'outil de refactoring (opérateur de clavier humain? plutôt que l'automatisation guidée) n'était pas bien aligné avec la couverture du test, en raison du taux d'erreur plus élevé.

Cela aurait pu être résolu soit en utilisant des outils de refactoring avec une fiabilité plus élevée, soit en introduisant une batterie de tests plus large pour améliorer les contraintes sur le système.

Je pense donc que votre conjonction est mal choisie; ANDnon OR.

VoiceOfUnreason
la source
2

La refactorisation ne doit pas modifier le comportement visible de l'extérieur de votre code. Voilà l'objectif.

Si vos tests unitaires échouent, cela indique que vous avez modifié le comportement. Mais réussir des tests unitaires n'est jamais l'objectif. Cela aide plus ou moins à atteindre votre objectif. Si le refactoring modifie le comportement visible de l'extérieur et que tous les tests unitaires réussissent, votre refactoring a échoué.

Les tests unitaires de travail dans ce cas ne vous donnent qu'un mauvais sentiment de succès. Mais qu'est-ce qui a mal tourné? Deux choses: le refactoring était imprudent, et les tests unitaires n'étaient pas très bons.

gnasher729
la source
1

Si vous définissez «correct» comme étant «les tests réussissent», alors par définition, il n'est pas faux de modifier le comportement non testé.

Si un comportement d'arête particulier doit être défini, ajoutez un test pour cela, sinon, alors c'est OK de ne pas se soucier de ce qui se passe. Si vous êtes vraiment pédant, vous pouvez écrire un test qui vérifie truedans ce cas de bord pour documenter que vous ne vous souciez pas du comportement.

Caleth
la source