Comment écrire des tests unitaires avant la refactorisation?


55

J'ai lu des réponses à des questions allant dans le même sens, telles que "Comment maintenez-vous vos tests unitaires pendant le refactoring?". Dans mon cas, le scénario est légèrement différent dans la mesure où on m'a confié un projet à réviser et à mettre en conformité avec certaines normes en vigueur. À l'heure actuelle, il n'y a aucun test pour le projet!

J'ai identifié un certain nombre de choses qui, à mon avis, auraient pu être améliorées, telles que le non-mélange du code de type DAO dans une couche de service.

Avant de refactoriser, il semblait être une bonne idée d'écrire des tests pour le code existant. Le problème qui m’apparaît est que lorsque je refacturais, alors ces tests se briseront au fur et à mesure que je changerai de logique et que les tests seront écrits avec la structure précédente à l’esprit (dépendances simulées, etc.).

Dans mon cas, quelle serait la meilleure façon de procéder? Je suis tenté de rédiger les tests autour du code refactorisé, mais je suis conscient du risque de refactoriser de manière incorrecte des éléments susceptibles de modifier le comportement souhaité.

Qu'il s'agisse d'un refactor ou d'une refonte, je suis heureux que ma compréhension de ces termes soit corrigé. Je travaille actuellement sur la définition suivante pour le refactoring "Avec le refactoring, par définition, vous ne changez pas ce que fait votre logiciel, vous changez la façon dont il le fait ". Donc, je ne change pas ce que le logiciel fait, je changerais comment / où il le fait.

De même, je peux voir que si je modifie la signature des méthodes, cela pourrait être considéré comme une refonte.

Voici un bref exemple

MyDocumentService.java (courant)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (refactored / redessiné quoi que soit)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}

14
Est-ce vraiment une refactorisation que vous envisagez de faire, ou une refonte ? Parce que la réponse peut être différente dans les deux cas.
Herby

4
Je travaille sur la définition "Avec le refactoring, par définition, vous ne changez pas ce que votre logiciel fait, vous changez comment il le fait." Donc, je crois que dans ce cas, c'est du refactoring, n'hésitez pas à corriger ma compréhension du terme
PDStat

21
Non, écrivez des tests d'intégration. Le "refactoring" que vous envisagez dépasse le niveau de test unitaire. Seules les unités testent les nouvelles classes (ou les anciennes que vous savez conserver).
Arrêtez de blesser Monica

2
En ce qui concerne la définition du refactoring, votre logiciel définit-il clairement ce qu’il fait? En d'autres termes, est-il déjà "factorisé" dans des modules avec des API indépendantes? Sinon, vous ne pouvez pas le refactoriser, sauf peut-être au plus haut niveau (face à l'utilisateur). Au niveau du module, vous devrez inévitablement le redéfinir. Dans ce cas, ne perdez pas votre temps à écrire des tests unitaires avant d'avoir des unités.
Kevin Krumwiede

4
Il est très probable que vous devrez refactoriser un peu sans le filet de sécurité des tests pour pouvoir le placer dans un harnais de test. Le meilleur conseil que je puisse vous donner est que si votre IDE ou votre outil de refactoring ne le fait pas pour vous, ne le faites pas à la main. Continuez à appliquer des refactorisations automatisées jusqu'à ce que vous puissiez obtenir la coupe dans un harnais. Vous allez sûrement vouloir prendre un exemplaire de "Working Effectively with Legacy Code" de Michael Feather.
RubberDuck

Réponses:


56

Vous recherchez des tests qui vérifient les régressions . c'est-à-dire rompre un comportement existant. Je commencerais par identifier à quel niveau ce comportement restera le même, l'interface que la conduite de ce comportement restera le même et commencer à mettre en place des tests à ce stade.

Vous avez maintenant des tests qui affirmeront que quoi que vous fassiez en dessous de ce niveau, votre comportement reste le même.

Vous avez parfaitement raison de vous demander comment les tests et le code peuvent rester synchronisés. Si votre interface avec un composant reste la même, vous pouvez alors écrire un test autour de celui-ci et affirmer les mêmes conditions pour les deux implémentations (lors de la création de la nouvelle implémentation). Si ce n'est pas le cas, vous devez accepter le fait qu'un test pour un composant redondant est un test redondant.


1
En d'autres termes, vous ferez probablement des tests d'intégration ou de système plutôt que des tests unitaires. Vous utiliserez probablement toujours un outil de "tests unitaires", mais vous rencontrerez plus d'une unité de code à chaque test.
Móż

Oui. C'est vraiment le cas. Votre test de régression pourrait bien faire quelque chose de très haut niveau, par exemple des requêtes REST adressées à un serveur et éventuellement un test de base de données ultérieur (c’est-à-dire certainement pas un test unitaire !)
Brian Agnew

