Comment enfin commencer à écrire des tests et ne pas le regretter



Venant à un nouveau projet, je rencontre régulièrement l'une des situations suivantes:

  1. Il n'y a aucun test du tout.
  2. Il y a peu de tests, ils sont rarement écrits et ne s'exécutent pas de façon continue.
  3. Des tests sont présents et inclus dans CI (intégration continue), mais font plus de mal que de bien.

Malheureusement, c'est ce dernier scénario qui conduit souvent à de sérieuses tentatives pour commencer à mettre en œuvre des tests en l'absence de compétences appropriées.

Que peut-on faire pour changer la situation actuelle? L'idée d'utiliser des tests n'est pas nouvelle. Dans le même temps, la plupart des tutoriels ressemblent à la célèbre image sur la façon de dessiner un hibou: connectez JUnit, écrivez le premier test, utilisez la première maquette - et c'est parti! De tels articles ne répondent pas aux questions sur les tests qui doivent être écrits, à quoi cela vaut la peine de prêter attention et comment vivre avec tout cela. De là est née l'idée de cet article. J'ai essayé de résumer brièvement mon expérience dans la mise en œuvre de tests dans différents projets afin de faciliter ce chemin pour tout le monde.


Il y a plus qu'assez d'articles introductifs sur ce sujet, donc nous ne nous répéterons pas et n'essaierons pas d'aller de l'autre côté. Dans la première partie, nous démystifierons le mythe selon lequel les tests entraînent exclusivement des coûts supplémentaires. Il sera montré comment la création de tests de qualité peut à son tour accélérer le processus de développement. Ensuite, sur l'exemple d'un petit projet, les principes et règles de base à suivre pour réaliser cet avantage seront examinés. Enfin, dans la dernière section, des recommandations spécifiques de mise en œuvre seront données: comment éviter les problèmes typiques lorsque les tests commencent, au contraire, ralentir considérablement le développement.

Puisque ma spécialisation principale est le backend Java, la pile technologique suivante sera utilisée dans les exemples: Java, JUnit, H2, Mockito, Spring, Hibernate. Dans le même temps, une partie importante de l'article est consacrée aux problèmes généraux de test et les conseils qui s'y trouvent s'appliquent à un éventail beaucoup plus large de tâches.

Attention cependant! Les tests sont très addictifs: une fois que vous avez appris à les utiliser, vous ne pouvez plus vous en passer.


Tests vs vitesse de développement


Les principales questions qui se posent lors de l'examen de la mise en œuvre des tests: combien de temps faut-il pour écrire des tests et quels avantages cela aura-t-il? Les tests, comme toute autre technologie, nécessiteront de sérieux efforts de développement et de mise en œuvre, de sorte qu'au début, aucun avantage significatif ne devrait être attendu. Quant aux coûts de temps, ils dépendent fortement de l'équipe particulière. Cependant, moins de 20-30% des coûts supplémentaires de codage ne doivent pas être calculés exactement. Moins n'est tout simplement pas suffisant pour obtenir au moins un résultat. L'attente de retours instantanés est souvent la principale raison de limiter cette activité avant même que les tests ne deviennent utiles.

Mais de quelle efficacité parle-t-on? Laissons tomber les paroles sur les difficultés de mise en œuvre et voyons quelles opportunités spécifiques pour gagner du temps s'ouvre.

Exécuter du code en tout lieu


S'il n'y a pas de tests dans le projet, la seule façon de commencer est de soulever toute l'application. C'est bien si cela prend environ 15 à 20 secondes, mais les cas de grands projets dans lesquels un lancement complet peut prendre plusieurs minutes sont loin d'être rares. Qu'est-ce que cela signifie pour les développeurs? Une partie importante de leur temps de travail sera consacrée à ces courtes sessions d'attente, au cours desquelles il est impossible de continuer à travailler sur la tâche en cours, mais en même temps, il y a trop peu de temps pour passer à autre chose. Beaucoup ont rencontré au moins une fois de tels projets où le code écrit en une heure nécessite plusieurs heures de débogage en raison de longs redémarrages entre les corrections. Dans les tests, vous pouvez vous limiter à exécuter de petites parties de l'application, ce qui réduira considérablement le temps d'attente et augmentera la productivité du travail sur le code.

