web-dev-qa-db-fra.com

Comment écrire des tests unitaires avant refactoring?

J'ai lu quelques réponses à des questions dans le même sens, telles que "Comment faites-vous pour que vos tests unitaires fonctionnent lors de la refactorisation?". Dans mon cas, le scénario est légèrement différent en ce sens que j'ai reçu un projet à réviser et à mettre en conformité avec certaines normes que nous avons, actuellement il n'y a pas du tout de tests pour le projet!

J'ai identifié un certain nombre de choses qui, à mon avis, auraient pu être mieux faites, comme NE PAS mélanger le 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, il me semble que lorsque je refactorise, ces tests se cassent car je change où certaines logiques sont effectuées et 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é d'écrire les tests autour du code refactorisé mais je suis conscient qu'il existe un risque que je refactorise incorrectement des choses qui pourraient changer le comportement souhaité.

Que ce soit un refactor ou une refonte, je suis heureux que ma compréhension de ces termes soit corrigée, actuellement je travaille sur la définition suivante de 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 fait le logiciel, je changerais comment/où il le fait.

De même, je peux voir l'argument selon lequel si je change la signature de méthodes qui pourraient être considérées 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 (refactorisé/remanié quoi que ce 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();
   }
}
55
PDStat

Vous recherchez des tests qui vérifient régressions. c'est-à-dire briser un comportement existant. Je commencerais par identifier à quel niveau ce comportement restera le même, et que l'interface pilotant ce comportement restera le même, et commencerais à mettre des tests à ce point.

Vous avez maintenant quelques tests qui affirmeront que quoi que vous fassiez ci-dessous ce niveau, votre comportement reste le même.

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

56
Brian Agnew

La pratique recommandée est de commencer par écrire des "tests de précision" qui testent le comportement actuel du code, y compris éventuellement des bogues, mais sans vous obliger à descendre dans la folie de discerner si un comportement donné qui viole les documents d'exigences est un bogue, solution de contournement pour quelque chose que vous ne connaissez pas ou représente une modification non documentée des exigences.

Il est plus logique que ces tests de précision soient à un niveau élevé, c'est-à-dire l'intégration plutôt que des tests unitaires, afin qu'ils continuent de fonctionner lorsque vous commencez la refactorisation.

Mais certains refactorings peuvent être nécessaires pour rendre le code testable - faites juste attention à vous en tenir aux refactorings "sûrs". Par exemple, dans presque tous les cas, les méthodes privées peuvent être rendues publiques sans rien casser.

40

Je suggère - si vous ne l'avez pas déjà fait - de lire les deux Travailler efficacement avec le code hérité ainsi que Refactoring - Améliorer la conception du code existant .

[..] Le problème qui m'apparaît est que lorsque je refactorise ces tests, ils se cassent car je change où une certaine logique est faite et les tests seront é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, changez la structure de votre code, puis ajustez également la structure de test . Cela vous indiquera directement si votre nouvelle structure est réellement meilleure que l'ancienne, car si elle l'est, les tests ajustés seront plus faciles à écrire ( et donc changer les tests devrait être relativement simple, réduisant le risque de voir un bug nouvellement introduit passer les tests).

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

12
Daniel Jour

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 se moquer de DocumentDao, se moquer 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 MyDocumentService vers DocumentDao sans interrompre les tests. Les tests montreront que la fonctionnalité est la même (pour autant que vous l'ayez testée).

5
Winston Ewert

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 fait votre logiciel, vous changez comment il le fait

il y a très large spectre. À une extrémité se trouve un changement autonome d'une méthode particulière, peut-être en utilisant un algorithme plus efficace. À l'autre extrémité, le portage vers une autre langue.

Quel que soit le niveau de refactorisation/refonte en cours, il est important d'avoir des tests qui fonctionnent à ce niveau ou plus.

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

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

  • Tests d'intégration - Interactions entre composants

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

Écrivez 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 le refactoring? Comment puis-je tester cette chose fonctionne toujours la même chose?

3
Paul Draper

Comme vous le dites, si vous changez le comportement, c'est une transformation et non un refactor. À quel niveau vous modifiez le comportement est ce qui fait la différence.

S'il n'y a pas de tests formels au plus haut niveau, essayez de trouver un ensemble d'exigences que les clients (code d'appel ou humains) doivent rester les mêmes après votre refonte pour que votre code soit considéré comme fonctionnel. 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 changeants, je vous suggère de jeter un œil à Detroit (classique) vs London (mockist) TDD. Martin Fowler en parle dans son excellent article Les simulacres ne sont pas des talons mais beaucoup de gens ont des opinions. Si vous commencez au plus haut niveau, où vos externes ne peuvent pas changer et que vous descendez, les exigences devraient rester assez stables jusqu'à ce que vous atteigniez un niveau qui doit vraiment changer.

