Réflexions sur des tests d'entreprise efficaces

Bonjour, Habr!

Récemment, nous sommes revenus à une étude approfondie du sujet des tests, et dans les plans prévisibles, nous avons même un excellent livre sur les tests unitaires. Dans le même temps, nous pensons que le contexte est important dans ce sujet comme nulle part ailleurs, c'est pourquoi nous proposons aujourd'hui une traduction de deux publications (combinées en une seule) publiées sur le blog d'un éminent spécialiste de Java EE Sebastian Dashner - à savoir, 1/6 et 2/6 de la série " Réflexions sur des tests d'entreprise efficaces. "

Les tests en entreprise sont un sujet qui n'a pas encore été examiné avec autant de détails que nous le souhaiterions. Il faut beaucoup de temps et d'efforts pour écrire et surtout pour supporter les tests, cependant, essayer de gagner du temps en abandonnant les tests n'est pas une option. Quels volumes de tâches, d'approches et de technologies de tests méritent d'être explorés afin d'augmenter l'efficacité des tests?

introduction


Quels que soient les différents types de tests et leur portée, le but de la préparation d'un ensemble de tests est de s'assurer sur ce matériel qu'en production notre application fonctionnera exactement comme prévu. Une telle motivation devrait être la principale pour vérifier si le système remplit la tâche, si nous considérons ce système du point de vue de l'utilisateur.

Étant donné que la durée d'attention et le changement de contexte sont des éléments dont il faut tenir compte, nous devons nous assurer que nos tests ne sont pas exécutés et testés depuis longtemps et que les résultats des tests sont prévisibles. Lors de l'écriture de code, une vérification rapide du code (réalisable en une seconde) est cruciale - cela garantit une productivité et une concentration élevées pendant le travail.
D'autre part, nous devons assurer un support de test. Le logiciel change très souvent, et avec une couverture substantielle du code avec des tests fonctionnels, chaque changement fonctionnel dans le code de production nécessitera un changement au niveau du test. Idéalement, le code de test ne devrait changer que lorsque la fonctionnalité, c'est-à-dire la logique métier, change, et non lors du nettoyage du code inutile et de la refactorisation. En général, les scénarios de test devraient inclure la possibilité de changements structurels non fonctionnels.

Lorsque nous considérons les différents domaines d'application des tests, la question se pose: lesquels de ces domaines valent le temps et les efforts? Par exemple, dans les applications de microservices, ainsi que dans tout système qui fournit un travail important sur la distribution et l'intégration de code, les tests d'intégration sont particulièrement importants, aidant à repousser les limites du système. Par conséquent, nous avons besoin d'un moyen efficace pour tester l'ensemble de l'application dans son ensemble pendant le développement local, tout en maintenant l'environnement et la structure de cette application sous la forme la plus proche possible de la production.

Principes et limites


Quelles que soient les solutions qui seront sélectionnées, définissons les principes et limitations suivants pour notre suite de tests:

  • , . , . , , .
  • , , . , , , . , , .
  • - . , , .
  • , , . : « HTTP- gRPC, JSON - enterprise-, ..?”.
  • , , -. API, DSL .
  • « », , , , , , “dev” debug () , dev Quarkus', Telepresence, watch-and-deploy (« ») .
  • . , , , , . .
  • , , -, , , . , , , .


Le test unitaire vérifie le comportement d'un seul module, généralement une classe, tandis que tous les facteurs externes qui ne sont pas liés à la structure du module sont ignorés ou simulés. Les tests unitaires doivent vérifier la logique métier des modules individuels, sans vérifier leur intégration ou configuration supplémentaire.

D'après mon expérience, la plupart des développeurs d'entreprise ont une assez bonne idée de la façon dont les tests unitaires sont compilés. Pour vous faire une idée, vous pouvez voir cet exemple dans mon projet de test de café .. Dans la plupart des projets, JUnit est utilisé en combinaison avec Mockito pour simuler des dépendances, et idéalement avec AssertJ pour définir efficacement des instructions lisibles. J'insiste toujours sur le fait que les tests unitaires peuvent être effectués sans extensions ou démarreurs spéciaux, c'est-à-dire avec le JUnit habituel. L'explication est simple: tout est question d'exécution, car nous avons besoin de pouvoir exécuter des centaines de tests en quelques millisecondes.