De plus, la possibilité d'exécuter du code n'importe où conduit à un débogage plus approfondi. Souvent, la vérification même des principaux cas d'utilisation positifs via l'interface de l'application nécessite beaucoup d'efforts et de temps. La présence de tests permet d'effectuer une vérification détaillée d'une fonctionnalité spécifique beaucoup plus facilement et plus rapidement.

Un autre avantage est la possibilité de réguler la taille de l'unité testée. Selon la complexité de la logique testée, vous pouvez vous limiter à une méthode, une classe, un groupe de classes qui implémentent certaines fonctionnalités, un service, etc., jusqu'à l'automatisation du test de l'application entière. Cette flexibilité vous permet de décharger des tests de haut niveau de nombreuses pièces car ils seront testés à des niveaux inférieurs.

Relancer les tests


Cet avantage est souvent cité comme l'essence de l'automatisation des tests, mais regardons-le sous un angle moins familier. Quelles nouvelles opportunités ouvre-t-il aux développeurs?

Premièrement, chaque nouveau développeur venu au projet pourra facilement exécuter des tests existants pour comprendre la logique de l'application à l'aide d'exemples. Malheureusement, son importance est largement sous-estimée. Dans les conditions modernes, les mêmes personnes travaillent rarement sur un projet pendant plus de 1 à 2 ans. Et comme les équipes sont composées de plusieurs personnes, l'apparition d'un nouveau participant tous les 2-3 mois est une situation typique pour des projets relativement importants. Des projets particulièrement difficiles subissent des changements de générations entières de développeurs! La possibilité de lancer facilement n'importe quelle partie de l'application et de regarder le comportement du système à certains moments simplifie l'immersion de nouveaux programmeurs dans le projet. De plus, une étude plus détaillée de la logique du code réduit le nombre d'erreurs commises en sortie et le temps de les déboguer à l'avenir.

Deuxièmement, la possibilité de vérifier facilement que l'application fonctionne correctement ouvre la voie à une refactorisation continue. Ce terme, malheureusement, est beaucoup moins populaire que CI. Cela signifie que la refactorisation peut et doit être effectuée chaque fois que le code est affiné. C'est précisément le respect régulier de la règle notoire du scoutisme «laissez le parking plus propre qu'avant votre arrivée» qui vous permet d'éviter la dégradation de la base du code et garantit au projet une vie longue et heureuse.

Débogage


Le débogage a déjà été mentionné dans les paragraphes précédents, mais ce point est si important qu'il mérite un examen plus approfondi. Malheureusement, il n'existe aucun moyen fiable de mesurer la relation entre le temps passé à écrire du code et à le déboguer, car ces processus sont pratiquement inséparables les uns des autres. Néanmoins, la présence de tests de qualité dans le projet réduit considérablement le temps de débogage, jusqu'à l'absence presque complète de la nécessité d'exécuter un débogueur.

Efficacité


Tout ce qui précède peut considérablement gagner du temps sur le débogage initial du code. Avec la bonne approche, seule celle-ci remboursera tous les coûts de développement supplémentaires. Les bonus de test restants - l'amélioration de la qualité de la base de code (un code mal conçu est difficile à tester), la réduction du nombre de défauts, la possibilité de vérifier l'exactitude du code à tout moment, etc. - deviendront presque gratuits.

De la théorie à la pratique


En termes, tout semble bon, mais passons aux choses sérieuses. Comme mentionné précédemment, il existe plus que suffisamment de documents sur la façon de procéder à la configuration initiale de l'environnement de test. Par conséquent, nous procédons immédiatement au projet terminé. Sources ici.

Défi


En tant que tâche de modèle, considérez un petit fragment du backend d'une boutique en ligne. Nous allons écrire une API typique pour travailler avec des produits: créer, recevoir, éditer. Ainsi que quelques méthodes pour travailler avec les clients: changer un «produit préféré» et calculer des points bonus pour une commande.

Modèle de domaine


Afin de ne pas surcharger l'exemple, nous nous limitons à un ensemble minimal de champs et de classes.



Le client a un nom d'utilisateur, un lien vers un produit préféré et un drapeau indiquant s'il est un client premium.

Produit (Produit) - nom, prix, remise et indicateur indiquant s'il est actuellement annoncé.

Structure du projet


La structure du code de projet principal est la suivante.



