Principe de la pâte feuilletée

Dédié à tous les intrépides sur le chemin du déni à la condamnation ...


image



Il y a une opinion juste parmi les développeurs que si un programmeur ne couvre pas le code avec des tests, il ne comprend tout simplement pas pourquoi ils sont nécessaires et comment les préparer. Il est difficile d'être en désaccord avec cela quand on comprend déjà de quoi il s'agit. Mais comment parvenir à cette précieuse compréhension?



Ce n'est pas censé être ...



Il se trouve que souvent les choses les plus évidentes n'ont pas de description claire parmi les tonnes d'informations utiles sur le réseau mondial. Une sorte de candidat régulier décide de traiter la question urgente «qu'est-ce que les tests unitaires» et tombe sur de nombreux exemples similaires, qui sont copiés d'article en article comme une copie:



"Nous avons une méthode qui calcule la somme des nombres"



public Integer sum (Integer a, Integer b) {

return a + b

}



"vous pouvez écrire un test pour cette méthode"



Tester

public void testGoodOne () {

assertThat (somme (2,2), est (4));

}


Ce n'est pas une blague, c'est un exemple simplifié d'un article typique sur la technologie des tests unitaires, où au début et à la fin il y a des phrases générales sur les avantages et la nécessité, et au milieu c'est ...



Voyant cela, et le relisant deux fois pour des raisons de foi, le candidat s'exclame: «Quelle cruelle illusion ? .. "Après tout, dans son code, il n'y a pratiquement pas de méthodes qui reçoivent tout ce dont elles ont besoin à travers des arguments, et qui donnent ensuite un résultat sans ambiguïté pour elles. Ce sont des méthodes utilitaires typiques et ne changent guère. Mais qu'en est-il des procédures complexes, des dépendances injectées et des méthodes sans retour de valeurs? Là, cette approche n'est pas applicable à partir du mot «du tout».



Si à ce stade le candidat têtu ne fait pas signe de la main et plonge plus loin, il découvre bientôt que les MOC sont utilisés pour les dépendances, pour les méthodes desquelles un comportement conditionnel est défini, en fait un stub. Ici, le candidat peut complètement souffler son esprit, s'il n'y a pas d'intermédiaire / senior gentil et patient qui soit prêt et capable de tout expliquer ... Sinon, le candidat à la vérité perd complètement le sens de «ce que sont les tests unitaires», car la plupart de la méthode testée s'avère être une sorte de fiction simulée , et ce qui est testé dans ce cas n'est pas clair. De plus, il n'est pas clair comment organiser cela pour une grande application multicouche et pourquoi cela est nécessaire. Ainsi, au mieux, la question est reportée à des temps meilleurs, au pire - elle se cache dans une boîte de choses maudites.



Le plus ennuyeux est que la technologie de couverture de test est élémentairement simple et accessible à tous, et ses avantages sont si évidents que toutes les excuses semblent naïves pour les personnes bien informées. Mais pour le comprendre, le débutant manque d'une très petite essence élémentaire, comme le basculement d'un interrupteur.



Mission clé



Pour commencer, je propose de formuler en quelques mots la fonction clé (mission) des tests unitaires et le gain clé. Il existe différentes options pittoresques ici, mais je propose de considérer celle-ci: La



fonction clé des tests unitaires est de capturer le comportement attendu du système.



et celui-ci: le



principal avantage des tests unitaires est la possibilité de «lancer» toutes les fonctionnalités de l'application en quelques secondes.



Je recommande de s'en souvenir pour les interviews et je vais vous expliquer un peu. Toute fonctionnalité implique des règles d'utilisation et des résultats. Ces exigences proviennent de l'entreprise, via l'analyse des systèmes, et sont implémentées dans le code. Mais le code évolue constamment, de nouvelles exigences et améliorations arrivent, qui peuvent changer imperceptiblement et de manière inattendue quelque chose dans la fonctionnalité finale. C'est là que les tests unitaires montent la garde, qui fixent les règles approuvées selon lesquelles le système doit fonctionner! Les tests enregistrent un scénario qui est important pour l'entreprise, et si après la prochaine révision le test échoue, alors quelque chose manque: soit le développeur ou l'analyste s'est trompé, soit les nouvelles exigences contredisent celles existantes et doivent être clarifiées, etc. Le plus important est que la «surprise» ne passe pas.