40

La pratique recommandée consiste à commencer par écrire des "tests" qui testent le comportement actuel du code, y compris éventuellement des bogues, mais sans vous obliger à plonger dans la folie de discerner si un comportement donné qui enfreint les exigences est un bogue, solution de contournement pour quelque chose que vous ignorez ou représente un changement non documenté des exigences.

Il est plus logique que ces tests de précision se situent à un niveau élevé, c’est-à-dire des tests d’intégration plutôt que des tests unitaires, afin qu’ils continuent de fonctionner lorsque vous démarrez le refactoring.

Toutefois, certaines refactorisations peuvent être nécessaires pour rendre le code testable - veillez à vous en tenir à des refactorisations "sûres". Par exemple, dans presque tous les cas, les méthodes privées peuvent être rendues publiques sans rien casser.


+1 pour les tests d'intégration. En fonction de l'application, vous pourrez peut-être commencer par envoyer des demandes à l'application Web. Ce que l'application renvoie ne devrait pas changer simplement en raison de la refactorisation, bien que, si elle renvoie du HTML, cela est certainement moins testable.
JPMc26

J'aime la phrase "pin-down" tests.
Brian Agnew

12

Si vous ne l'avez pas déjà fait, je suggère de lire à la fois Travailler efficacement avec le code hérité et Refactoriser - Améliorer la conception du code existant .

[..] Le problème qui m’apparaît, c’est que, lorsque je fais le refactor, ces tests se brisent lorsque je change de logique et que les tests sont écrits avec la structure précédente à l’esprit (dépendances simulées, etc.) [ ..]

Je ne vois pas nécessairement cela comme un problème: écrivez les tests, modifiez la structure de votre code, puis ajustez également la structure du test . Cela vous indiquera directement si votre nouvelle structure est réellement meilleure que l’ancienne, car si c’est le cas, les tests ajustés seront plus faciles à écrire (et donc changer les tests devrait être relativement simple, ce qui réduirait le risque d’introduction récente. bug passer les tests).

En outre, comme d’autres l’ont déjà écrit, n’écrivez pas des tests trop détaillés (du moins pas au début). Essayez de rester à un niveau d'abstraction élevé (vos tests seront donc probablement mieux caractérisés par des tests de régression ou même d'intégration).


1
Cette. Les tests auront une apparence affreuse , mais ils couvriront le comportement existant. Ensuite, au fur et à mesure que le code est refactorisé, les tests le sont également, étape par étape. Répétez jusqu'à ce que vous ayez quelque chose dont vous soyez fier. ++
RubberDuck

1
J'appuie ces deux recommandations du livre - j'ai toujours les deux à portée de main lorsque je dois gérer du code sans test.
Toby Speight

5

N'écrivez pas de tests unitaires stricts où vous vous moquez de toutes les dépendances. Certaines personnes vous diront que ce ne sont pas de vrais tests unitaires. Ignore les. Ces tests sont utiles et c'est ce qui compte.

Regardons votre exemple:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

Votre test ressemble probablement à ceci:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Au lieu de vous moquer de DocumentDao, moquez-vous de ses dépendances:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Maintenant, vous pouvez déplacer la logique de MyDocumentServicedans DocumentDaosans que les tests ne se brisent. Les tests montreront que la fonctionnalité est la même (dans la mesure où vous l'avez testée).


Si vous testez DocumentService et que vous ne vous moquez pas de DAO, il ne s'agit pas d'un test unitaire. C'est un peu entre le test unitaire et le test d'intégration. N'est-ce pas?
Laiv

7
@ Laiv, il existe en fait une grande diversité dans l'utilisation du terme test unitaire. Certains l'utilisent pour désigner uniquement des tests strictement isolés. D'autres incluent tout test qui s'exécute rapidement. Certains incluent tout ce qui fonctionne dans une structure de test. Mais finalement, peu importe comment vous voulez définir le terme test unitaire. La question est de savoir quels tests sont utiles, nous ne devrions donc pas nous laisser distraire par la définition exacte du test unitaire.
Winston Ewert

Excellent point qui montre que l'utilité est ce qui compte le plus. Les tests unitaires extravagants pour les algorithmes les plus triviaux, dans le seul but de faire en sorte que les tests unitaires fassent plus de tort que de bien, sinon une simple perte de temps et des ressources précieuses. Cela peut s'appliquer à peu près à tout et c'est quelque chose que je souhaiterais connaître plus tôt dans ma carrière.
Lee

3

Comme vous le dites, si vous modifiez le comportement, il s’agit d’une transformation et non d’un refactor. À quel niveau vous modifiez le comportement est ce qui fait la différence.

S'il n'y a pas de test formel au plus haut niveau, essayez de trouver un ensemble d'exigences auxquelles les clients (code d'appel ou humains) doivent rester inchangés après votre nouvelle conception pour que votre code soit considéré comme opérationnel. C'est la liste des cas de test que vous devez implémenter.