Les classes sont en couches:

  • Modèle - modèle de domaine du projet;
  • Jpa - référentiels pour travailler avec des bases de données basées sur Spring Data;
  • Service - logique métier de l'application;
  • Contrôleur - contrôleurs qui implémentent l'API.

Structure de test unitaire.



Les classes de test sont dans les mêmes packages que le code d'origine. De plus, un package avec des constructeurs pour la préparation des données de test a été créé, mais plus sur ce qui suit.

Il est pratique de séparer les tests unitaires et les tests d'intégration. Ils ont souvent des dépendances différentes, et pour un développement confortable, il devrait être possible d'exécuter l'un ou l'autre. Cela peut être réalisé de différentes manières: conventions de dénomination, modules, packages, sourceSets. Le choix d'une méthode spécifique est exclusivement une question de goût. Dans ce projet, les tests d'intégration se trouvent dans un sourceSet distinct - integrationTest.



Comme les tests unitaires, les classes avec des tests d'intégration sont dans les mêmes packages que le code d'origine. De plus, il existe des classes de base qui aident à éliminer la duplication de configuration et, si nécessaire, contiennent des méthodes universelles utiles.

Tests d'intégration


Il existe différentes approches pour déterminer les tests qui valent la peine de commencer. Si la logique testée n'est pas très compliquée, vous pouvez immédiatement passer à celles d'intégration (elles sont parfois appelées celles d'acceptation). Contrairement aux tests unitaires, ils s'assurent que l'application dans son ensemble fonctionne correctement.

L'architecture

Vous devez d'abord décider à quel niveau spécifique les contrôles d'intégration seront effectués. Spring Boot offre une liberté de choix totale: vous pouvez augmenter une partie du contexte, le contexte entier, et même un serveur à part entière, accessible à partir des tests. À mesure que la taille de l'application augmente, ce problème devient de plus en plus complexe. Souvent, vous devez écrire différents tests à différents niveaux.

Un bon point de départ serait des tests de contrôleur sans démarrer le serveur. Dans des applications relativement petites, il est tout à fait acceptable de relever tout le contexte, car par défaut, il est réutilisé entre les tests et initialisé une seule fois. Considérez les méthodes de base de la classe ProductController :

 @PostMapping("new") public Product createProduct(@RequestBody Product product) { return productService.createProduct(product); } @GetMapping("{productId}") public Product getProduct(@PathVariable("productId") long productId) { return productService.getProduct(productId); } @PostMapping("{productId}/edit") public void updateProduct(@PathVariable("productId") long productId, @RequestBody Product product) { productService.updateProduct(productId, product); } 

La question de la gestion des erreurs est laissée de côté. Supposons qu'il soit implémenté en externe sur la base d'une analyse des exceptions levées. Le code des méthodes est très simple, leur implémentation dans le ProductService pas beaucoup plus compliquée:

 @Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); } @Transactional public Product createProduct(Product product) { return productRepository.save(new Product(product)); } @Transactional public Product updateProduct(Long productId, Product product) { Product dbProduct = productRepository.findById(productId) .orElseThrow(() -> new DataNotFoundException("Product", productId)); dbProduct.setPrice(product.getPrice()); dbProduct.setDiscount(product.getDiscount()); dbProduct.setName(product.getName()); dbProduct.setIsAdvertised(product.isAdvertised()); return productRepository.save(dbProduct); } 

Le référentiel ProductRepository ne contient pas du tout ses propres méthodes:

 public interface ProductRepository extends JpaRepository<Product, Long> { } 

Tout indique que ces classes n'ont pas besoin de tests unitaires simplement parce que toute la chaîne peut être vérifiée facilement et efficacement par plusieurs tests d'intégration. La duplication des mêmes tests dans différents tests complique le débogage. En cas d'erreur dans le code, désormais plus un test ne tombera, mais 10-15 à la fois. Cela nécessitera à son tour une analyse plus approfondie. S'il n'y a pas de duplication, le seul test tombé est susceptible d'indiquer immédiatement une erreur.

La configuration

Pour plus de commodité, nous mettons en évidence la classe de base BaseControllerIT , qui contient la configuration Spring et quelques champs:

 @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @Transactional public abstract class BaseControllerIT { @Autowired protected ProductRepository productRepository; @Autowired protected CustomerRepository customerRepository; } 