Un test unitaire simple et standard a permis de détecter très tôt un comportement inattendu et probablement indésirable du système. Pendant ce temps, le système se développe et se développe, la probabilité de manquer ses détails augmente également, et seuls les scripts de test unitaire sont capables de tout se souvenir et d'éviter des départs inaperçus dans le temps. C'est très pratique et fiable, et le principal avantage est la rapidité. L'application n'a même pas besoin de démarrer et de parcourir des centaines de ses champs, formulaires ou boutons, vous devez exécuter des tests et obtenir une disponibilité totale ou un bogue en quelques secondes.



Alors rappelons-nous: corrigez le comportement attendu sous forme de scripts de test unitaire, et «exécutez» instantanément l'application sans la lancer. Il s'agit de la valeur absolue que les tests unitaires peuvent atteindre.



Mais bon sang, comment?



Passons à la partie amusante. Les applications modernes se débarrassent activement de la monolithicité. Microservices, modules, «couches» - les principes de base de l'organisation du code de travail, vous permettant d'atteindre l'indépendance, la facilité de réutilisation, d'échange et de transfert vers les systèmes, etc. La superposition et l'injection de dépendances sont essentielles dans notre sujet.



Considérez les couches d'une application Web typique: contrôleurs, services, référentiels, etc. De plus, des utilitaires, des façades, des modèles et des couches DTO sont utilisés. Les deux derniers ne doivent pas contenir de fonctionnalité, c'est-à-dire méthodes autres que les accesseurs (getters / setters), vous n'avez donc pas besoin de les couvrir de tests. Nous considérerons le reste des couches comme des cibles de couverture.



Peu importe la saveur de cette comparaison, l'application ne peut pas être comparée à un gâteau feuilleté car ces couches sont imbriquées les unes dans les autres, comme des dépendances:



  • le contrôleur implémente le (s) service (s), qu'il appelle pour le résultat
  • le service s'injecte des référentiels (DAO) en lui-même, peut injecter des composants utilitaires
  • la façade est conçue pour combiner le travail de nombreux services ou composants, respectivement, elle les intègre


L'idée principale de tester tout cela dans toute l'application: couvrir chaque couche indépendamment des autres couches. Une référence à l'indépendance et à d'autres caractéristiques anti-monolithiques. Ceux. si un référentiel est intégré au service testé, cet «invité» est simulé dans le cadre du test du service, mais il est déjà testé personnellement honnêtement dans le cadre du test du référentiel. Ainsi, des tests sont créés pour chaque élément de chaque couche, personne n'est oublié - tout est en affaires.



Principe de la pâte feuilletée



Passons aux exemples, une simple application Java Spring Boot, le code sera élémentaire, donc l'essence est facile à comprendre et applicable de la même manière à d'autres langages / frameworks modernes. L'application aura une tâche simple - multipliez le nombre par 3, c'est-à-dire triple, mais en même temps, nous allons créer une application multicouche avec injection de dépendance et couverture en couches de la tête aux pieds.



image



La structure contient des packages pour trois couches: contrôleur, service, dépôt. La structure des tests est similaire.

L'application fonctionnera comme ceci:



  1. depuis le front-end, une requête GET arrive au contrôleur avec l'identifiant du nombre à tripler.
  2. le contrôleur demande le résultat de sa dépendance de service
  3. le service demande des données à sa dépendance - référentiel, multiplie et renvoie le résultat au contrôleur
  4. le contrôleur complète le résultat et retourne au front-end


Commençons par le contrôleur:



@RestController
@RequiredArgsConstructor
public class SomeController {
   private final SomeService someService; // dependency injection

   static final String RESP_PREFIX = ": ";

   static final String PATH_GET_TRIPLE = "/triple/{numberId}";

   @GetMapping(path = PATH_GET_TRIPLE) // mapping method to GET with url=path
   public ResponseEntity<String> triple(@PathVariable(name = "numberId") int numberId) {
       int res = someService.tripleMethod(numberId);   // dependency call
       String resp = RESP_PREFIX + res;                // own logic
       return ResponseEntity.ok().body(resp);
   }
}
      
      