En règle générale, les tests unitaires s'exécutent très rapidement et il est facile d'en assembler des suites de tests complexes ou des workflows spéciaux, car ils sont simples à exécuter et n'imposent aucune restriction au cycle de vie du test.

Cependant, lorsque vous avez beaucoup de tests unitaires simulant les dépendances de la classe testée, il y a un inconvénient: ils sont étroitement liés à l'implémentation (cela s'applique particulièrement aux classes et aux méthodes), c'est pourquoi notre code est difficile à refactoriser. En d'autres termes, pour chaque acte de refactoring dans le code de production, il nécessite également des modifications du code de test. Dans le pire des cas, les développeurs commencent même à refuser partiellement le refactoring, simplement parce que c'est trop lourd et que la qualité du code dans le projet diminue rapidement. Idéalement, le développeur devrait être en mesure de refactoriser et de réorganiser les éléments, à condition que, pour cette raison, aucune modification de l'application ne soit visible par l'utilisateur. Les tests unitaires ne simplifient en aucun cas toujours la refactorisation du code de production.

Mais l'expérience suggère que les tests unitaires sont très efficaces pour vérifier le code qui est densément rempli de logique concise ou décrit la mise en œuvre d'une fonction spécifique, par exemple, un algorithme, et, en même temps, n'interagit pas très activement avec d'autres composants. Moins le code est complexe ou dense dans une classe particulière, moins sa complexité cyclomatique ou interagit activement avec d'autres composants, moins les tests unitaires seront efficaces lors du test de cette classe. Surtout dans les cas de microservices, dans lesquels la logique métier est relativement peu contenue, mais où une intégration étendue avec des systèmes externes est fournie, il n'est probablement pas nécessaire d'utiliser des tests unitaires dans de nombreux cas. Dans de tels systèmes, les modules individuels (à de rares exceptions près) contiennent généralement peu de logique spécialisée. Cela doit être pris en compte lors de la décisionce qui est plus approprié pour passer du temps et des efforts.

Tester des situations d'application


Pour faire face au problème de la liaison étroite des tests avec l'implémentation, vous pouvez essayer une approche légèrement différente pour étendre la portée des tests. Dans mon livre, j'ai écrit sur les tests de composants, car je ne pouvais pas trouver un meilleur terme; mais, en substance, dans ce cas, nous parlons de tester des situations appliquées.

Les tests de situation d'application sont des tests d'intégration qui fonctionnent au niveau du code, qui n'utilisent pas de conteneurs intégrés - ils sont abandonnés pour accélérer le lancement. Ils testent la logique métier de composants bien coordonnés, qui sont généralement utilisés dans un cas pratique spécifique, depuis la méthode des limites, puis jusqu'à tous les composants qui leur sont associés. L'intégration avec des systèmes externes, par exemple avec des bases de données, est imitée à l'aide de simulacres.

La construction de tels scénarios sans l'utilisation de technologies plus avancées qui connecteraient automatiquement les composants semble être un gros travail. Cependant, nous définissons des composants de test réutilisables, ce sont également des homologues de test qui étendent les composants en simulant, en connectant et en ajoutant également des configurations de test; tout cela est fait pour minimiser l'effort total requis pour le refactoring. L'objectif est de créer les seules responsabilités limitant le degré d'influence des changements sur une seule classe (ou plusieurs classes) dans le domaine des tests. En effectuant de tels travaux en vue de les réutiliser, nous réduisons la quantité totale de travail nécessaire, et une telle stratégie est justifiée lorsque le projet se développe, mais chaque composant ne nécessite que des réparations mineures, et ce travail est rapidement amorti.

Pour mieux imaginer tout cela, supposons que nous testons une classe qui décrit l'ordre du café. Cette classe comprend deux autres classes: CoffeeShopet OrderProcessor.