Les référentiels sont déplacés vers la classe de base afin de ne pas encombrer les classes de test. Leur rôle est exclusivement auxiliaire: préparer les données et vérifier l'état de la base de données après le travail du contrôleur. Lorsque vous augmentez la taille de l'application, cela peut ne plus être pratique, mais pour commencer, cela convient parfaitement.

La configuration principale de Spring est définie par les lignes suivantes:

@SpringBootTest - utilisé pour définir le contexte de l'application. WebEnvironment.NONE signifie qu'aucun contexte Web n'a besoin d'être soulevé.

@Transactional - @Transactional tous les tests de classe dans une transaction avec restauration automatique pour enregistrer l'état de la base de données.

Structure de test

Passons à un ensemble minimaliste de tests pour la classe ProductControllerIT - ProductControllerIT .

 @Test public void createProduct_productSaved() { Product product = product("productName").price("1.01").discount("0.1").advertised(true).build(); Product createdProduct = productController.createProduct(product); Product dbProduct = productRepository.getOne(createdProduct.getId()); assertEquals("productName", dbProduct.getName()); assertEquals(number("1.01"), dbProduct.getPrice()); assertEquals(number("0.1"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); } 

Le code de test doit être extrêmement simple et compréhensible en un coup d'œil. Si ce n'est pas le cas, la plupart des avantages des tests décrits dans la première section de l'article sont perdus. Il est recommandé de diviser le corps du test en trois parties qui peuvent être visuellement séparées les unes des autres: préparer les données, appeler la méthode de test, valider les résultats. Dans le même temps, il est très souhaitable que le code de test s'adapte à l'écran dans son ensemble.

Personnellement, cela me semble plus évident lorsque les valeurs de test de la section de préparation des données sont utilisées plus tard dans les vérifications. Alternativement, vous pouvez comparer explicitement des objets, par exemple comme ceci:

 assertEquals(product, dbProduct); 

Dans un autre test de mise à jour des informations produit ( updateProduct ), il est clair que la création de données est devenue un peu plus compliquée et pour maintenir l'intégrité visuelle des trois parties du test, elles sont séparées par deux sauts de ligne consécutifs:

 @Test public void updateProduct_productUpdated() { Product product = product("productName").build(); productRepository.save(product); Product updatedProduct = product("updatedName").price("1.1").discount("0.5").advertised(true).build(); updatedProduct.setId(product.getId()); productController.updateProduct(product.getId(), updatedProduct); Product dbProduct = productRepository.getOne(product.getId()); assertEquals("updatedName", dbProduct.getName()); assertEquals(number("1.1"), dbProduct.getPrice()); assertEquals(number("0.5"), dbProduct.getDiscount()); assertEquals(true, dbProduct.isAdvertised()); } 

Chacune des trois parties de la pâte peut être simplifiée. Pour la préparation des données, les générateurs de tests sont excellents, qui contiennent la logique de création d'objets qui est pratique à utiliser à partir de tests. Des appels de méthode trop complexes peuvent être effectués en méthodes auxiliaires à l'intérieur des classes de test, masquant certains des paramètres qui ne sont pas pertinents pour cette classe. Pour simplifier les vérifications complexes, vous pouvez également écrire des fonctions auxiliaires ou implémenter vos propres correspondants. L'essentiel de toutes ces simplifications est de ne pas perdre la visibilité du test: tout doit être clair en un coup d'œil sur la méthode principale, sans avoir besoin d'approfondir.

Constructeurs de tests

Les constructeurs de tests méritent une attention particulière. L'encapsulation de la logique de création d'objets simplifie la maintenance des tests. En particulier, le remplissage des champs du modèle qui ne sont pas pertinents pour ce test peut être masqué dans le générateur. Pour ce faire, vous n'avez pas à le créer directement, mais utilisez une méthode statique qui remplira les champs manquants avec des valeurs par défaut. Par exemple, si de nouveaux champs obligatoires apparaissent dans le modèle, ils peuvent être facilement ajoutés à cette méthode. Dans ProductBuilder cela ressemble à ceci:

 public static ProductBuilder product(String name) { return new ProductBuilder() .name(name) .advertised(false) .price("0.00"); } 

Nom du test

Il est impératif de comprendre ce qui est spécifiquement testé dans ce test. Pour plus de clarté, il est préférable de donner une réponse à cette question dans son titre. En utilisant les exemples de tests pour la méthode getProduct considérez la convention de dénomination utilisée:

 @Test public void getProduct_oneProductInDb_productReturned() { Product product = product("productName").build(); productRepository.save(product); Product result = productController.getProduct(product.getId()); assertEquals("productName", result.getName()); } @Test public void getProduct_twoProductsInDb_correctProductReturned() { Product product1 = product("product1").build(); Product product2 = product("product2").build(); productRepository.save(product1); productRepository.save(product2); Product result = productController.getProduct(product1.getId()); assertEquals("product1", result.getName()); } 

Dans le cas général, l'en-tête de la méthode de test se compose de trois parties, séparées par un soulignement: le nom de la méthode testée, le script et le résultat attendu. Cependant, personne n'a annulé le bon sens, et il peut être justifié d'omettre certaines parties du nom si elles ne sont pas nécessaires dans ce contexte (par exemple, un script dans un seul test pour créer un produit). Le but de cette dénomination est de s'assurer que l'essence de chaque test est compréhensible sans apprendre le code. Cela rend la fenêtre des résultats des tests aussi claire que possible, et c'est avec elle que le travail avec les tests commence généralement.



Conclusions

C’est tout. Pour la première fois, un ensemble minimal de quatre tests suffit pour tester les méthodes de la classe ProductController . En cas de détection de bugs, vous pouvez toujours ajouter les tests manquants. Dans le même temps, le nombre minimum de tests réduit considérablement le temps et les efforts nécessaires pour les prendre en charge. Ceci, à son tour, est essentiel dans le processus de mise en œuvre des tests, car les premiers tests sont généralement obtenus de mauvaise qualité et créent de nombreux problèmes inattendus. Dans le même temps, une telle suite de tests est largement suffisante pour recevoir les bonus décrits dans la première partie de l'article.

Il convient de noter que ces tests ne vérifient pas la couche Web de l'application, mais souvent cela n'est pas nécessaire. Si nécessaire, vous pouvez écrire des tests distincts pour la couche Web avec un stub au lieu de la base ( @WebMvcTest , MockMvc , @MockBean ) ou utiliser un serveur à part entière. Ce dernier peut compliquer le débogage et compliquer le travail avec les transactions, car le test ne peut pas contrôler la transaction du serveur. Un exemple d'un tel test d'intégration peut être trouvé dans la classe CustomerControllerServerIT .

Tests unitaires


Les tests unitaires présentent plusieurs avantages par rapport aux tests d'intégration:

  • Le démarrage prend des millisecondes;
  • Petite taille de l'unité testée;
  • Il est facile de mettre en œuvre la vérification d'un grand nombre d'options, car lorsque la méthode est appelée directement, la préparation des données est grandement simplifiée.

Malgré cela, les tests unitaires de par leur nature ne peuvent garantir l'opérabilité de l'application dans son ensemble et ne vous permettent pas d'éviter d'écrire des tests d'intégration. Si la logique de l'unité testée est simple, la duplication des tests d'intégration avec les tests unitaires n'apportera aucun avantage, mais ajoutera seulement plus de code à prendre en charge.

La seule classe de cet exemple qui mérite des tests unitaires est le BonusPointCalculator . Sa caractéristique distinctive est un grand nombre de branches de la logique métier. Par exemple, on suppose que l'acheteur reçoit des bonus de 10% du coût du produit, multipliés par pas plus de 2 multiplicateurs de la liste suivante:

  • Le produit coûte plus de 10 000 (× 4);
  • Le produit participe à une campagne publicitaire (× 3);
  • Le produit est le produit «préféré» du client (× 5);
  • Le client a un statut premium (× 2);
  • Si le client a un statut premium et achète un produit «préféré», au lieu des deux multiplicateurs indiqués, un (× 8) est utilisé.

Dans la vraie vie, bien sûr, il vaudrait la peine de concevoir un mécanisme universel flexible pour calculer ces bonus, mais pour simplifier l'exemple, nous nous limitons à une implémentation fixe. Le code de calcul du multiplicateur ressemble à ceci:

 private List<BigDecimal> calculateMultipliers(Customer customer, Product product) { List<BigDecimal> multipliers = new ArrayList<>(); if (customer.getFavProduct() != null && customer.getFavProduct().equals(product)) { if (customer.isPremium()) { multipliers.add(PREMIUM_FAVORITE_MULTIPLIER); } else { multipliers.add(FAVORITE_MULTIPLIER); } } else if (customer.isPremium()) { multipliers.add(PREMIUM_MULTIPLIER); } if (product.isAdvertised()) { multipliers.add(ADVERTISED_MULTIPLIER); } if (product.getPrice().compareTo(EXPENSIVE_THRESHOLD) >= 0) { multipliers.add(EXPENSIVE_MULTIPLIER); } return multipliers; } 

Un grand nombre d'options conduit au fait que deux ou trois tests d'intégration ne sont pas limités ici. Un ensemble minimaliste de tests unitaires est parfait pour déboguer une telle fonctionnalité.



La suite de tests correspondante se trouve dans la classe BonusPointCalculatorTest . En voici quelques uns:

 @Test public void calculate_oneProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").build(); assertEquals(expectedBonus, bonus); } @Test public void calculate_favProduct() { Product product = product("product").price("1.00").build(); Customer customer = customer("customer").favProduct(product).build(); Map<Product, Long> quantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").addMultiplier(FAVORITE_MULTIPLIER).build(); assertEquals(expectedBonus, bonus); } 