Un contrôleur de repos typique a une injection de dépendance someService. La méthode triple est configurée pour une requête GET à l'URL "/ triple / {numberId}", où l'identifiant numérique est passé dans la variable de chemin. La méthode elle-même peut être divisée en deux composants principaux:



  • accéder à une dépendance - demander des données de l'extérieur ou appeler une procédure sans résultat
  • propre logique - travailler avec des données existantes


Considérez un service:



@Service
@RequiredArgsConstructor
public class SomeService {
   private final SomeRepository someRepository; // dependency injection

   public int tripleMethod(int numberId) {
       Integer fromDB = someRepository.findOne(numberId);  // dependency call
       int res = fromDB * 3;                               // own logic
       return res;
   }
}
      
      





Voici une situation similaire: injecter la dépendance someRepository, et la méthode consiste à accéder à la dépendance et à sa propre logique.



Enfin - le référentiel, pour plus de simplicité, fait sans base de données:



@Repository
public class SomeRepository {
   public Integer findOne(Integer id){
       return id;
   }
}
      
      





La méthode conditionnelle findOne recherche supposément dans la base de données une valeur par identificateur, mais renvoie simplement le même entier. Cela n'affecte pas l'essence de notre exemple.



Si vous exécutez notre application, puis par l'URL configurée, vous pouvez voir:







Fonctionne! En couches! En production ...



Oh oui, des tests ...



Un peu sur l'essence. Écrire des tests est aussi un processus créatif! Par conséquent, l'excuse «Je suis un développeur, pas un testeur» est totalement inappropriée. Un bon test, comme une bonne fonctionnalité, demande de l'ingéniosité et de la beauté. Mais tout d'abord, il est nécessaire de déterminer la structure de base du test.



La classe testing contient des méthodes qui testent les méthodes de la classe cible. Le minimum que chaque méthode de test doit contenir est un appel à la méthode correspondante de la classe cible, conditionnellement comme ceci:



@Test
    void someMethod_test() {
        // prepare...

        int res = someService.someMethod(); 
        
        // check...
    }
      
      





Ce défi peut être entouré de préparation et de révision. Préparer les données, y compris les arguments d'entrée et décrire le comportement des simulacres. La validation des résultats est généralement une comparaison avec la valeur attendue, pensez-vous à capturer le comportement attendu? Au total, un test est un scénario qui simule une situation et enregistre qu'il a réussi comme prévu et a renvoyé les résultats attendus.



En utilisant le contrôleur comme exemple, essayons de décrire en détail l'algorithme de base pour écrire un test. Tout d'abord, la méthode cible du contrôleur prend un paramètre int numberId, ajoutons-le à notre script:



int numberId = 42; // input path variable
      
      





Le même numberId est transmis en transit à l'entrée de la méthode de service, et il est maintenant temps de fournir le service simulé:



@MockBean
private SomeService someService;
      
      





Le code de méthode du contrôleur fonctionne avec le résultat reçu du service, nous simulons ce résultat, ainsi qu'un appel qui le renvoie:




int serviceRes = numberId*3; // result from mock someService
// prepare someService.tripleMethod behavior
when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

      
      





Cette entrée signifie: "lorsque someService.tripleMethod est appelé avec un argument égal à numberId, renvoie la valeur de serviceRes."



En outre, cet enregistrement capture le fait que cette méthode de service doit être appelée, ce qui est un point important. Il arrive que vous deviez corriger un appel à une procédure sans résultat, puis une notation différente est utilisée, par convention - "ne rien faire quand ...":




Mockito.doNothing().when(someService).someMethod(eq(someParam));

      
      





Encore une fois, voici juste une imitation du travail de someService, des tests honnêtes avec une correction détaillée du comportement de someService seront implémentés séparément. De plus, il n'a même pas d'importance ici que la valeur triplât, si on écrit




int serviceRes = numberId*5; 
      
      





cela ne cassera pas le script actuel, car ce n'est pas le comportement de someService qui est capturé ici, mais le comportement du contrôleur qui prend le résultat de someService pour acquis. C'est tout à fait logique, car la classe cible ne peut pas être responsable du comportement de la dépendance injectée, mais doit lui faire confiance.



