Tout en refactorisant mon code à l'aide de Test Driven Development (TDD), dois-je continuer à créer de nouveaux cas de test pour le nouveau code refactorisé que j'écris?
Cette question est basée sur les étapes TDD suivantes:
Mon doute est dans l'étape de refactorisation. De nouveaux cas de tests unitaires devraient-ils être écrits pour le code refactorisé?
Pour illustrer cela, je vais donner un exemple simplifié:
Supposons que je crée un RPG et que je crée un système HPContainer qui devrait faire ce qui suit:
Pour répondre à cela, j'écris les tests suivants:
[Test]
public void LoseHP_LosesHP_DecreasesCurrentHPByThatAmount()
{
int initialHP = 100;
HPContainer hpContainer= new HPContainer(initialHP);
hpContainer.Lose(5)
int currentHP = hpContainer.Current();
Assert.AreEqual(95, currentHP);
}
[Test]
public void LoseHP_LosesMoreThanCurrentHP_CurrentHPIsZero()
{
int initialHP = 100;
HPContainer hpContainer= new HPContainer(initialHP);
hpContainer.Lose(200)
int currentHP = hpContainer.Current();
Assert.AreEqual(0, currentHP);
}
Pour satisfaire aux exigences, j'implémente le code suivant:
public class HPContainer
{
private int currentHP = 0;
public void HPContainer(int initialHP)
{
this.currentHP = initialHP;
}
public int Current()
{
return this.currentHP;
}
public void Lose(int value)
{
this.currentHP -= value;
if (this.currentHP < 0)
this.currentHP = 0;
}
}
Bien!
Les tests passent.
Nous avons fait notre boulot!
Supposons maintenant que le code croisse et que je souhaite refactoriser ce code, et je décide que l'ajout d'une classe Clamper
comme suit est une bonne solution.
public static class Clamper
{
public static int ClampToNonNegative(int value)
{
if(value < 0)
return 0;
return value;
}
}
Et par conséquent, en changeant la classe HPContainer:
public class HPContainer
{
private int currentHP = 0;
public void HPContainer(int initialHP)
{
this.currentHP = initialHP;
}
public int Current()
{
return this.currentHP;
}
public void Lose(int value)
{
this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);
}
}
Les tests réussissent toujours, donc nous sommes sûrs que nous n'avons pas introduit de régression dans notre code.
Mais ma question est:
Faut-il ajouter des tests unitaires à la classe Clamper
?
Je vois deux arguments opposés:
Oui, des tests doivent être ajoutés car nous devons couvrir Clamper
de la régression. Cela garantira que si Clamper
doit être changé, nous pouvons le faire en toute sécurité avec la couverture de test.
Non, Clamper
ne fait pas partie de la logique métier et est déjà couvert par les cas de test de HPContainer. L'ajout de tests ne fera qu'encombrer inutilement et ralentir la refactorisation future.
Quel est le raisonnement correct, en suivant les principes et bonnes pratiques TDD?
Dans TDD, dois-je ajouter des tests unitaires au code refactorisé?
"code refactorisé" implique que vous ajoutez les tests après vous avez refactorisé. Il ne s'agit pas de tester vos modifications. TDD repose beaucoup sur le test avant et après la mise en œuvre/refactorisation/correction du code.
Vous ne devriez pas ajouter vos tests unitaires après vous refactorisez, mais plutôt avant (en supposant que ces tests sont bien sûr garantis).
De nouveaux cas de tests unitaires devraient-ils être écrits pour le code refactorisé?
Le très définition de refactoring est de changer le code sans changer son comportement.
Le refactoring est une technique disciplinée pour restructurer un corps de code existant, en modifiant sa structure interne sans changer son comportement externe .
Comme les tests unitaires sont écrits spécifiquement pour tester le comportement, cela n'a pas de sens pour vous d'exiger des tests unitaires supplémentaires après la refactorisation.
Le refactoring ne peut en soi jamais conduire à avoir besoin de tests unitaires supplémentaires qui n'étaient pas nécessaires auparavant.
Cela étant dit, s'il y avait des tests que vous auriez dû faire depuis le début mais que vous l'aviez oublié jusqu'à présent, vous pouvez bien sûr les ajouter. Ne prenez pas ma réponse pour dire que vous ne pouvez pas ajouter de tests simplement parce que vous avez oublié de les écrire auparavant.
De même, vous oubliez parfois de couvrir un cas et cela n'apparaît qu'après avoir rencontré un bug. Il est recommandé d'écrire ensuite un nouveau test qui vérifie maintenant ce cas de problème.
Faut-il ajouter des tests unitaires à la classe Clamper?
Il me semble que Clamper
devrait être une classe internal
, car c'est une dépendance cachée de votre HPContainer
. Le consommateur de votre classe HPContainer
ne sait pas que Clamper
existe et n'a pas besoin de le savoir.
Les tests unitaires se concentrent uniquement sur le comportement externe (public) des consommateurs. Comme Clamper
doit être internal
, il ne nécessite aucun test unitaire.
Si Clamper
se trouve dans un autre assembly, il a besoin de tests unitaires car il est public. Mais votre question ne permet pas de savoir si cela est pertinent.
Sidenote
Je ne vais pas entrer dans un sermon IoC ici. Certaines dépendances cachées sont acceptables lorsqu'elles sont pures (c'est-à-dire sans état) et n'ont pas besoin d'être moquées - par exemple personne n'applique vraiment que la classeMath
de .NET soit injectée, et votreClamper
n'est fonctionnellement pas différent deMath
.
Je suis sûr que d'autres ne seront pas d'accord et adopteront l'approche "tout injecter". Je ne suis pas en désaccord sur le fait que cela peut être fait, mais ce n'est pas l'objet de cette réponse car elle n'est pas pertinente pour la question publiée, à mon avis.
Je ne pense pas que la méthode de serrage soit tout ce qu'il faut pour commencer.
public static int ClampToNonNegative(int value)
{
if(value < 0)
return 0;
return value;
}
Ce que vous avez écrit ici est une version plus limitée de la méthode Math.Max()
existante. Chaque utilisation:
this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);
peut être remplacé par Math.Max
:
this.currentHP = Math.Max(this.currentHP - value, 0);
Si votre méthode n'est rien d'autre qu'un wrapper autour d'une seule méthode existante, il devient inutile de l'avoir.
Cela pourrait être considéré comme deux étapes:
d'abord vous allez créer une nouvelle classe publique Clamper
(sans changer HPContainer
). Ce n'est en fait pas une refactorisation, et lors de l'application stricte de TDD, suivant littéralement les nano-cycles de TDD , vous ne seriez même pas autorisé à écrire la première ligne de code pour cette classe avant d'écrire au moins un test unitaire pour cela.
puis vous commencez à refactoriser la HPContainer
en utilisant la classe Clamper
. En supposant que les tests unitaires existants pour cette classe fournissent déjà une couverture suffisante, il n'est pas nécessaire d'ajouter d'autres tests unitaires au cours de cette étape.
Donc oui, si vous créez un composant réutilisable avec l'intention de l'utiliser pour une refactorisation dans un avenir proche, vous devez ajouter des tests unitaires pour le composant. Et non, lors de la refactorisation, vous n'ajoutez généralement pas de tests unitaires supplémentaires.
Un autre cas est celui où Clamper
est toujours maintenu privé/interne, non destiné à être réutilisé. Ensuite, l'extraction entière peut être considérée comme une étape de refactorisation, et l'ajout de nouveaux tests unitaires n'apporte pas nécessairement d'avantage. Cependant, pour ces cas, je prendrais également en considération la complexité des composants - si les deux composants sont si complexes que la cause première d'un test défaillant qui teste les deux peut être difficile à repérer, alors il peut être une bonne idée de fournir des tests unitaires individuels pour les deux: un ensemble de tests qui teste Clamper
seul, et un test HPContainer
avec une maquette injectée pour Clamper
.
Clamper
est sa propre unité - et les unités doivent être testées avec les tests unitaires - car les unités peuvent être utilisées ailleurs. Ce qui est génial si Clamper
vous aide également à implémenter ManaContainer
, FoodContainer
, DamageCalculator
, etc ...
Si Clamper
n'était qu'un détail d'implémentation, il ne peut pas être directement testé. En effet, nous ne pouvons pas y accéder en tant qu'unité pour le tester.
Votre premier exemple traite la vérification comme un détail d'implémentation - c'est pourquoi vous n'avez pas écrit de test vérifiant que l'instruction if
fonctionne de manière isolée. En tant que détail d'implémentation, la seule façon de le tester est de tester le comportement observable de l'unité dont il s'agit d'un détail d'implémentation (dans ce cas, le comportement de HPContainer
centré autour de Lose(...)
) .
Pour conserver le refactoring, mais lui laisser un détail d'implémentation:
public class HPContainer
{
private int currentHP = 0;
public void HPContainer(int initialHP)
{
this.currentHP = initialHP;
}
public int Current()
{
return this.currentHP;
}
public void Lose(int value)
{
this.currentHP = ClampToNonNegative(this.currentHP - value);
}
private static int ClampToNonNegative(int value)
{
if(value < 0)
return 0;
return value;
}
}
Vous donne l'expressivité, mais laisse la décision d'introduire une nouvelle unité à plus tard. Espérons que lorsque vous avez plusieurs instances de duplication à partir desquelles vous pouvez raisonnablement généraliser une solution réutilisable. À l'heure actuelle (votre deuxième exemple) suppose que cela sera nécessaire.
Non, n'écrivez pas de tests pour la classe Clamper
,
car il est déjà testé par le biais de tests pour la classe HPContainer
.
Si vous écrivez la solution la plus simple et la plus rapide possible pour réussir les tests, vous vous retrouvez avec une grande classe/fonction qui fait tout.
Lorsque vous commencez le refactoring, car maintenant vous pouvez voir une image complète de l'implémentation, vous pourrez reconnaître les duplications ou certains modèles dans la logique.
Lors de la refactorisation, vous supprimez la duplication en extrayant les duplications vers des méthodes ou des classes dédiées.
Si vous décidez de passer des classes nouvellement introduites via le constructeur, vous devrez changer un seul endroit dans les tests où vous configurez la classe sous le test pour passer de nouvelles dépendances. Cela ne devrait être que le changement de code de test "autorisé" pendant la refactorisation.
Si vous écrivez des tests pour les classes introduites lors du refactoring, vous vous retrouverez en boucle "infinie".
.
Dans la plupart des cas, le refactoring extrait une logique dupliquée ou compliquée de manière plus lisible et structurée.
Faut-il ajouter des tests unitaires à la classe Clamper?
Pas encore.
L'objectif est un code propre qui fonctionne. Les rituels qui ne contribuent pas à cet objectif sont des déchets.
Je suis payé pour du code qui fonctionne, pas pour des tests, donc ma philosophie est de tester le moins possible pour atteindre un niveau de confiance donné - Kent Beck, 2008
Votre refactoring est un détail d'implémentation; le comportement externe du système testé n'a pas changé du tout. L'écriture d'une nouvelle collection de tests pour ce détail d'implémentation n'améliorera pas du tout votre confiance.
Déplacer l'implémentation dans une nouvelle fonction, ou une nouvelle classe, ou un nouveau fichier - nous faisons ces choses pour un certain nombre de raisons sans rapport avec le comportement du code. Nous n'avons pas encore besoin d'introduire une nouvelle suite de tests. Ce sont des changements de structure, pas de comportement
Les tests du programmeur doivent être sensibles aux changements de comportement et insensibles aux changements de structure. - Kent Beck, 2019
Le point où nous commençons à penser au changement, c'est quand nous voulons changer le comportement de Clamper
, et la cérémonie supplémentaire de création d'un HPContainer
commence à se mettre en travers.
Vous vouliez une banane mais ce que vous avez obtenu était un gorille tenant la banane et toute la jungle. - Joe Armstrong
Nous essayons d'éviter la situation où nos tests (qui servent de documentation sur le comportement attendu de certains modules de notre solution) sont pollués avec un tas de détails non pertinents. Vous avez probablement vu des exemples de tests qui créent un sujet de test avec un tas d'objets nuls, car de réelles implémentations ne sont pas nécessaires pour le cas d'utilisation actuel, mais vous ne pouvez pas invoquer le code sans eux.
Pour les refactorisations purement structurelles, non, vous n'avez pas besoin de commencer à introduire de nouveaux tests.
Personnellement, je suis très partisan de ne tester que sur des interfaces stables (externes ou internes) qui ne sont pas susceptibles d'être affectées par le refactoring. Je n'aime pas créer des tests qui empêcheront le refactoring (j'ai vu des cas où les gens ne pouvaient pas implémenter un refactoring car cela casserait trop de tests). Si un composant ou un sous-système a un contrat avec d'autres composants ou sous-systèmes pour fournir une interface particulière, testez cette interface; si une interface est purement interne, ne la testez pas ou ne jetez pas vos tests une fois qu'ils ont fait leur travail.
Les tests unitaires vous donnent une certaine assurance que votre effort de refactoring n'a pas introduit de bogues.
Vous écrivez donc des tests unitaires et assurez-vous qu'ils réussissent sans modifier le code existant.
Ensuite, vous refactorisez, en vous assurant que vos tests unitaires n'échouent pas.
C'est ainsi que vous avez un certain niveau de certitude que votre refactoring n'a pas cassé les choses. Bien sûr, cela n'est vrai que si vos tests unitaires sont corrects et couvrent tous les chemins de code possibles dans le code d'origine. Si vous manquez quelque chose dans les tests, vous courez toujours le risque que votre refactorisation casse des choses.
C'est ainsi que j'aime généralement structurer et penser à mes tests et à mon code. Le code doit être organisé en dossiers, les dossiers peuvent avoir des sous-dossiers qui le subdivisent davantage, et les dossiers qui sont des feuilles (n'a pas de sous-dossiers) est appelé un fichier. Les tests doivent également être organisés en une hiérarchie correspondante qui reflète la hiérarchie du code principal.
Dans les langues où les dossiers n'ont pas de sens, vous pouvez le remplacer par des packages/modules/etc ou d'autres structures hiérarchiques similaires dans votre langue. Peu importe l'élément hiérarchique dans votre projet, le point important ici est d'organiser vos tests et votre code principal avec des hiérarchies correspondantes.
Les tests pour un dossier dans la hiérarchie doivent couvrir complètement chaque code sous le dossier correspondant de la base de code principale. Un test qui teste indirectement du code provenant de différentes parties de la hiérarchie est accidentel et ne compte pas dans la couverture de cet autre dossier. Idéalement, il ne devrait pas y avoir de code appelé et testé uniquement par des tests provenant de différentes parties de la hiérarchie.
Je ne recommande pas de subdiviser la hiérarchie de test au niveau classe/fonction. Il est généralement trop fin et cela ne vous donne pas beaucoup d'avantages de subdiviser les choses dans ce détail. Si un fichier de code principal est suffisamment volumineux pour justifier plusieurs fichiers de test, cela indique généralement que le fichier en fait trop et aurait dû être décomposé.
Sous cette structure d'organisation, alors si votre nouvelle classe/fonction vit sous le même dossier feuille que tout le code qui l'utilise, alors elle n'a pas besoin de ses propres tests tant que les tests pour ce fichier le couvrent déjà. Si, en revanche, vous considérez la nouvelle classe/méthode suffisamment grande ou indépendante pour garantir son propre fichier/dossier dans la hiérarchie, vous devez également créer le fichier/dossier de test correspondant.
De manière générale, un fichier doit être de la taille que vous pouvez adapter le contour approximatif dans votre tête et où vous pouvez écrire un paragraphe pour expliquer quel est le contenu des fichiers pour décrire ce qui les rassemble. En règle générale, il s'agit généralement d'un écran pour moi (un dossier ne doit pas avoir plus d'un écran de sous-dossiers, un fichier ne doit pas avoir plus d'un écran de classes/fonctions de niveau supérieur, une fonction ne doit pas avoir plus d'un écran de lignes). Si imaginer le contour du fichier vous semble difficile, le fichier est probablement trop volumineux.
Comme d'autres réponses l'ont noté, ce que vous décrivez ne ressemble pas à une refactorisation. L'application de TDD au refactoring ressemblerait à ceci:
Identifiez votre surface API. Par définition, le refactoring ne changera pas la surface de votre API. Si le code a été écrit sans une surface API clairement conçue et que les consommateurs dépendent des détails d'implémentation, vous rencontrez des problèmes plus importants qui ne peuvent pas être résolus par une refactorisation. C'est là que vous définissez une surface API, verrouillez tout le reste et bousculez le numéro de version principal pour signifier que la nouvelle version n'est pas rétrocompatible, ou jetez le projet entier et réécrivez-le à partir de zéro.
Écrivez des tests sur la surface de l'API. Pensez à l'API en termes de garanties, par exemple, la méthode Foo
renvoie un résultat significatif quand on lui donne un paramètre qui remplit les conditions spécifiées, et lève une exception spécifique sinon. Rédigez des tests pour chaque garantie que vous pouvez identifier. Pensez en termes de ce que l'API est censée faire, pas de ce qu'elle fait réellement. S'il y avait une spécification ou une documentation originale, étudiez-la. S'il n'y en avait pas, écrivez-en. Un code sans documentation n'est ni bon ni mauvais. N'écrivez pas de tests sur des éléments qui ne figurent pas dans la spécification API.
Commencez à modifier le code, en exécutant fréquemment vos tests pour vous assurer que vous n'avez violé aucune garantie de l'API.
Il existe un décalage dans de nombreuses organisations entre les développeurs et les testeurs. Les développeurs qui ne pratiquent pas TDD, au moins de manière informelle, ignorent souvent les caractéristiques qui rendent le code testable. Si tous les développeurs écrivaient du code testable, il n'y aurait pas besoin de frameworks moqueurs. Le code qui n'est pas conçu pour la testabilité crée un problème de poulet et d'oeuf. Vous ne pouvez pas refactoriser sans tests, et vous ne pouvez pas écrire de tests avant d'avoir corrigé le code. Les coûts de ne pas pratiquer le TDD dès le départ sont énormes. Les modifications coûteront probablement plus cher que le projet d'origine. Encore une fois, c'est là que vous vous résignez à faire des changements de rupture ou à jeter le tout.