Les classes de tests doubles, CoffeeShopTestDoubleet OrderProcessorTestDoubleils *TDsont situés dans la zone de test du projet, où ils héritent des composants CoffeeShopet OrderProcessorsitués dans la zone principale du programme. Les homologues de test peuvent définir la logique de simulation et de connexion nécessaire et potentiellement étendre l'interface publique de la classe à l'aide des méthodes de simulation nécessaires dans cette application ou des méthodes de vérification.

Ce qui suit montre la classe double de test pour le composant CoffeeShop:

public class CoffeeShopTestDouble extends CoffeeShop {

    public CoffeeShopTestDouble(OrderProcessorTestDouble orderProcessorTestDouble) {
        entityManager = mock(EntityManager.class);
        orderProcessor = orderProcessorTestDouble;
    }

    public void verifyCreateOrder(Order order) {
        verify(entityManager).merge(order);
    }

    public void verifyProcessUnfinishedOrders() {
        verify(entityManager).createNamedQuery(Order.FIND_UNFINISHED, Order.class);
    }

    public void answerForUnfinishedOrders(List<Order> orders) {
        //     
    }
}

La classe double test peut accéder aux champs et aux constructeurs de la classe de base CoffeeShop pour établir les dépendances. Ici, sous la forme de jumeaux de test, des variantes d'autres composants sont également utilisées, en particulier, OrderProcessorTestDoubleelles sont nécessaires pour appeler des méthodes de simulation ou de vérification supplémentaires, qui font partie du cas pratique.

Les classes de doubles de test sont des composants réutilisables, chacun étant écrit une fois par portée de chaque projet, et est ensuite utilisé dans de nombreux cas pratiques:

class CoffeeShopTest {

    private CoffeeShopTestDouble coffeeShop;
    private OrderProcessorTestDouble orderProcessor;

    @BeforeEach
    void setUp() {
        orderProcessor = new OrderProcessorTestDouble();
        coffeeShop = new CoffeeShopTestDouble(orderProcessor);
    }

    @Test
    void testCreateOrder() {
        Order order = new Order();
        coffeeShop.createOrder(order);
        coffeeShop.verifyCreateOrder(order);
    }

    @Test
    void testProcessUnfinishedOrders() {
        List<Order> orders = Arrays.asList(...);
        coffeeShop.answerForUnfinishedOrders(orders);

        coffeeShop.processUnfinishedOrders();

        coffeeShop.verifyProcessUnfinishedOrders();
        orderProcessor.verifyProcessOrders(orders);
    }

}

Le test de composant vérifie le cas spécifique de la logique métier invoquée au point d'entrée, dans ce cas CoffeeShop. Ces tests sont obtenus de manière concise et lisible, car toutes les connexions et simulations sont effectuées dans des jumeaux de test séparés, et plus tard, ils peuvent utiliser des techniques de dépistage hautement spécialisées, telles que verifyProcessOrders().

Comme vous pouvez le voir, la classe de test étend la portée de la classe de production, vous permettant d'installer mokee et d'utiliser des méthodes qui vérifient le comportement. Bien qu'il semble que la mise en place d'un tel système demande beaucoup d'efforts, ces coûts sont rapidement amortis si, dans le cadre de l'ensemble du projet, nous avons de nombreux cas pratiques où les composants peuvent être réutilisés. Plus notre projet se développe, plus cette approche devient utile - surtout si vous faites attention au temps nécessaire pour terminer les tests. Tous nos cas de test sont toujours exécutés à l'aide de JUnit, et dans les plus brefs délais, ils sont exécutés par centaines.

C'est le principal avantage de cette approche: les tests de composants s'exécutent aussi rapidement que les tests unitaires réguliers, mais ils stimulent le refactoring du code de production, car des modifications doivent être apportées à un seul composant ou à quelques composants seulement. De plus, en améliorant les doublons de test avec un réglage expressif et des méthodes de test spécifiques à notre domaine, nous augmentons la lisibilité de notre code, facilitons son utilisation et éliminons le code stéréotypé dans les scripts de test.

All Articles