Nous avons donc défini le comportement de la maquette dans notre script.Par conséquent, lors de l'exécution du test, lorsque dans l'appel à la méthode cible il s'agit d'une maquette, elle retournera ce qui a été demandé - serviceRes, puis le propre code du contrôleur fonctionnera avec cette valeur.



Ensuite, nous appelons la méthode cible dans le script. La méthode du contrôleur a une particularité - elle n'est pas appelée explicitement dans le code, mais est liée via la méthode HTTP GET et l'URL, donc dans les tests, elle est appelée via un client de test spécial. Au printemps, c'est MockMvc, dans d'autres frameworks, il y a des analogues, par exemple, WebTestCase.createClient dans Symfony. Donc, en outre, il est simple d'exécuter la méthode du contrôleur via le mappage par GET et URL.




       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

      
      





Dans le même temps, il est également vérifié qu'une telle cartographie existe. Si l'appel réussit, il s'agit de vérifier et de corriger les résultats. Par exemple, vous pouvez déterminer combien de fois la méthode fictive a été appelée:




// check of calling
Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));

      
      





Dans notre cas, c'est redondant, car nous avons déjà fixé son seul appel via when, mais parfois cette méthode est appropriée.



Et maintenant, l'essentiel - nous vérifions le comportement du propre code du contrôleur:




// check of result
assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());

      
      





Ici, nous avons fixé ce dont la méthode elle-même est responsable - que le résultat reçu de someService est concaténé avec le préfixe du contrôleur, et c'est cette ligne qui entre dans le corps de la réponse. Au fait, vous pouvez voir de vos propres yeux le contenu de Body si vous décommentez la ligne




//.andDo(MockMvcResultHandlers.print())

      
      





mais généralement, cette impression sur la console est utilisée uniquement comme une aide au débogage.



Ainsi, nous avons une méthode de test dans la classe de test du contrôleur:




@WebMvcTest(SomeController.class)
class SomeControllerTest {
   @MockBean
   private SomeService someService;

   @Autowired
   private MockMvc mockMvc;

   @Test
   void triple() throws Exception {
       int numberId = 42; // input path variable
       int serviceRes = numberId*3; // result from mock someService
       // prepare someService.tripleMethod behavior
       when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);

       //// mockMvc.perform
       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);

       MvcResult mvcResult = mockMvc.perform(requestConfig)
           .andExpect(status().isOk())
           //.andDo(MockMvcResultHandlers.print())
           .andReturn()
       ;//// mockMvc.perform

       // check of calling
       Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));
       // check of result
       assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());
   }
}
      
      





Il est maintenant temps de tester honnêtement la méthode someService.tripleMethod, où il existe également un appel de dépendance et votre propre code. Préparez un argument d'entrée arbitraire et simulez le comportement de la dépendance someRepository:




int numberId = 42;
when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

      
      





Traduction: "lorsque someRepository.findOne est appelé avec un argument égal à numberId, renvoie le même argument." Une situation similaire - ici nous ne vérifions pas la logique de la dépendance, mais nous la croyons sur parole. Nous capturons uniquement l'appel à la dépendance dans cette méthode. Le principe ici est la propre logique du service, son domaine de responsabilité:




assertEquals(numberId*3, res);

      
      





Nous corrigeons que la valeur reçue du référentiel doit être triplée par la propre logique de la méthode. Maintenant, ce test garde cette exigence:




@ExtendWith(MockitoExtension.class)
class SomeServiceTest {
   @Mock
   private SomeRepository someRepository; // ,  

   @InjectMocks
   private SomeService someService; //   ,  

   @Test
   void tripleMethod() {
       int numberId = 42;
       when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());

       int res = someService.tripleMethod(numberId);

       assertEquals(numberId*3, res);
   }
}
      
      





Comme notre référentiel est conditionnellement un jouet, le test s'est avéré approprié:




class SomeRepositoryTest {
   // no dependency injection
   private final SomeRepository someRepository = new SomeRepository();

   @Test
   void findOne() {
       int id = 777;
       Integer fromDB = someRepository.findOne(id);
       assertEquals(id, fromDB);
   }
}
      
      





Cependant, même ici, tout le squelette est en place: préparation, invocation et vérification. Ainsi, le travail correct de someRepository.findOne est corrigé.