Il convient de noter que dans les tests, nous nous référons spécifiquement à l'API publique de la classe - la méthode de calculate . Tester un contrat de classe plutôt que son implémentation évite les pannes de tests dues à des changements non fonctionnels et à une refactorisation.

Enfin, lorsque nous avons vérifié la logique interne avec des tests unitaires, nous n'avons plus besoin de mettre tous ces détails en intégration. Dans ce cas, un test plus ou moins représentatif suffit, par exemple ceci:

 @Test public void calculateBonusPoints_twoProductTypes_correctValueCalculated() { Product product1 = product("product1").price("1.01").build(); Product product2 = product("product2").price("10.00").build(); productRepository.save(product1); productRepository.save(product2); Customer customer = customer("customer").build(); customerRepository.save(customer); Map<Long, Long> quantities = mapOf(product1.getId(), 1L, product2.getId(), 2L); BigDecimal bonus = customerController.calculateBonusPoints( new CalculateBonusPointsRequest("customer", quantities) ); BigDecimal bonusPointsProduct1 = bonusPoints("0.10").build(); BigDecimal bonusPointsProduct2 = bonusPoints("1.00").quantity(2).build(); BigDecimal expectedBonus = bonusPointsProduct1.add(bonusPointsProduct2); assertEquals(expectedBonus, bonus); } 