Sans aucun test, cela va être difficile, et vous voudrez peut-être envisager d'exécuter les clients via des chemins de code doubles (et d'enregistrer les différences) jusqu'à ce que vous soyez sûr que votre nouveau code fait exactement ce qu'il doit faire.

3
Encaitar

Voici mon approche. Il a un coût en temps car c'est un refactor-test en 4 phases.

Ce que je vais exposer peut mieux correspondre à des 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 regrouper dans une interface. Par exemple. S'il existe déjà, utilisez-le au lieu d'en définir 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. Aucune modification majeure n'a été apportée, nous avons respecté le contrat actuel et les comportements restent intacts.

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 acharné. Pour configurer une suite de tests. Nous devons définir autant de cas que possible: cas réussis et aussi cas d'erreur. Ces derniers sont pour le bien de la qualité du résultat.

Maintenant, 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 a l'air 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 il établira le point de référence pour les comparaisons futures.

Remarque: En raison d'aucune modification majeure n'a été apportée et le comportement reste intact. Je suggère de faire un tag ici dans le SCM. Le tag ou la branche n'a pas d'importance. Faites juste une version.

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

3. Refactoring

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

Nouvelle classe continue d'implémenter DocumentService . Ensuite, allez refactoriser getAllDocuments () . (Commençons par un. Refacteurs de broches)

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

Il est important de ne pas interrompre/modifier l'implémentation 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. Pour 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);
    } 
 }

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

Peut ne pas avoir trop de sens pour comparer deux collections de cette manière, mais serait valable pour tout autre type d'objet (pojos, entités de modèle de données, DTO, Wrappers, types natifs ...)

Notes

Je n'oserais pas dire comment faire des tests unitaires ou comment utiliser des bibliothèques factices. Je n'ose pas non plus dire comment vous devez faire le refactor. Ce que je voulais faire, c'est proposer une stratégie globale. Comment le faire avancer dépend de vous. Vous savez exactement comment est le code, sa complexité et si une telle stratégie mérite d'être essayée. Des faits comme le temps et les ressources comptent ici. Importe également ce que vous attendez de ces tests à l'avenir.

J'ai commencé mes exemples par un service et je suivrais avec DAO et ainsi de suite. Approfondir les niveaux de dépendance. Plus ou moins, cela pourrait être décrit comme une stratégie en haut. Cependant, pour des changements/refacteurs mineurs ( comme celui exposé dans l'exemple de tour ), un bottom up ferait la tâche plus facilement. Parce que la portée des changements est faible.

Enfin, c'est à vous 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 versionné l'ancienne solution avec ses tests, vous pouvez vous vérifier et vous comparer à tout moment.

À la suite de tant de travail, vous avez testé, validé et versionné le code hérité. Et un nouveau code, testé, validé et prêt à être versionné.

3
Laiv

Ne perdez pas de temps à écrire des tests qui se connectent à des points où vous pouvez vous attendre à ce que l'interface change de manière non triviale. C'est souvent le signe que vous essayez de tester des classes de nature "collaborative" - dont la valeur n'est 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 que le comportement que vous souhaitez tester, ce qui signifie que vous voulez tester à un niveau supérieur. Les tests en dessous de ce niveau nécessitent souvent beaucoup de moqueries moches, et les tests qui en résultent peuvent être plus un frein au développement qu'une aide à la défense du comportement.

Ne soyez pas trop accroché à savoir si vous faites un refactoriseur, une refonte ou autre. 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 remaniement. Le but est d'être clair sur les comportements qui ont de la valeur pour vous et de les défendre au fur et à mesure.

Il pourrait être utile de prendre en compte lors de la rédaction de vos tests - pourrais-je facilement décrire à un QA, un propriétaire de produit ou un utilisateur, ce que ce test teste réellement? S'il semble que décrire le test serait trop ésotérique et technique, vous testez peut-être au mauvais niveau. Testez aux points/niveaux qui "ont du sens", et ne gommez pas votre code avec des tests à tous les niveaux.

2

Votre première tâche est d'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 actuellement testé; c'est une petite couche d'adaptateur. Écrivez votre code dans cette couche d'adaptateur. Désormais, lorsque vous refactorisez votre code, il vous suffit de modifier la couche d'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 testé a une mauvaise API. Je peux le refactoriser sans changer les tests simplement en mettant à 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 Don't Repeat Yourself, mais il 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 POCO/POJO car ils n'ont pas vraiment d'API qui pourrait changer indépendamment du code de test. De plus, si vous écrivez un petit nombre de tests, une couche d'adaptateur relativement grande serait probablement un effort inutile.

1
default.kramer