Un vrai référentiel nécessite des tests avec élever la base de données en mémoire ou dans un conteneur de test, migrer la structure et les données, parfois insérer des enregistrements de test. Il s'agit souvent de la couche de test la plus longue, mais non moins importante car la migration réussie, l'enregistrement des modèles, la sélection correcte, etc. sont enregistrés. L'organisation des tests de bases de données dépasse le cadre de cet article, mais elle est décrite avec précision dans les manuels. Il n'y a pas d'injection de dépendances dans le référentiel et n'est pas nécessaire, sa tâche est de travailler avec la base de données. Dans notre cas, ce serait un test avec une sauvegarde préliminaire de l'enregistrement dans la base de données et une recherche ultérieure par identifiant.



Ainsi, nous avons atteint une couverture complète de toute la chaîne fonctionnelle. Chaque test est responsable de l'exécution de son propre code et capture les appels à toutes les dépendances. Le test d'une application ne nécessite pas de l'exécuter avec une augmentation complète du contexte, ce qui est difficile et prend du temps. Le maintien de la fonctionnalité avec des tests unitaires rapides et faciles crée un environnement de travail confortable et fiable.



De plus, les tests améliorent la qualité du code. Dans le cadre de tests indépendants en couches, vous devez souvent repenser la façon dont vous organisez votre code. Par exemple, une méthode a d'abord été créée dans le service, elle n'est pas petite, elle contient à la fois son propre code et des simulations, et, par exemple, cela n'a pas de sens de la diviser, elle est couverte par le ou les tests dans leur intégralité - toutes les préparations et vérifications sont définies. Ensuite, quelqu'un décide d'ajouter une deuxième méthode au service, qui appelle la première méthode. Cela semble autrefois une situation courante, mais quand il s'agit de couverture avec un test, quelque chose ne s'additionne pas ... Pour la deuxième méthode, vous devrez décrire le deuxième scénario et dupliquer le premier scénario de préparation? Après tout, cela ne fonctionnera pas pour verrouiller la première méthode de la classe testée elle-même.



Peut-être, dans ce cas, convient-il de réfléchir à une organisation différente du code. Il existe deux approches opposées:



  • déplacez la première méthode dans un composant utilitaire qui est injecté en tant que dépendance dans le service.
  • déplacer la deuxième méthode dans une façade de service qui combine différentes méthodes du service embarqué voire plusieurs services.


Ces deux options s'intègrent bien dans le principe des «couches» et sont testées de manière pratique avec la simulation des dépendances. La beauté est que chaque couche est responsable de son propre travail et, ensemble, elles créent un cadre solide pour l'invulnérabilité de l'ensemble du système.



Sur la piste ...



Question d'entretien: combien de fois un développeur doit-il exécuter des tests dans un ticket? Autant que vous le souhaitez, mais au moins deux fois:



  • avant de commencer le travail, pour s'assurer que tout va bien, et ne pas découvrir plus tard ce qui a déjà été cassé, et pas toi
  • à la fin des travaux


Alors pourquoi écrire des tests? Ensuite, qu'il ne vaut pas la peine d'essayer de se souvenir et de tout prévoir dans une application vaste et complexe, il faut le confier à l'automatisation. Un développeur qui ne possède pas d'auto-test n'est pas prêt à participer à un grand projet, toute personne interrogée le révélera immédiatement.



Par conséquent, je recommande de développer ces compétences si vous voulez avoir droit à des salaires élevés. Vous pouvez commencer cette pratique passionnante avec des choses de base, à savoir, dans le cadre de votre cadre préféré, apprendre à tester:



  • composants avec dépendances intégrées, techniques de simulation
  • contrôleurs, parce que il y a des nuances d'appeler le point final
  • DAO, référentiels, y compris l'élévation de la base de test et les migrations


J'espère que ce concept de "pâte feuilletée" a permis de comprendre la technique de test d'applications complexes et de ressentir à quel point l'outil flexible et puissant nous est présenté pour le travail. Bien sûr, meilleur est l'outil, plus il nécessite un travail habile.



Profitez de votre travail et de votre grande compétence!



L'exemple de code est disponible sur le lien sur github.com: https://github.com/denisorlov/examples/tree/main/unittestidea



All Articles