Comme dans le cas des tests d'intégration, l'ensemble de tests unitaires utilisé est très petit et ne garantit pas l'exactitude complète de l'application. Néanmoins, sa présence augmente considérablement la confiance dans le code, facilite le débogage et donne aux autres bonus listés dans la première partie de l'article.

Recommandations de mise en œuvre


J'espère que les sections précédentes ont été suffisantes pour convaincre au moins un développeur d'essayer de commencer à utiliser des tests dans son projet. Ce chapitre énumérera brièvement les principales recommandations qui aideront à éviter de graves problèmes et à réduire les coûts initiaux de mise en œuvre.

Essayez de commencer à implémenter les tests sur la nouvelle application. La rédaction des premiers tests dans un grand projet hérité sera beaucoup plus difficile et nécessitera plus de compétences que dans un projet fraîchement créé. Par conséquent, si possible, il est préférable de commencer avec une petite nouvelle application. Si de nouvelles applications à part entière ne sont pas attendues, vous pouvez essayer de développer un utilitaire utile à usage interne. L'essentiel est que la tâche soit plus ou moins réaliste - les exemples inventés ne donneront pas une expérience à part entière.

Configurez des tests réguliers. Si les tests ne sont pas exécutés régulièrement, ils cessent non seulement d'exécuter leur fonction principale - vérifier l'exactitude du code - mais aussi deviennent rapidement obsolètes. Par conséquent, il est extrêmement important de configurer au moins le pipeline CI minimum avec un lancement automatique des tests chaque fois que le code est mis à jour dans le référentiel.