Pour répondre à votre question sur la modification des implémentations nécessitant des scénarios de test différents, je vous suggère de jeter un coup d'œil à Detroit (classique) vs London (mockist) TDD. Martin Fowler en parle dans son excellent article Les moqueries ne sont pas des moignons, mais beaucoup de gens ont des opinions. Si vous commencez au plus haut niveau, où vos externals ne peuvent pas changer, et que vous réduisez votre temps, les exigences doivent rester relativement stables jusqu'à ce que vous atteigniez un niveau qui doit réellement changer.

Sans aucun test, la tâche sera difficile et vous voudrez peut-être envisager d'exécuter les clients selon deux chemins de code (et enregistrer les différences) jusqu'à ce que vous puissiez être sûr que votre nouveau code fasse exactement ce qu'il doit faire.


3

Voici mon approche. Cela a un coût en termes de temps car il s'agit d'un test de refactorisation en 4 phases.

Ce que je vais exposer convient peut-être mieux aux composants plus complexes que celui exposé dans l'exemple de la question.

Quoi qu'il en soit, la stratégie est valable pour tout composant candidat à normaliser par une interface (DAO, Services, Contrôleurs, ...).

1. l'interface

Permet de rassembler toutes les méthodes publiques de MyDocumentService et de les rassembler dans une interface. Par exemple. S'il existe déjà, utilisez celui-là au lieu d'en créer un nouveau .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Ensuite, nous forçons MyDocumentService à implémenter cette nouvelle interface.

Jusqu'ici tout va bien. Aucun changement majeur n'a été apporté, nous avons respecté le contrat actuel et behaivos reste intact.

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. Test unitaire du code hérité

Ici nous avons le travail difficile. Pour configurer une suite de tests. Nous devrions définir autant de cas que possible: des cas réussis mais aussi des cas d'erreur. Ces dernières sont pour le bien de la qualité du résultat.

Au lieu de tester MyDocumentService, nous allons utiliser l'interface comme contrat à tester.

Je ne vais pas entrer dans les détails, alors pardonnez-moi si mon code est trop simple ou trop agnostique

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

Cette étape prend plus de temps que toute autre dans cette approche. Et c'est le plus important, car cela servira de point de référence pour les comparaisons futures.

Remarque: En raison de l'absence de modifications majeures, behaivor reste inchangé. Je suggère de faire une étiquette ici dans le SCM. Le tag ou la branche n'a pas d'importance. Il suffit de faire une version.

Nous le voulons pour les annulations, les comparaisons de versions et peut être pour les exécutions en parallèle de l'ancien code et du nouveau.

3. Refactoring

Refactor va être implémenté dans un nouveau composant. Nous ne ferons aucune modification sur le code existant. La première étape est aussi simple que de copier-coller de MyDocumentService et de le renommer en CustomDocumentService (par exemple).

La nouvelle classe continue d'implémenter DocumentService . Puis allez et refactorisez getAllDocuments () . (Commençons par un. Pin-refactors)

Cela peut nécessiter quelques modifications sur l'interface / les méthodes de DAO. Si c'est le cas, ne changez pas le code existant. Implémentez votre propre méthode dans l'interface DAO. Annotez l'ancien code avec obsolète et vous saurez plus tard ce qui doit être supprimé.

Il est important de ne pas casser / changer la mise en œuvre existante. Nous voulons exécuter les deux services en parallèle, puis comparer les résultats.

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4. Mise à jour de DocumentServiceTestSuite

Ok, maintenant la partie la plus facile. Ajouter les tests du nouveau composant.

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

Nous avons maintenant oldResult et newResult validés indépendamment, mais nous pouvons également comparer les uns avec les autres. Cette dernière validation est facultative et dépend du résultat. Peut-être que ce n'est pas comparable.

Ne comparez peut-être pas deux collections de cette façon, mais serait valable pour tout autre type d’objet (pojos, entités de modèle de données, DTO, wrappers, types natifs, etc.).

Remarques

Je n'oserais pas dire comment faire des tests unitaires ou comment utiliser des librairies factices. Je n'ose pas non plus dire comment vous devez faire le refactor. Ce que je voulais faire, c'est suggérer une stratégie globale. Comment aller de l'avant dépend de vous. Vous savez exactement ce qu'est le code, sa complexité et si une telle stratégie vaut la peine d'être essayée. Des faits tels que le temps et les ressources sont importants ici. En outre, qu'attendez-vous de ces tests à l'avenir?