Ne poursuivez pas le couvercle. Comme dans le cas de toute autre technologie, au début, les tests ne seront pas obtenus de la meilleure qualité. La littérature pertinente (liens à la fin de l'article) ou un mentor compétent peut vous aider ici, mais cela n'annule pas le besoin de cônes à farce. Les tests à cet égard sont similaires au reste du code: pour comprendre comment ils affecteront le projet, vous ne pouvez qu'après avoir vécu avec eux pendant un certain temps. Par conséquent, pour minimiser les dommages, la première fois est préférable de ne pas chasser le nombre et les beaux chiffres comme une couverture à cent pour cent. Au lieu de cela, vous devez vous limiter aux principaux scénarios positifs pour votre propre fonctionnalité d'application.

Ne vous laissez pas emporter par les tests unitaires. En poursuivant le thème «quantité vs qualité», il convient de noter que des tests unitaires honnêtes ne doivent pas être effectués pour la première fois, car cela peut facilement conduire à une spécification excessive de l'application. À son tour, cela deviendra un facteur inhibiteur sérieux dans les améliorations ultérieures de refactorisation et d'application. Les tests unitaires ne doivent être utilisés que s'il existe une logique complexe dans une classe ou un groupe de classes particulier, ce qui n'est pas pratique à vérifier au niveau de l'intégration.

Ne vous laissez pas emporter par les classes de stub et les méthodes d'application. Les talons (talon, maquette) sont un autre outil qui nécessite une approche équilibrée et le maintien d'un équilibre. D'une part, l'isolement complet de l'unité vous permet de vous concentrer sur la logique testée et de ne pas penser au reste du système. En revanche, cela nécessitera un temps de développement supplémentaire et, comme pour les tests unitaires, peut conduire à une spécification excessive du comportement.

Détachez les tests d'intégration des systèmes externes. Une erreur très courante dans les tests d'intégration est l'utilisation d'une base de données réelle, de files d'attente de messages et d'autres systèmes externes à l'application. Bien sûr, la possibilité d'exécuter un test dans un environnement réel est utile pour le débogage et le développement. De tels tests en petites quantités peuvent avoir un sens, en particulier pour une exécution interactive. Cependant, leur utilisation généralisée entraîne un certain nombre de problèmes:

  1. Pour exécuter les tests, vous devrez configurer l'environnement externe. Par exemple, installez une base de données sur chaque machine sur laquelle l'application sera assemblée. Cela rendra difficile pour les nouveaux développeurs d'entrer dans le projet et de configurer CI.
  2. L'état des systèmes externes peut varier sur différentes machines avant d'exécuter les tests. Par exemple, la base de données peut déjà contenir les tables dont l'application a besoin avec des données qui ne sont pas attendues dans le test. Cela entraînera des échecs imprévisibles dans les tests et leur élimination nécessitera un temps considérable.
  3. Si des travaux parallèles sont en cours sur plusieurs projets, l'influence non évidente de certains projets sur d'autres est possible. Par exemple, des paramètres de base de données spécifiques définis pour l'un des projets peuvent aider la fonctionnalité d'un autre projet à fonctionner correctement, qui, cependant, se brisera lors du lancement sur une base de données propre sur une autre machine.
  4. Les tests sont effectués pendant une longue période: un cycle complet peut atteindre des dizaines de minutes. Cela conduit au fait que les développeurs arrêtent d'exécuter des tests localement et ne regardent leurs résultats qu'après avoir envoyé les modifications au référentiel distant. Ce comportement annule la plupart des avantages des tests, qui ont été discutés dans la première partie de l'article.

Effacez le contexte entre les tests d'intégration. Souvent, pour accélérer le travail des tests d'intégration, vous devez réutiliser le même contexte entre eux. Même la documentation officielle de Spring fait une telle recommandation. En même temps, l'influence des tests les uns sur les autres doit être évitée. Puisqu'elles sont lancées dans un ordre arbitraire, la présence de telles connexions peut conduire à des erreurs irréprochables aléatoires. Pour éviter que cela ne se produise, les tests ne doivent laisser aucun changement de contexte. Par exemple, lors de l'utilisation d'une base de données, pour l'isolement, il suffit généralement d'annuler toutes les transactions validées dans le test. Si les modifications du contexte ne peuvent pas être évitées, vous pouvez configurer sa recréation à l'aide de l'annotation @DirtiesContext .

, . , - . , . , , — , .

. , , . , , .

TDD (Test-Driven Development). TDD , , . , , . , , .

, ?


, :

  1. ( )? .
  2. , ( , CI)? .
  3. ? .
  4. ? . , , .

, . , , - . — .

Conclusion


, . - , . , - . — , , -. , .

, , , !

GitHub

Source: https://habr.com/ru/post/fr427603/


All Articles