J'ai commencé mes exemples par un service et je suivrais avec DAO et ainsi de suite. Aller au fond des niveaux de dépendance. Plus ou moins, cela pourrait être décrit comme une stratégie ascendante . Cependant, pour les modifications / refactorisations mineures ( comme celle exposée dans l'exemple de tournée ), un processus ascendant faciliterait la tâche. Parce que la portée des changements est faible.

Enfin, il vous appartient de supprimer le code obsolète et de rediriger les anciennes dépendances vers la nouvelle.

Supprimez également les tests obsolètes et le travail est terminé. Si vous avez mis à niveau l'ancienne solution avec ses tests, vous pouvez vérifier et comparer à tout moment.

En conséquence de tant de travail, vous avez testé, validé et mis à jour le code existant. Et un nouveau code, testé, validé et prêt à être mis en version.


3

tl; dr N'écrivez pas de tests unitaires. Rédigez des tests à un niveau plus approprié.


Compte tenu de votre définition de travail du refactoring:

vous ne changez pas ce que votre logiciel fait, vous changez comment il le fait

le spectre est très large. D'un côté, il y a un changement autonome à une méthode particulière, qui utilise peut-être un algorithme plus efficace. À l'autre extrémité est le portage dans une autre langue.

Quel que soit le niveau de refactorisation / refonte utilisé, il est important que les tests fonctionnent à ce niveau ou à un niveau supérieur.

Les tests automatisés sont souvent classés par niveau comme suit:

  • Tests unitaires - Composants individuels (classes, méthodes)

  • Tests d'intégration - Interactions entre composants

  • Tests du système - L'application complète

Ecrire le niveau de test qui peut supporter la refactorisation essentiellement intacte.

Pense:

Quel comportement essentiel et visible du public l'application aura-t-elle à la fois avant et après la refactorisation? Comment puis-je tester cette chose fonctionne toujours la même chose?


2

Ne perdez pas de temps à écrire des tests qui se rattachent à des points où vous pouvez anticiper que l’interface va changer de manière non triviale. C'est souvent le signe que vous essayez de tester des classes qui sont de nature «collaborative» - leur valeur ne réside pas dans ce qu'elles font elles-mêmes, mais dans la façon dont elles interagissent avec un certain nombre de classes étroitement liées pour produire un comportement précieux . C'est ce comportement que vous souhaitez tester, ce qui signifie que vous souhaitez effectuer des tests à un niveau supérieur. Tester à un niveau inférieur à ce niveau nécessite souvent beaucoup de moqueries laides, et les tests qui en résultent peuvent être plus un frein au développement qu'une aide à la défense du comportement.

Ne vous attardez pas trop sur le fait que vous fassiez un refactor, une refonte, etc. Vous pouvez apporter des modifications qui, au niveau inférieur, constituent une refonte d'un certain nombre de composants, mais à un niveau d'intégration supérieur, il s'agit simplement d'un refactor. Le but est de définir clairement le comportement qui vous est utile et de le défendre au fur et à mesure.

Il serait peut-être utile de prendre en compte vos tests lors de la rédaction de vos tests. Pourrais-je facilement décrire à un responsable de l'assurance qualité, un propriétaire de produit ou un utilisateur, ce que ce test teste en réalité? S'il semble que la description du test soit trop ésotérique et technique, vous effectuez peut-être des tests au mauvais niveau. Testez à des points / niveaux qui «ont du sens», et ne gommez pas votre code avec des tests à tous les niveaux.


Toujours intéressé par les raisons des votes négatifs!
topo Réintégrer Monica

1

Votre première tâche consiste à essayer de trouver la "signature de méthode idéale" pour vos tests. Efforcez-vous d'en faire une fonction pure . Cela devrait être indépendant du code qui est actuellement à l’essai; c'est une petite couche d'adaptateur. Écrivez votre code sur cette couche d’adaptateur. Désormais, lorsque vous refactorisez votre code, il vous suffit de changer le calque de l'adaptateur. Voici un exemple simple:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

Les tests sont bons, mais le code sous test a une mauvaise API. Je peux le refactoriser sans changer les tests en mettant simplement à jour ma couche d'adaptateur:

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

Cet exemple semble être une chose assez évidente à faire selon le principe «Ne vous répétez pas», mais cela peut ne pas être aussi évident dans d'autres cas. L'avantage va au-delà de DRY - le véritable avantage est le découplage des tests du code sous test.

Bien sûr, cette technique peut ne pas être recommandée dans toutes les situations. Par exemple, il n'y aurait aucune raison d'écrire des adaptateurs pour les POCO / POJO car ils ne disposent pas vraiment d'une API susceptible de changer indépendamment du code de test. De même, si vous écrivez un petit nombre de tests, une couche d’adaptateur relativement grande serait probablement un effort inutile.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.